@financial-times/custom-code-component 2.0.1-alpha.11 → 2.0.1-alpha.6

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file
3
+ * Main component definition for custom-code-component
4
+ */
5
+ import type { ContentTree } from "@financial-times/content-tree";
6
+ import { BaseRenderer } from "../../ccc-sdk/src/renderers/BaseRenderer";
7
+ import Tracking from "./tracking";
8
+ import { Logger } from "./logger";
9
+ import { ComponentPath } from "./path";
10
+ export declare class FTCustomCodeComponent extends HTMLElement {
11
+ app?: typeof BaseRenderer.prototype.render;
12
+ mode: "closed" | "open";
13
+ RESERVED_ATTRS: Set<string>;
14
+ source?: string;
15
+ tracking?: Tracking;
16
+ lightRoot: ChildNode[];
17
+ component: ComponentPath;
18
+ log: Logger;
19
+ constructor();
20
+ connectedCallback(): Promise<void>;
21
+ emitError(error: Error): void;
22
+ disconnectedCallback(): void;
23
+ channel: MessageChannel;
24
+ onmessage(): void;
25
+ onunmount(root?: any): void;
26
+ onready(app: Promise<void>): Promise<void>;
27
+ postMessage(event: any): void;
28
+ mount(prerendered?: ShadowRoot | null): Promise<void>;
29
+ unmount(e: Error): void;
30
+ load(): Promise<(shadowRoot: ShadowRoot, attrs: any, ssr?: boolean) => {
31
+ unmount: (root: any) => void;
32
+ onmessage: {
33
+ (...data: any[]): void;
34
+ (message?: any, ...optionalParams: any[]): void;
35
+ };
36
+ ready: Promise<void>;
37
+ }>;
38
+ initTracking: () => Promise<void>;
39
+ }
40
+ export interface CustomCodeComponent extends ContentTree.Node {
41
+ type: "CustomCodeComponent";
42
+ path: string;
43
+ versionRange: string;
44
+ altText: string;
45
+ lastModified: string;
46
+ fallbackImage?: ContentTree.Image;
47
+ displayFallbackText: boolean;
48
+ layout: "in-line" | "mid-grid" | "full-grid" | "full-bleed";
49
+ attributes: {
50
+ [key: string]: string | boolean | undefined;
51
+ } | {
52
+ children?: CustomCodeComponent | Array<CustomCodeComponent>;
53
+ };
54
+ }
55
+ export type { FTCustomCodeComponent as CCCHTMLElement };
@@ -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,8 @@
1
+ type PropMap = {
2
+ [k: string]: any;
3
+ };
4
+ export declare function getTrace(el: Element, rootEl: Element): {
5
+ trace: PropMap[];
6
+ customContext: {};
7
+ };
8
+ export {};
@@ -1,3 +1 @@
1
- export declare const init: () => void;
2
-
3
- export { }
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,8 @@
1
+ /**
2
+ * @file
3
+ * Unit test for web component error handling
4
+ */
5
+ export declare const worker: import("msw/browser").SetupWorker;
6
+ export declare const it: import("vitest").TestAPI<{
7
+ worker: import("msw/browser").SetupWorker;
8
+ }>;
@@ -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,8 @@
1
+ import { BaseRenderer } from "~sdk/renderers/BaseRenderer";
2
+ export declare const generateReadableStream: ({ framework, props, renderer, }: {
3
+ framework: string;
4
+ renderer: BaseRenderer;
5
+ props: {
6
+ [key: string]: any;
7
+ };
8
+ }) => Promise<Response>;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @file
3
+ * Unit tests for the ComponentPath module
4
+ */
5
+ export {};
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @file
3
+ * Integration testing for server-side rendering
4
+ */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@financial-times/custom-code-component",
3
- "version": "2.0.1-alpha.11",
3
+ "version": "2.0.1-alpha.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
- "types": "./dist/CustomCodeComponent.d.ts",
8
- "default": "./dist/CustomCodeComponent.js"
7
+ "import": "./dist/custom-code-component.js",
8
+ "types": "./dist/custom-code-component.d.ts"
9
9
  }
10
10
  },
11
11
  "scripts": {
@@ -30,8 +30,8 @@
30
30
  "msw": "^2.7.3",
31
31
  "playwright": "^1.50.1",
32
32
  "typescript": "^5.2.2",
33
- "vite": "^6.3.5",
34
- "vite-plugin-dts": "^4.5.3",
33
+ "vite": "^4.4.9",
34
+ "vite-plugin-dts": "^3.6.0",
35
35
  "vitest": "^3.0.6"
36
36
  },
37
37
  "dependencies": {
@@ -34,17 +34,25 @@ export class FTCustomCodeComponent extends HTMLElement {
34
34
 
35
35
  source?: string;
36
36
  tracking?: Tracking;
37
- lightRoot?: ChildNode[];
38
- component?: ComponentPath;
37
+ lightRoot: ChildNode[];
38
+ component: ComponentPath;
39
39
 
40
40
  log: Logger;
41
41
 
42
42
  constructor() {
43
43
  super();
44
+
44
45
  this.log = new Logger({
45
46
  level: convertStringLogLevel(this.getAttribute("log")),
46
47
  });
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
+
48
56
  const supportsDeclarative =
49
57
  HTMLElement.prototype.hasOwnProperty("attachInternals");
50
58
 
@@ -57,12 +65,6 @@ export class FTCustomCodeComponent extends HTMLElement {
57
65
 
58
66
  async connectedCallback() {
59
67
  try {
60
- const path = this.getAttribute("path");
61
- const versionRange = this.getAttribute("version");
62
- this.component = ComponentPath.fromString(path, versionRange);
63
-
64
- // Backup the light root to restore in case of error
65
- this.lightRoot = Array.from(this.childNodes);
66
68
  this.app = await this.load();
67
69
  await this.mount();
68
70
  await this.initTracking();
@@ -127,64 +129,61 @@ export class FTCustomCodeComponent extends HTMLElement {
127
129
  try {
128
130
  this.mode =
129
131
  this.getAttribute("shadow-open") == "false" ? "closed" : "open";
130
- if (this.component) {
131
-
132
- this.dispatchEvent(
133
- new CCCConnectedEvent({
134
- component: this.component,
135
- source: this.source,
136
- })
137
- );
138
132
 
139
- this.dataset.cccReady = "true";
140
- delete this.dataset.cccError;
141
-
133
+ this.dispatchEvent(
134
+ new CCCConnectedEvent({
135
+ component: this.component,
136
+ source: this.source,
137
+ })
138
+ );
142
139
 
143
- if (!this.app) {
144
- throw new Error("CCC mounted without App");
145
- }
140
+ this.dataset.cccReady = "true";
141
+ delete this.dataset.cccError;
146
142
 
147
- const ssr = this.shadowRoot !== null;
148
- const shadow = this.shadowRoot ?? this.attachShadow({ mode: this.mode });
143
+ if (!this.app) {
144
+ throw new Error("CCC mounted without App");
145
+ }
149
146
 
150
- const data = JSON.parse(this.getAttribute("data-component-props")!);
147
+ const ssr = this.shadowRoot !== null;
148
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: this.mode });
151
149
 
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
- );
150
+ const data = JSON.parse(this.getAttribute("data-component-props")!);
157
151
 
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
- });
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
+ );
166
157
 
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
- }
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);
185
184
  } catch (err) {
186
185
  this.log.info(
187
- `<custom-code-component> uncaught error during mount from ${this.component?.toString()}`
186
+ `<custom-code-component> uncaught error during mount from ${this.component.toString()}`
188
187
  );
189
188
  this.log.error(err);
190
189
 
@@ -195,20 +194,15 @@ export class FTCustomCodeComponent extends HTMLElement {
195
194
  // Called in top-level error handler
196
195
  // Replace shadow root with light DOM children on error
197
196
  unmount(e: Error) {
198
- if (this.lightRoot) {
199
- this.onunmount();
200
- this.shadowRoot?.replaceChildren(...this.lightRoot);
201
-
202
- if (!this.dataset.cccError)
203
- this.dataset.cccError = kebabize(e.name.replace("CCC", ""));
204
- delete this.dataset.cccReady;
205
- }
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;
206
203
  }
207
204
 
208
205
  async load() {
209
- if (!this.component) {
210
- throw new Error("No path found")
211
- }
212
206
  const path = this.getAttribute("path");
213
207
  const componentVersionRange = this.getAttribute("version");
214
208
  const timeout = Number(this.getAttribute("load-timeout") || 10000);
@@ -231,7 +225,7 @@ export class FTCustomCodeComponent extends HTMLElement {
231
225
  this.log.error("CCC import timeout error");
232
226
  reject(
233
227
  new CCCTimeoutError({
234
- component: this.component!,
228
+ component: this.component,
235
229
  source: this.source,
236
230
  })
237
231
  );
@@ -247,7 +241,7 @@ export class FTCustomCodeComponent extends HTMLElement {
247
241
  throw new CCCImportError(
248
242
  "No component renderer default export found",
249
243
  {
250
- component: this.component!,
244
+ component: this.component,
251
245
  source: this.source,
252
246
  }
253
247
  );
@@ -258,7 +252,7 @@ export class FTCustomCodeComponent extends HTMLElement {
258
252
  if (e instanceof Error && !(e instanceof CCCImportError)) {
259
253
  reject(
260
254
  new CCCImportError(e.message, {
261
- component: this.component!,
255
+ component: this.component,
262
256
  source: this.source,
263
257
  })
264
258
  );
@@ -269,7 +263,7 @@ export class FTCustomCodeComponent extends HTMLElement {
269
263
  } else {
270
264
  clearTimeout(to);
271
265
  throw new CCCImportError(`Unable to mount ${path}`, {
272
- component: this.component!,
266
+ component: this.component,
273
267
  source: this.source,
274
268
  });
275
269
  }
@@ -8,16 +8,15 @@ import { isLocalEnv, isSafeTestEnv, isSparkEnv } from "./util"
8
8
  * @returns A `URL` object if valid and safe, or `undefined` if invalid.
9
9
  */
10
10
  export function assignTestURL(testEnv: string | null): URL | undefined {
11
- if (testEnv === null) {
11
+ if (!testEnv) {
12
12
  return
13
13
  }
14
-
15
14
  let testUrl
16
15
  const defaultTestUrl = new URL('http://localhost:5173')
17
16
 
18
17
  try {
19
18
  if (typeof testEnv === 'string') {
20
- if ((testEnv === '' || testEnv.toLowerCase() === 'true') && isLocalEnv()) {
19
+ if (testEnv === 'true' && isLocalEnv()) {
21
20
  testUrl = defaultTestUrl
22
21
  } else {
23
22
  const hasProtocol = testEnv.startsWith('http://') || testEnv.startsWith('https://')
@@ -67,6 +66,7 @@ export async function useComponentTestEnv(testUrl?: URL): Promise<boolean> {
67
66
  }, { once: true });
68
67
  });
69
68
  } catch (err) {
69
+ console.error("WebSocket creation failed:", err);
70
70
  return Promise.resolve(false);
71
71
  }
72
72
  }