@decocms/blocks 7.1.1 → 7.2.0

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.1.1",
3
+ "version": "7.2.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework-agnostic core: CMS block resolution, section registry, matchers, portable SDK utilities",
6
6
  "repository": {
@@ -21,6 +21,15 @@
21
21
  "./matchers/builtins": "./src/matchers/builtins.ts",
22
22
  "./matchers/posthog": "./src/matchers/posthog.ts",
23
23
  "./matchers/override": "./src/matchers/override.ts",
24
+ "./flags/audience": "./src/flags/audience.ts",
25
+ "./flags/everyone": "./src/flags/everyone.ts",
26
+ "./flags/flag": "./src/flags/flag.ts",
27
+ "./flags/types": "./src/flags/types.ts",
28
+ "./flags/multivariate": "./src/flags/multivariate.ts",
29
+ "./flags/multivariate/image": "./src/flags/multivariate/image.ts",
30
+ "./flags/multivariate/message": "./src/flags/multivariate/message.ts",
31
+ "./flags/multivariate/page": "./src/flags/multivariate/page.ts",
32
+ "./flags/multivariate/section": "./src/flags/multivariate/section.ts",
24
33
  "./middleware": "./src/middleware/index.ts",
25
34
  "./middleware/healthMetrics": "./src/middleware/healthMetrics.ts",
26
35
  "./middleware/hydrationContext": "./src/middleware/hydrationContext.ts",
@@ -0,0 +1,56 @@
1
+ import type { FlagObj, Matcher } from "./types";
2
+ import Flag from "./flag";
3
+
4
+ /**
5
+ * @title Site Route
6
+ * @titleBy pathTemplate
7
+ */
8
+ export interface Route {
9
+ pathTemplate: string;
10
+ /**
11
+ * @description if true so the path will be checked against the coming from request instead of using urlpattern.
12
+ */
13
+ isHref?: boolean;
14
+ handler: {
15
+ value: unknown;
16
+ };
17
+ /**
18
+ * @title Priority
19
+ * @description higher priority means that this route will be used in favor of other routes with less or none priority
20
+ */
21
+ highPriority?: boolean;
22
+ }
23
+
24
+ /**
25
+ * @title Routes
26
+ * @description Used to configure your site routes
27
+ */
28
+ export type Routes = Route[];
29
+
30
+ /**
31
+ * @titleBy name
32
+ */
33
+ export interface Audience {
34
+ matcher: Matcher;
35
+ /**
36
+ * @title The audience name (will be used on cookies).
37
+ * @description Add a meaningful short word for the audience name.
38
+ * @minLength 3
39
+ * @pattern ^[A-Za-z0-9_-]+$
40
+ */
41
+ name: string;
42
+ routes?: Routes;
43
+ }
44
+
45
+ /**
46
+ * @title Audience
47
+ * @description Select routes based on the matched audience.
48
+ */
49
+ export default function Audience({ matcher, routes, name }: Audience): FlagObj<Route[]> {
50
+ return Flag<Route[]>({
51
+ matcher,
52
+ true: routes ?? [],
53
+ false: [],
54
+ name,
55
+ });
56
+ }
@@ -0,0 +1,29 @@
1
+ import type { FlagObj, Matcher } from "./types";
2
+ import Audience, { type Route, type Routes } from "./audience";
3
+
4
+ /**
5
+ * Ported from apps-start's `website/matchers/always.ts`. That file existed
6
+ * as its own block file only because deco-cx's old manifest system
7
+ * discovered matchers by scanning files; this codebase's matcher/flag
8
+ * mechanisms aren't file-scanned, so it's inlined here as its sole
9
+ * consumer. (Its `cacheable = true` export was manifest-generation
10
+ * metadata for that old system with no runtime consumer — dropped, same
11
+ * as the rest of the `date`/matcher ports in `../matchers/builtins.ts`.)
12
+ */
13
+ const MatchAlways: Matcher = () => true;
14
+
15
+ export interface EveryoneConfig {
16
+ routes?: Routes;
17
+ }
18
+
19
+ /**
20
+ * @title Audience Everyone
21
+ * @description Always match regardless of the current user
22
+ */
23
+ export default function Everyone({ routes }: EveryoneConfig): FlagObj<Route[]> {
24
+ return Audience({
25
+ matcher: MatchAlways,
26
+ routes: routes ?? [],
27
+ name: "Everyone",
28
+ });
29
+ }
@@ -0,0 +1,15 @@
1
+ import type { FlagObj } from "./types";
2
+
3
+ export type Props<T> = FlagObj<T>;
4
+
5
+ /**
6
+ * @title Flag
7
+ */
8
+ export default function Flag<T>({ matcher, name, true: T, false: F }: Props<T>): FlagObj<T> {
9
+ return {
10
+ matcher,
11
+ true: T,
12
+ false: F,
13
+ name,
14
+ };
15
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import Audience from "./audience";
3
+ import Everyone from "./everyone";
4
+ import Flag from "./flag";
5
+ import type { Matcher } from "./types";
6
+ import multivariate from "./utils/multivariate";
7
+ import ImageVariants from "./multivariate/image";
8
+ import MessageVariants from "./multivariate/message";
9
+ import PageVariants from "./multivariate/page";
10
+ import SectionVariants from "./multivariate/section";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // flag.ts
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("Flag", () => {
17
+ it("returns a FlagObj with the same values", () => {
18
+ const matcher: Matcher = () => true;
19
+ const result = Flag({
20
+ matcher,
21
+ true: "variant-a",
22
+ false: "variant-b",
23
+ name: "test-flag",
24
+ });
25
+
26
+ expect(result.matcher).toBe(matcher);
27
+ expect(result.true).toBe("variant-a");
28
+ expect(result.false).toBe("variant-b");
29
+ expect(result.name).toBe("test-flag");
30
+ });
31
+
32
+ it("preserves complex true/false values", () => {
33
+ const routes = [{ pathTemplate: "/*", handler: { value: {} } }];
34
+ const result = Flag<typeof routes>({
35
+ matcher: () => false,
36
+ true: routes,
37
+ false: [],
38
+ name: "routes-flag",
39
+ });
40
+
41
+ expect(result.true).toBe(routes);
42
+ expect(result.false).toEqual([]);
43
+ });
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // audience.ts
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("Audience", () => {
51
+ it("returns FlagObj with routes as true branch and empty as false", () => {
52
+ const matcher: Matcher = () => true;
53
+ const routes = [{ pathTemplate: "/products/*", handler: { value: {} } }];
54
+
55
+ const result = Audience({ matcher, name: "vip-users", routes });
56
+
57
+ expect(result.name).toBe("vip-users");
58
+ expect(result.true).toEqual(routes);
59
+ expect(result.false).toEqual([]);
60
+ });
61
+
62
+ it("defaults routes to empty array when not provided", () => {
63
+ const result = Audience({ matcher: () => false, name: "empty" });
64
+
65
+ expect(result.true).toEqual([]);
66
+ expect(result.false).toEqual([]);
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // everyone.ts
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe("Everyone", () => {
75
+ it("creates a flag named Everyone that always matches", () => {
76
+ const routes = [{ pathTemplate: "/*", handler: { value: {} } }];
77
+ const result = Everyone({ routes });
78
+
79
+ expect(result.name).toBe("Everyone");
80
+ expect(result.true).toEqual(routes);
81
+ expect(result.false).toEqual([]);
82
+ // The matcher should be MatchAlways which returns true
83
+ expect(result.matcher({} as any)).toBe(true);
84
+ });
85
+
86
+ it("works with no routes", () => {
87
+ const result = Everyone({});
88
+
89
+ expect(result.name).toBe("Everyone");
90
+ expect(result.true).toEqual([]);
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // multivariate
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("multivariate", () => {
99
+ it("returns the variants as-is", () => {
100
+ const variants = [
101
+ { value: "A", weight: 0.5 },
102
+ { value: "B", weight: 0.5 },
103
+ ];
104
+
105
+ const result = multivariate({ variants });
106
+
107
+ expect(result.variants).toBe(variants);
108
+ expect(result.variants).toHaveLength(2);
109
+ });
110
+
111
+ it("supports variants with matchers", () => {
112
+ const matcher: Matcher = () => true;
113
+ const variants = [{ value: "control", matcher }, { value: "default" }];
114
+
115
+ const result = multivariate({ variants });
116
+
117
+ expect(result.variants[0].matcher).toBe(matcher);
118
+ expect(result.variants[1].matcher).toBeUndefined();
119
+ });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // multivariate/* (Image/Message/Page/Section variants) + the multivariate.ts
124
+ // re-export (`./multivariate.ts` re-exports `./multivariate/page.ts`'s
125
+ // default, so `PageVariants` doubles as the coverage for that re-export).
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe("multivariate/*", () => {
129
+ it("Image delegates to multivariate", () => {
130
+ const variants = [{ value: "a.png" }, { value: "b.png" }];
131
+ expect(ImageVariants({ variants })).toEqual({ variants });
132
+ });
133
+
134
+ it("Message delegates to multivariate", () => {
135
+ const variants = [{ value: "hello" }, { value: "world" }];
136
+ expect(MessageVariants({ variants })).toEqual({ variants });
137
+ });
138
+
139
+ it("Page delegates to multivariate", () => {
140
+ const variants = [{ value: [] }, { value: [] }];
141
+ expect(PageVariants({ variants })).toEqual({ variants });
142
+ });
143
+
144
+ it("Section delegates to multivariate", () => {
145
+ const variants = [{ value: {} }, { value: {} }];
146
+ expect(SectionVariants({ variants })).toEqual({ variants });
147
+ });
148
+ });
@@ -0,0 +1,11 @@
1
+ import type { ImageWidget, MultivariateFlag } from "../types";
2
+ import multivariate, { type MultivariateProps } from "../utils/multivariate";
3
+
4
+ /**
5
+ * @title Image Variants
6
+ */
7
+ export default function Image(
8
+ props: MultivariateProps<ImageWidget>,
9
+ ): MultivariateFlag<ImageWidget> {
10
+ return multivariate(props);
11
+ }
@@ -0,0 +1,11 @@
1
+ import type { MultivariateFlag } from "../types";
2
+ import multivariate, { type MultivariateProps } from "../utils/multivariate";
3
+
4
+ export type Message = string;
5
+
6
+ /**
7
+ * @title Message Variants
8
+ */
9
+ export default function Message(props: MultivariateProps<Message>): MultivariateFlag<Message> {
10
+ return multivariate(props);
11
+ }
@@ -0,0 +1,16 @@
1
+ import type { MultivariateFlag } from "../types";
2
+ import multivariate, { type MultivariateProps } from "../utils/multivariate";
3
+
4
+ /**
5
+ * Section type placeholder — the actual Section type is defined by the framework.
6
+ */
7
+ type Section = unknown;
8
+
9
+ /**
10
+ * @title Page Variants
11
+ */
12
+ export default function PageVariants(
13
+ props: MultivariateProps<Section[]>,
14
+ ): MultivariateFlag<Section[]> {
15
+ return multivariate(props);
16
+ }
@@ -0,0 +1,16 @@
1
+ import type { MultivariateFlag } from "../types";
2
+ import multivariate, { type MultivariateProps } from "../utils/multivariate";
3
+
4
+ /**
5
+ * Section type placeholder — the actual Section type is defined by the framework.
6
+ */
7
+ type Section = unknown;
8
+
9
+ /**
10
+ * @title Section Variants
11
+ */
12
+ export default function SectionVariants(
13
+ props: MultivariateProps<Section>,
14
+ ): MultivariateFlag<Section> {
15
+ return multivariate(props);
16
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./multivariate/page";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Types for the feature-flag primitives in this subpath.
3
+ *
4
+ * Ported from apps-start's `website/types.ts` (the flag/matcher slice only).
5
+ * These are intentionally self-contained: `Matcher`/`MatchContext` here are
6
+ * a *function-composition* matcher — a plain `(ctx) => boolean` imported and
7
+ * invoked directly by TS code — which is a different mechanism from
8
+ * `@decocms/blocks/matchers`'s CMS rule engine (`registerMatcher`/
9
+ * `evaluateMatcher` dispatch by name over a `Record<string, unknown>` rule
10
+ * against `MatcherContext` from `cms/resolve.ts`). The two don't share a
11
+ * context shape (this `MatchContext` has `device`/`siteId`; the CMS one
12
+ * doesn't), so they are not merged.
13
+ */
14
+ import type { ImageWidget } from "../types/widgets";
15
+
16
+ export type { ImageWidget };
17
+
18
+ /**
19
+ * Context passed to matchers at request time.
20
+ * The framework populates this from the incoming request.
21
+ */
22
+ export interface MatchContext {
23
+ request: Request;
24
+ device: "mobile" | "tablet" | "desktop";
25
+ siteId: number;
26
+ }
27
+
28
+ /**
29
+ * A matcher is a function that evaluates request context and returns a boolean.
30
+ */
31
+ export type Matcher = (ctx: MatchContext) => boolean;
32
+
33
+ /**
34
+ * A feature flag with a matcher and two branches.
35
+ * The framework evaluates the matcher at request time and selects the
36
+ * appropriate branch value.
37
+ */
38
+ export interface FlagObj<T> {
39
+ matcher: Matcher;
40
+ true: T;
41
+ false: T;
42
+ name: string;
43
+ }
44
+
45
+ /**
46
+ * A single variant in a multivariate flag.
47
+ */
48
+ export interface Variant<T> {
49
+ matcher?: Matcher;
50
+ value: T;
51
+ weight?: number;
52
+ }
53
+
54
+ /**
55
+ * A multivariate flag with multiple variants, each with its own matcher.
56
+ */
57
+ export interface MultivariateFlag<T> {
58
+ variants: Variant<T>[];
59
+ }
@@ -0,0 +1,20 @@
1
+ import type { MultivariateFlag, Variant } from "../types";
2
+
3
+ /**
4
+ * @title Multivariate
5
+ */
6
+ export interface MultivariateProps<T> {
7
+ /**
8
+ * @minItems 1
9
+ * @addBehavior 1
10
+ */
11
+ variants: Variant<T>[];
12
+ }
13
+
14
+ /**
15
+ * @title Variant
16
+ * @label hidden
17
+ */
18
+ export default function multivariate<T>(props: MultivariateProps<T>): MultivariateFlag<T> {
19
+ return props;
20
+ }
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getOptimizedMediaUrl, getSrcSet } from "./Image";
3
+
4
+ describe("getOptimizedMediaUrl", () => {
5
+ let warnSpy: ReturnType<typeof vi.spyOn>;
6
+ let prevNodeEnv: string | undefined;
7
+
8
+ beforeEach(() => {
9
+ prevNodeEnv = process.env.NODE_ENV;
10
+ process.env.NODE_ENV = "development";
11
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
12
+ });
13
+
14
+ afterEach(() => {
15
+ warnSpy.mockRestore();
16
+ if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
17
+ else process.env.NODE_ENV = prevNodeEnv;
18
+ });
19
+
20
+ it("returns empty string and warns when src is undefined", () => {
21
+ const result = getOptimizedMediaUrl({
22
+ originalSrc: undefined as unknown as string,
23
+ width: 100,
24
+ fit: "cover",
25
+ });
26
+ expect(result).toBe("");
27
+ expect(warnSpy).toHaveBeenCalledTimes(1);
28
+ expect(warnSpy.mock.calls[0][0]).toMatch(/empty\/undefined src/);
29
+ });
30
+
31
+ it("returns empty string when src is empty", () => {
32
+ const result = getOptimizedMediaUrl({
33
+ originalSrc: "",
34
+ width: 100,
35
+ fit: "cover",
36
+ });
37
+ expect(result).toBe("");
38
+ });
39
+
40
+ it("returns empty string when src is null", () => {
41
+ const result = getOptimizedMediaUrl({
42
+ originalSrc: null as unknown as string,
43
+ width: 100,
44
+ fit: "cover",
45
+ });
46
+ expect(result).toBe("");
47
+ });
48
+
49
+ it("does NOT warn in production for missing src", () => {
50
+ process.env.NODE_ENV = "production";
51
+ const result = getOptimizedMediaUrl({
52
+ originalSrc: undefined as unknown as string,
53
+ width: 100,
54
+ fit: "cover",
55
+ });
56
+ expect(result).toBe("");
57
+ expect(warnSpy).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("returns data URI as-is", () => {
61
+ const dataUri = "data:image/png;base64,iVBORw0KGgo=";
62
+ expect(
63
+ getOptimizedMediaUrl({
64
+ originalSrc: dataUri,
65
+ width: 100,
66
+ fit: "cover",
67
+ }),
68
+ ).toBe(dataUri);
69
+ });
70
+
71
+ it("routes through Deco image CDN for arbitrary src", () => {
72
+ const result = getOptimizedMediaUrl({
73
+ originalSrc: "https://cdn.example.com/foo.jpg",
74
+ width: 200,
75
+ fit: "cover",
76
+ });
77
+ expect(result).toContain("/image?");
78
+ expect(result).toContain("width=200");
79
+ expect(result).toContain("fit=cover");
80
+ expect(result).toContain("src=https://cdn.example.com/foo.jpg");
81
+ });
82
+ });
83
+
84
+ describe("getSrcSet", () => {
85
+ it("returns undefined when src is undefined", () => {
86
+ expect(getSrcSet(undefined as unknown as string, 100)).toBeUndefined();
87
+ });
88
+
89
+ it("returns undefined when src is empty", () => {
90
+ expect(getSrcSet("", 100)).toBeUndefined();
91
+ });
92
+
93
+ it("produces a srcset string for valid src", () => {
94
+ const result = getSrcSet("https://cdn.example.com/foo.jpg", 100);
95
+ expect(result).toBeDefined();
96
+ // Each factor entry is "<url> <width>w".
97
+ expect(result).toMatch(/\d+w/);
98
+ expect(result).toContain("foo.jpg");
99
+ });
100
+ });