@player-ui/player 1.1.0-next.2 → 1.1.0-next.3

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/package.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "types"
7
7
  ],
8
8
  "name": "@player-ui/player",
9
- "version": "1.1.0-next.2",
9
+ "version": "1.1.0-next.3",
10
10
  "main": "dist/cjs/index.cjs",
11
11
  "dependencies": {
12
- "@player-ui/partial-match-registry": "1.1.0-next.2",
13
- "@player-ui/make-flow": "1.1.0-next.2",
14
- "@player-ui/types": "1.1.0-next.2",
12
+ "@player-ui/partial-match-registry": "1.1.0-next.3",
13
+ "@player-ui/make-flow": "1.1.0-next.3",
14
+ "@player-ui/types": "1.1.0-next.3",
15
15
  "@types/dlv": "^1.1.4",
16
16
  "dequal": "^2.0.2",
17
17
  "dlv": "^1.1.3",
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test, vitest } from "vitest";
2
+ import { Player } from "..";
3
+ import type { Flow, PlayerPlugin, InProgressState } from "..";
4
+
5
+ /** Minimal Flow used to short-circuit setupFlow. */
6
+ const flowFor = (id: string): Flow => ({
7
+ id,
8
+ views: [{ id: "v1", type: "text", value: "hi" }],
9
+ data: {},
10
+ navigation: {
11
+ BEGIN: "F",
12
+ F: {
13
+ startState: "VIEW_1",
14
+ VIEW_1: {
15
+ state_type: "VIEW",
16
+ ref: "v1",
17
+ transitions: { "*": "END" },
18
+ },
19
+ END: { state_type: "END", outcome: "done" },
20
+ },
21
+ },
22
+ });
23
+
24
+ describe("transformContent hook", () => {
25
+ test("default format 'player' passes the payload through unchanged", async () => {
26
+ const player = new Player();
27
+ const flow = flowFor("plain");
28
+ player.start(flow);
29
+ const state = player.getState() as InProgressState;
30
+ expect(state.flow).toBe(flow);
31
+ });
32
+
33
+ test("a plugin tapping for a custom format transforms the payload", async () => {
34
+ const greetPlugin: PlayerPlugin = {
35
+ name: "greet",
36
+ apply(p) {
37
+ p.hooks.transformContent.tap("greet", (content, meta) => {
38
+ if (meta.format !== "greet") return undefined;
39
+ const { message } = content as { message: string };
40
+ return { ...flowFor("greet"), data: { message } };
41
+ });
42
+ },
43
+ };
44
+
45
+ const player = new Player({ plugins: [greetPlugin] });
46
+ player.start({ message: "Hello world" }, { format: "greet" });
47
+
48
+ const state = player.getState() as InProgressState;
49
+ await vitest.waitFor(() =>
50
+ expect(state.controllers.view.currentView?.lastUpdate).toBeDefined(),
51
+ );
52
+ expect(state.controllers.data.get("message")).toBe("Hello world");
53
+ });
54
+
55
+ test("fires before resolveFlowContent", async () => {
56
+ const order: string[] = [];
57
+
58
+ const orderPlugin: PlayerPlugin = {
59
+ name: "order",
60
+ apply(p) {
61
+ p.hooks.transformContent.tap("order", () => {
62
+ order.push("transformContent");
63
+ // Return undefined so Player's default "player" handler claims it.
64
+ return undefined;
65
+ });
66
+ p.hooks.resolveFlowContent.tap("order", (c) => {
67
+ order.push("resolveFlowContent");
68
+ return c;
69
+ });
70
+ },
71
+ };
72
+
73
+ const player = new Player({ plugins: [orderPlugin] });
74
+ player.start(flowFor("order"));
75
+
76
+ expect(order).toEqual(["transformContent", "resolveFlowContent"]);
77
+ });
78
+
79
+ test("multiple format plugins coexist — only matching one transforms", async () => {
80
+ const aPlugin: PlayerPlugin = {
81
+ name: "a",
82
+ apply(p) {
83
+ p.hooks.transformContent.tap("a", (c, meta) =>
84
+ meta.format === "a" ? flowFor("from-a") : undefined,
85
+ );
86
+ },
87
+ };
88
+ const bPlugin: PlayerPlugin = {
89
+ name: "b",
90
+ apply(p) {
91
+ p.hooks.transformContent.tap("b", (c, meta) =>
92
+ meta.format === "b" ? flowFor("from-b") : undefined,
93
+ );
94
+ },
95
+ };
96
+
97
+ const player = new Player({ plugins: [aPlugin, bPlugin] });
98
+ player.start({ raw: true }, { format: "b" });
99
+
100
+ const state = player.getState() as InProgressState;
101
+ expect(state.flow.id).toBe("from-b");
102
+ });
103
+
104
+ test("version is plumbed from StartOptions to the hook's meta arg", () => {
105
+ const versions: Array<string | undefined> = [];
106
+ const probe: PlayerPlugin = {
107
+ name: "probe",
108
+ apply(p) {
109
+ p.hooks.transformContent.tap("probe", (c, meta) => {
110
+ versions.push(meta.version);
111
+ // For format !== "demo", return undefined so the default path runs.
112
+ if (meta.format !== "demo") return undefined;
113
+ return flowFor("demo");
114
+ });
115
+ },
116
+ };
117
+
118
+ const player = new Player({ plugins: [probe] });
119
+ player.start({ x: 1 }, { format: "demo", version: "2" });
120
+ player.start({ x: 1 }, { format: "demo", version: "1" });
121
+ player.start(flowFor("noversion"));
122
+
123
+ expect(versions).toEqual(["2", "1", undefined]);
124
+ });
125
+
126
+ test("errors when no tap transforms an unknown format into a Flow", async () => {
127
+ const player = new Player();
128
+ const result = player.start({ some: "content" }, { format: "unknown" });
129
+ await expect(result).rejects.toThrow(/format "unknown"/);
130
+ });
131
+ });
@@ -12,7 +12,7 @@ export class ReadOnlyDataController
12
12
  this.controller = controller;
13
13
  }
14
14
 
15
- get(binding: BindingLike, options?: DataModelOptions | undefined) {
15
+ get(binding: BindingLike, options?: DataModelOptions | undefined): any {
16
16
  return this.controller.get(binding, options);
17
17
  }
18
18
  }
package/src/player.ts CHANGED
@@ -3,7 +3,7 @@ import deferred from "p-defer";
3
3
  import type { Flow, FlowResult } from "@player-ui/types";
4
4
  import queueMicrotask from "queue-microtask";
5
5
 
6
- import { SyncHook, SyncWaterfallHook } from "tapable-ts";
6
+ import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
7
7
  import type { Logger } from "./logger";
8
8
  import { TapableLogger } from "./logger";
9
9
  import type { ExpressionType } from "./expressions";
@@ -29,6 +29,8 @@ import type {
29
29
  CompletedState,
30
30
  ErrorState,
31
31
  PlayerHooks,
32
+ ContentMeta,
33
+ StartOptions,
32
34
  } from "./types";
33
35
  import { NOT_STARTED_STATE } from "./types";
34
36
 
@@ -125,6 +127,7 @@ export class Player {
125
127
  onStart: new SyncHook<[Flow]>(),
126
128
  onEnd: new SyncHook<[]>(),
127
129
  resolveFlowContent: new SyncWaterfallHook<[Flow]>(),
130
+ transformContent: new SyncBailHook<[unknown, ContentMeta], Flow>(),
128
131
  };
129
132
 
130
133
  constructor(config?: PlayerConfigOptions) {
@@ -141,6 +144,13 @@ export class Player {
141
144
  this.config.plugins?.forEach((plugin) => {
142
145
  plugin.apply(this);
143
146
  });
147
+
148
+ // Default content handler: a `"player"`-format payload is already a `Flow`.
149
+ // Registered after plugins so a format plugin gets first claim on the bail
150
+ // hook; this is the fallback that lets `start()` require a `Flow` out.
151
+ this.hooks.transformContent.tap("player", (payload, meta) =>
152
+ meta.format === "player" ? (payload as Flow) : undefined,
153
+ );
144
154
  }
145
155
 
146
156
  /** Returns currently registered plugins */
@@ -513,8 +523,21 @@ export class Player {
513
523
  };
514
524
  }
515
525
 
516
- public async start(payload: Flow): Promise<CompletedState> {
517
- const ref = Symbol(payload?.id ?? "payload");
526
+ public async start(
527
+ payload: unknown,
528
+ options?: StartOptions,
529
+ ): Promise<CompletedState> {
530
+ const meta: ContentMeta = {
531
+ format: options?.format ?? "player",
532
+ version: options?.version,
533
+ };
534
+ const flow = this.hooks.transformContent.call(payload, meta);
535
+ if (!flow) {
536
+ throw new Error(
537
+ `Player.start received content with format "${meta.format}" that no plugin transformed into a Flow.`,
538
+ );
539
+ }
540
+ const ref = Symbol(flow.id ?? "payload");
518
541
 
519
542
  /** A check to avoid updating the state for a flow that's not the current one */
520
543
  const maybeUpdateState = <T extends PlayerFlowState>(newState: T) => {
@@ -537,7 +560,7 @@ export class Player {
537
560
  });
538
561
 
539
562
  try {
540
- const { state, start } = this.setupFlow(payload);
563
+ const { state, start } = this.setupFlow(flow);
541
564
  this.setState({
542
565
  ref,
543
566
  ...state,
@@ -564,7 +587,7 @@ export class Player {
564
587
  const errorState: ErrorState = {
565
588
  status: "error",
566
589
  ref,
567
- flow: payload,
590
+ flow,
568
591
  error,
569
592
  };
570
593
 
package/src/types.ts CHANGED
@@ -11,9 +11,39 @@ import type {
11
11
  ErrorController,
12
12
  } from "./controllers";
13
13
  import type { ReadOnlyDataController } from "./controllers/data/utils";
14
- import { SyncHook, SyncWaterfallHook } from "tapable-ts";
14
+ import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
15
15
  import { ViewInstance } from "./view";
16
16
 
17
+ /**
18
+ * Metadata describing the incoming content to `Player.start()`. Passed
19
+ * alongside the raw payload through the `transformContent` hook so plugins
20
+ * can decide whether to claim/convert it.
21
+ */
22
+ export interface ContentMeta {
23
+ /**
24
+ * Content format identifier. `"player"` (default) means the input is
25
+ * already a `Flow` and needs no conversion. Anything else is a free-form
26
+ * string a plugin can recognize.
27
+ */
28
+ format: string;
29
+ /**
30
+ * Optional format version (e.g., `"0.9"`). Free-form — plugins decide the
31
+ * convention. Lets a single format plugin dispatch across major/minor
32
+ * versions without the caller picking a different `format` string.
33
+ */
34
+ version?: string;
35
+ }
36
+
37
+ /**
38
+ * Options passed to `Player.start()` alongside the payload.
39
+ */
40
+ export interface StartOptions {
41
+ /** Identifier of the input content's format. Default: `"player"`. */
42
+ format?: string;
43
+ /** Optional content-format version. See `ContentMeta.version`. */
44
+ version?: string;
45
+ }
46
+
17
47
  /**
18
48
  * Public Player Hooks
19
49
  */
@@ -47,6 +77,16 @@ export interface PlayerHooks {
47
77
  [Flow<Asset<string>>],
48
78
  Record<string, any>
49
79
  >;
80
+ /**
81
+ * Transform raw input content into a Player `Flow` before any state is set
82
+ * up. Fires at the top of `Player.start()` — after plugins are applied,
83
+ * before `resolveFlowContent`. Being a bail hook, taps inspect `meta.format`
84
+ * (and optionally `meta.version`) and return a `Flow` to claim the content;
85
+ * returning `undefined` passes to the next tap. Player registers a default
86
+ * tap for the `"player"` format that returns the payload as-is, so the hook
87
+ * is guaranteed to yield a `Flow` and `start()` needs no type-casting.
88
+ */
89
+ transformContent: SyncBailHook<[unknown, ContentMeta], Flow<Asset<string>>>;
50
90
  }
51
91
 
52
92
  /** The status for a flow's execution state */
package/types/player.d.ts CHANGED
@@ -1,8 +1,7 @@
1
- import type { Flow } from "@player-ui/types";
2
1
  import type { Logger } from "./logger";
3
2
  import { TapableLogger } from "./logger";
4
3
  import { ConstantsController } from "./controllers";
5
- import type { PlayerFlowState, CompletedState, PlayerHooks } from "./types";
4
+ import type { PlayerFlowState, CompletedState, PlayerHooks, StartOptions } from "./types";
6
5
  /**
7
6
  Variables injected at build time
8
7
  */
@@ -75,7 +74,7 @@ export declare class Player {
75
74
  private setState;
76
75
  /** Start Player with the given flow */
77
76
  private setupFlow;
78
- start(payload: Flow): Promise<CompletedState>;
77
+ start(payload: unknown, options?: StartOptions): Promise<CompletedState>;
79
78
  }
80
79
  export {};
81
80
  //# sourceMappingURL=player.d.ts.map
package/types/types.d.ts CHANGED
@@ -5,8 +5,36 @@ import type { ExpressionEvaluator } from "./expressions";
5
5
  import type { Logger } from "./logger";
6
6
  import type { ViewController, DataController, ValidationController, FlowController, ErrorController } from "./controllers";
7
7
  import type { ReadOnlyDataController } from "./controllers/data/utils";
8
- import { SyncHook, SyncWaterfallHook } from "tapable-ts";
8
+ import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
9
9
  import { ViewInstance } from "./view";
10
+ /**
11
+ * Metadata describing the incoming content to `Player.start()`. Passed
12
+ * alongside the raw payload through the `transformContent` hook so plugins
13
+ * can decide whether to claim/convert it.
14
+ */
15
+ export interface ContentMeta {
16
+ /**
17
+ * Content format identifier. `"player"` (default) means the input is
18
+ * already a `Flow` and needs no conversion. Anything else is a free-form
19
+ * string a plugin can recognize.
20
+ */
21
+ format: string;
22
+ /**
23
+ * Optional format version (e.g., `"0.9"`). Free-form — plugins decide the
24
+ * convention. Lets a single format plugin dispatch across major/minor
25
+ * versions without the caller picking a different `format` string.
26
+ */
27
+ version?: string;
28
+ }
29
+ /**
30
+ * Options passed to `Player.start()` alongside the payload.
31
+ */
32
+ export interface StartOptions {
33
+ /** Identifier of the input content's format. Default: `"player"`. */
34
+ format?: string;
35
+ /** Optional content-format version. See `ContentMeta.version`. */
36
+ version?: string;
37
+ }
10
38
  /**
11
39
  * Public Player Hooks
12
40
  */
@@ -39,6 +67,16 @@ export interface PlayerHooks {
39
67
  resolveFlowContent: SyncWaterfallHook<[
40
68
  Flow<Asset<string>>
41
69
  ], Record<string, any>>;
70
+ /**
71
+ * Transform raw input content into a Player `Flow` before any state is set
72
+ * up. Fires at the top of `Player.start()` — after plugins are applied,
73
+ * before `resolveFlowContent`. Being a bail hook, taps inspect `meta.format`
74
+ * (and optionally `meta.version`) and return a `Flow` to claim the content;
75
+ * returning `undefined` passes to the next tap. Player registers a default
76
+ * tap for the `"player"` format that returns the payload as-is, so the hook
77
+ * is guaranteed to yield a `Flow` and `start()` needs no type-casting.
78
+ */
79
+ transformContent: SyncBailHook<[unknown, ContentMeta], Flow<Asset<string>>>;
42
80
  }
43
81
  /** The status for a flow's execution state */
44
82
  export type PlayerFlowStatus = "not-started" | "in-progress" | "completed" | "error";