@decocms/apps-website 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.
@@ -0,0 +1,119 @@
1
+ import type { Font } from "../../types";
2
+
3
+ interface Props {
4
+ fonts: GoogleFont[];
5
+ }
6
+
7
+ /**
8
+ * @title {{weight}} {{#italic}}Italic{{/italic}}{{^italic}}{{/italic}}
9
+ */
10
+ interface FontVariation {
11
+ weight: "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
12
+ italic?: boolean;
13
+ }
14
+
15
+ /** @titleBy family */
16
+ interface GoogleFont {
17
+ family: string;
18
+ variations: FontVariation[];
19
+ }
20
+
21
+ const getFontVariations = (variations: FontVariation[]) => {
22
+ if (variations.length === 0) {
23
+ return "";
24
+ }
25
+
26
+ let hasItalic = false;
27
+ const sortedVariations = [...variations]
28
+ .sort((a, b) => {
29
+ a.italic ??= false;
30
+ b.italic ??= false;
31
+
32
+ if (a.italic !== b.italic) {
33
+ hasItalic = true;
34
+ if (a.italic) return 1;
35
+ if (!a.italic) return -1;
36
+ }
37
+
38
+ return Number.parseInt(a.weight, 10) - Number.parseInt(b.weight, 10);
39
+ })
40
+ .filter(
41
+ (item, index, self) =>
42
+ index === self.findIndex((t) => t.weight === item.weight && t.italic === item.italic),
43
+ );
44
+
45
+ const variants: string[] = [];
46
+
47
+ for (const { weight, italic } of sortedVariations) {
48
+ if (!hasItalic) {
49
+ variants.push(weight);
50
+ continue;
51
+ }
52
+ variants.push(`${italic ? "1," : "0,"}${weight}`);
53
+ }
54
+
55
+ return `:${hasItalic ? "ital," : ""}wght@${variants.join(";")}`;
56
+ };
57
+
58
+ const NEW_BROWSER_KEY = {
59
+ "User-Agent":
60
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
61
+ };
62
+
63
+ const OLD_BROWSER_KEY = {
64
+ "User-Agent": "deco-cx/1.0",
65
+ };
66
+
67
+ const loader = async (props: Props): Promise<Font> => {
68
+ const { fonts = [] } = props;
69
+
70
+ if (fonts.length === 0) {
71
+ return { family: "", styleSheet: "" };
72
+ }
73
+
74
+ const url = new URL("https://fonts.googleapis.com/css2?display=swap");
75
+
76
+ const reduced = fonts.reduce(
77
+ (acc, font) => {
78
+ const { family, variations } = font;
79
+ acc[family] = acc[family] ?? { family, variations: [] };
80
+ acc[family].variations = [...acc[family].variations, ...variations];
81
+ return acc;
82
+ },
83
+ {} as Record<string, GoogleFont>,
84
+ );
85
+
86
+ for (const font of Object.values(reduced)) {
87
+ url.searchParams.append("family", `${font.family}${getFontVariations(font.variations)}`);
88
+ }
89
+
90
+ const logFontError = (label: string, fontUrl: URL, e: unknown) => {
91
+ const message = e instanceof Error ? e.message : String(e);
92
+ const short = message.length > 300 ? `${message.slice(0, 300)}…` : message;
93
+ console.error(`Error fetching font (${label}): ${fontUrl.toString()} - ${short}`);
94
+ };
95
+
96
+ const sheets = await Promise.all([
97
+ fetch(url, { headers: OLD_BROWSER_KEY })
98
+ .then((res) => res.text())
99
+ .catch((e) => {
100
+ logFontError("OLD_UA", url, e);
101
+ return "";
102
+ }),
103
+ fetch(url, { headers: NEW_BROWSER_KEY })
104
+ .then((res) => res.text())
105
+ .catch((e) => {
106
+ logFontError("NEW_UA", url, e);
107
+ return "";
108
+ }),
109
+ ]);
110
+
111
+ const styleSheet = sheets.join("\n");
112
+
113
+ return {
114
+ family: Object.keys(reduced).join(", "),
115
+ styleSheet,
116
+ };
117
+ };
118
+
119
+ export default loader;
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import localFonts from "./local";
3
+
4
+ describe("localFonts", () => {
5
+ it("returns empty family and stylesheet for no fonts", () => {
6
+ const result = localFonts({ fonts: [] });
7
+ expect(result.family).toBe("");
8
+ expect(result.styleSheet).toBe("");
9
+ });
10
+
11
+ it("generates @font-face for a single font", () => {
12
+ const result = localFonts({
13
+ fonts: [
14
+ {
15
+ family: "CustomFont",
16
+ variations: [{ weight: "400", italic: false, src: "https://cdn.example.com/font.woff2" }],
17
+ },
18
+ ],
19
+ });
20
+
21
+ expect(result.family).toBe("CustomFont");
22
+ expect(result.styleSheet).toContain("@font-face");
23
+ expect(result.styleSheet).toContain("font-family: 'CustomFont'");
24
+ expect(result.styleSheet).toContain("font-weight: 400");
25
+ expect(result.styleSheet).toContain("font-style: normal");
26
+ expect(result.styleSheet).toContain("format('woff2')");
27
+ });
28
+
29
+ it("generates italic font-face", () => {
30
+ const result = localFonts({
31
+ fonts: [
32
+ {
33
+ family: "CustomFont",
34
+ variations: [
35
+ { weight: "700", italic: true, src: "https://cdn.example.com/font-bold-italic.woff2" },
36
+ ],
37
+ },
38
+ ],
39
+ });
40
+
41
+ expect(result.styleSheet).toContain("font-style: italic");
42
+ expect(result.styleSheet).toContain("font-weight: 700");
43
+ });
44
+
45
+ it("merges duplicate font families", () => {
46
+ const result = localFonts({
47
+ fonts: [
48
+ {
49
+ family: "Inter",
50
+ variations: [{ weight: "400", src: "https://cdn.example.com/inter-400.woff2" }],
51
+ },
52
+ {
53
+ family: "Inter",
54
+ variations: [{ weight: "700", src: "https://cdn.example.com/inter-700.woff2" }],
55
+ },
56
+ ],
57
+ });
58
+
59
+ expect(result.family).toBe("Inter");
60
+ expect(result.styleSheet).toContain("font-weight: 400");
61
+ expect(result.styleSheet).toContain("font-weight: 700");
62
+ });
63
+
64
+ it("detects font format from extension", () => {
65
+ const ttfResult = localFonts({
66
+ fonts: [{ family: "F", variations: [{ weight: "400", src: "https://x.com/f.ttf" }] }],
67
+ });
68
+ expect(ttfResult.styleSheet).toContain("format('truetype')");
69
+
70
+ const woffResult = localFonts({
71
+ fonts: [{ family: "F", variations: [{ weight: "400", src: "https://x.com/f.woff" }] }],
72
+ });
73
+ expect(woffResult.styleSheet).toContain("format('woff')");
74
+ });
75
+ });
@@ -0,0 +1,85 @@
1
+ import type { Font } from "../../types";
2
+
3
+ interface Props {
4
+ fonts: LocalFont[];
5
+ }
6
+
7
+ /**
8
+ * @title {{weight}} {{#italic}}Italic{{/italic}}{{^italic}}{{/italic}}
9
+ */
10
+ interface FontVariation {
11
+ weight: "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
12
+ italic?: boolean;
13
+ /** @format file */
14
+ src: string;
15
+ }
16
+
17
+ const getFontFormat = (src: string): string => {
18
+ const clean = src.toLowerCase().split(/[?#]/, 1)[0];
19
+ const extension = clean.slice(clean.lastIndexOf(".") + 1);
20
+ switch (extension) {
21
+ case "woff2":
22
+ return "woff2";
23
+ case "woff":
24
+ return "woff";
25
+ case "ttf":
26
+ return "truetype";
27
+ case "otf":
28
+ return "opentype";
29
+ case "eot":
30
+ return "embedded-opentype";
31
+ case "svg":
32
+ return "svg";
33
+ default:
34
+ return "truetype";
35
+ }
36
+ };
37
+
38
+ /** @titleBy family */
39
+ interface LocalFont {
40
+ family: string;
41
+ variations: FontVariation[];
42
+ }
43
+
44
+ const loader = (props: Props): Font => {
45
+ const { fonts = [] } = props;
46
+
47
+ const reduced = fonts.reduce(
48
+ (acc, font) => {
49
+ const { family, variations } = font;
50
+ acc[family] = acc[family] ?? { family, variations: [] };
51
+ acc[family].variations = [...acc[family].variations, ...variations];
52
+ return acc;
53
+ },
54
+ {} as Record<string, LocalFont>,
55
+ );
56
+
57
+ const fontFaces: string[] = [];
58
+
59
+ for (const font of Object.values(reduced)) {
60
+ for (const variation of font.variations) {
61
+ const { weight, italic = false, src } = variation;
62
+ const fontStyle = italic ? "italic" : "normal";
63
+ const format = getFontFormat(src);
64
+
65
+ const fontFace = `@font-face {
66
+ font-family: '${font.family}';
67
+ font-style: ${fontStyle};
68
+ font-weight: ${weight};
69
+ font-display: swap;
70
+ src: url(${encodeURI(src)}) format('${format}');
71
+ }`;
72
+
73
+ fontFaces.push(fontFace);
74
+ }
75
+ }
76
+
77
+ const styleSheet = fontFaces.join("\n");
78
+
79
+ return {
80
+ family: Object.keys(reduced).join(", "),
81
+ styleSheet,
82
+ };
83
+ };
84
+
85
+ export default loader;
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import SecretLoader from "./secret";
3
+
4
+ describe("SecretLoader", () => {
5
+ it("reads from process.env when name is set", () => {
6
+ process.env.MY_SECRET = "super-secret";
7
+ const result = SecretLoader({ encrypted: "xxx", name: "MY_SECRET" });
8
+ expect(result.get()).toBe("super-secret");
9
+ delete process.env.MY_SECRET;
10
+ });
11
+
12
+ it("returns null when encrypted is empty", () => {
13
+ const result = SecretLoader({ encrypted: "" });
14
+ expect(result.get()).toBeNull();
15
+ });
16
+
17
+ it("returns encrypted value as fallback in dev", () => {
18
+ const original = process.env.NODE_ENV;
19
+ process.env.NODE_ENV = "test";
20
+ const result = SecretLoader({ encrypted: "encrypted-value", name: "NONEXISTENT" });
21
+ expect(result.get()).toBe("encrypted-value");
22
+ process.env.NODE_ENV = original;
23
+ });
24
+
25
+ it("reads empty-string env var correctly", () => {
26
+ process.env.EMPTY_SECRET = "";
27
+ const result = SecretLoader({ encrypted: "fallback", name: "EMPTY_SECRET" });
28
+ expect(result.get()).toBe("");
29
+ delete process.env.EMPTY_SECRET;
30
+ });
31
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @title Secret
3
+ * @hideOption true
4
+ */
5
+ export interface Secret {
6
+ /** @ignore */
7
+ get: () => string | null;
8
+ }
9
+
10
+ export interface Props {
11
+ /**
12
+ * @title Secret Value
13
+ * @format secret
14
+ */
15
+ encrypted: string;
16
+ /**
17
+ * @title Secret Name
18
+ * @description Used in dev mode as a environment variable (should not contain spaces or special characters)
19
+ * @pattern ^[a-zA-Z_][a-zA-Z0-9_]*$
20
+ */
21
+ name?: string;
22
+ }
23
+
24
+ /**
25
+ * Resolve a secret value.
26
+ * In local dev, reads from process.env using the `name` field.
27
+ * In production, the framework's ResolveSecretFn handles decryption
28
+ * before the value reaches this loader.
29
+ */
30
+ const getSecret = (props: Props): string | null => {
31
+ const name = props?.name;
32
+ if (name && process.env[name] !== undefined) {
33
+ return process.env[name]!;
34
+ }
35
+ const encrypted = props?.encrypted;
36
+ if (!encrypted) {
37
+ return null;
38
+ }
39
+ // In production, the encrypted value should already be resolved
40
+ // by the framework's ResolveSecretFn before reaching this loader.
41
+ // If we get here with an encrypted value in dev, warn.
42
+ if (process.env.NODE_ENV !== "production") {
43
+ console.warn(
44
+ `Secret "${name ?? "anonymous"}" has encrypted value but no env var set. Set ${name} in .env.`,
45
+ );
46
+ }
47
+ return encrypted;
48
+ };
49
+
50
+ /**
51
+ * @title Secret
52
+ */
53
+ export default function SecretLoader(props: Props): Secret {
54
+ const secretValue = getSecret(props);
55
+ return {
56
+ get: (): string | null => {
57
+ return secretValue;
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,18 @@
1
+ import type { Secret } from "./secret";
2
+
3
+ export interface Props {
4
+ secret: Secret;
5
+ }
6
+
7
+ /**
8
+ * @title Secret String (use Secret instead)
9
+ */
10
+ export type SecretString = string | null;
11
+
12
+ /**
13
+ * @title Secret String
14
+ * @deprecated true
15
+ */
16
+ export default function ({ secret }: Props): SecretString {
17
+ return secret.get();
18
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getWebsiteConfig } from "./client";
3
+ import { configure } from "./mod";
4
+
5
+ describe("configure", () => {
6
+ const mockResolveSecret = async () => null;
7
+
8
+ it("returns an AppDefinition with name website", async () => {
9
+ const result = await configure({}, mockResolveSecret);
10
+
11
+ expect(result).not.toBeNull();
12
+ expect(result.name).toBe("website");
13
+ expect(result.manifest).toBeDefined();
14
+ expect(result.state.config).toBeDefined();
15
+ });
16
+
17
+ it("passes SEO config from block data", async () => {
18
+ const seo = { title: "My Site", description: "A great site" };
19
+ const result = await configure({ seo }, mockResolveSecret);
20
+
21
+ expect(result.state.config.seo).toEqual(seo);
22
+ });
23
+
24
+ it("configures the global singleton", async () => {
25
+ const seo = { title: "Singleton Test" };
26
+ await configure({ seo }, mockResolveSecret);
27
+
28
+ const config = getWebsiteConfig();
29
+ expect(config.seo).toEqual(seo);
30
+ });
31
+
32
+ it("works with null/undefined block", async () => {
33
+ const result = await configure(null, mockResolveSecret);
34
+
35
+ expect(result.name).toBe("website");
36
+ expect(result.state.config.seo).toBeUndefined();
37
+ });
38
+
39
+ it("manifest has loaders and sections", async () => {
40
+ const result = await configure({}, mockResolveSecret);
41
+
42
+ expect(Object.keys(result.manifest.loaders).length).toBeGreaterThan(0);
43
+ expect(Object.keys(result.manifest.sections ?? {}).length).toBeGreaterThan(0);
44
+ });
45
+ });
package/src/mod.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Website app module — standard autoconfig contract.
3
+ *
4
+ * Exports `configure` following the AppModContract pattern.
5
+ * Provides SEO defaults, theme, matchers, and flags for the site.
6
+ */
7
+
8
+ import type { AppDefinition, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
9
+ import { configureWebsite } from "./client";
10
+ import type { Props as SecretProps } from "./loaders/secret";
11
+ import manifest from "./manifest.gen";
12
+ import type { WebsiteConfig } from "./types";
13
+
14
+ // -------------------------------------------------------------------------
15
+ // CMS Props (mirrors deco-cx/apps/website/mod.ts)
16
+ // -------------------------------------------------------------------------
17
+
18
+ export type Script = { src: string };
19
+
20
+ export interface CacheDirectiveBase {
21
+ name: string;
22
+ value: number;
23
+ }
24
+
25
+ export interface StaleWhileRevalidate extends CacheDirectiveBase {
26
+ name: "stale-while-revalidate";
27
+ }
28
+
29
+ export interface MaxAge extends CacheDirectiveBase {
30
+ name: "max-age";
31
+ }
32
+
33
+ export type CacheDirective = StaleWhileRevalidate | MaxAge;
34
+
35
+ export interface Caching {
36
+ enabled?: boolean;
37
+ directives?: CacheDirective[];
38
+ }
39
+
40
+ export interface AbTesting {
41
+ enabled?: boolean;
42
+ /** @description The name of the A/B test — appears in cookies */
43
+ name?: string;
44
+ matcher?: unknown;
45
+ /** @description URL to run the A/B test against */
46
+ urlToRunAgainst?: string;
47
+ replaces?: unknown[];
48
+ includeScriptsToHead?: { includes?: Script[] };
49
+ includeScriptsToBody?: { includes?: Script[] };
50
+ }
51
+
52
+ /** @titleBy framework */
53
+ export interface FreshFlavor {
54
+ /** @default fresh */
55
+ framework: "fresh";
56
+ }
57
+
58
+ /** @titleBy framework */
59
+ export interface HtmxFlavor {
60
+ /** @default htmx */
61
+ framework: "htmx";
62
+ }
63
+
64
+ /** @title Website */
65
+ export interface Props {
66
+ /** @title Routes Map */
67
+ routes?: unknown[];
68
+
69
+ /** @title Global Sections */
70
+ global?: unknown[];
71
+
72
+ /** @title Error Page */
73
+ errorPage?: unknown;
74
+
75
+ /** @title Caching configuration of pages */
76
+ caching?: Caching;
77
+
78
+ /**
79
+ * @title Global Async Rendering (Deprecated)
80
+ * @deprecated true
81
+ * @default false
82
+ */
83
+ firstByteThresholdMS?: boolean;
84
+
85
+ /** @title Avoid redirecting to editor */
86
+ avoidRedirectingToEditor?: boolean;
87
+
88
+ /** @title AB Testing */
89
+ abTesting?: AbTesting;
90
+
91
+ /** @title Flavor */
92
+ flavor?: FreshFlavor | HtmxFlavor;
93
+
94
+ /** @title Seo */
95
+ seo?: Record<string, unknown>;
96
+
97
+ /** @title Theme */
98
+ theme?: unknown;
99
+
100
+ /** @hide true */
101
+ sendToClickHouse?: boolean;
102
+
103
+ /** @title Default Image Quality */
104
+ defaultImageQuality?: string;
105
+
106
+ /** @title Disable image/asset proxy for this site */
107
+ disableProxy?: boolean;
108
+
109
+ /** @title Whilelist URL Patterns */
110
+ whilelistURLs?: string[];
111
+ }
112
+
113
+ /** Alias for site app bridges that extend website Props. */
114
+ export type WebsiteProps = Props;
115
+
116
+ /** Secret block shape in decofile (website/loaders/secret.ts). */
117
+ export type Secret = SecretProps;
118
+
119
+ // -------------------------------------------------------------------------
120
+ // State
121
+ // -------------------------------------------------------------------------
122
+
123
+ export interface WebsiteState {
124
+ config: WebsiteConfig;
125
+ }
126
+
127
+ // -------------------------------------------------------------------------
128
+ // Configure
129
+ // -------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Configure the Website app from CMS block data.
133
+ * Always returns an AppDefinition (no required fields).
134
+ */
135
+ export async function configure(
136
+ // biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema
137
+ block: any,
138
+ _resolveSecret: ResolveSecretFn,
139
+ ): Promise<AppDefinition<WebsiteState>> {
140
+ const config: WebsiteConfig = {
141
+ seo: block?.seo,
142
+ };
143
+
144
+ configureWebsite(config);
145
+
146
+ return {
147
+ name: "website",
148
+ manifest,
149
+ state: { config },
150
+ };
151
+ }
152
+
153
+ /** Placeholder preview for CMS editor. */
154
+ export const preview = undefined;
@@ -0,0 +1,7 @@
1
+ import AnalyticsComponent, { type Props } from "../../components/Analytics";
2
+
3
+ function Section(props: Props) {
4
+ return <AnalyticsComponent {...props} />;
5
+ }
6
+
7
+ export default Section;
@@ -0,0 +1,14 @@
1
+ import SeoComponent, { type Props as SeoProps } from "../../components/Seo";
2
+
3
+ type Props = Omit<SeoProps, "jsonLDs">;
4
+
5
+ /**
6
+ * @deprecated true
7
+ * @migrate website/sections/Seo/SeoV2.tsx
8
+ * @title SEO deprecated
9
+ */
10
+ function Section(props: Props) {
11
+ return <SeoComponent {...props} />;
12
+ }
13
+
14
+ export default Section;
@@ -0,0 +1,58 @@
1
+ import { getWebsiteConfig } from "../../client";
2
+ import SeoComponent, {
3
+ renderTemplateString,
4
+ type SEOSection,
5
+ type Props as SeoProps,
6
+ } from "../../components/Seo";
7
+ import type { WebsiteConfig } from "../../types";
8
+
9
+ type Props = Pick<
10
+ SeoProps,
11
+ "title" | "description" | "type" | "favicon" | "image" | "themeColor" | "noIndexing"
12
+ >;
13
+
14
+ /**
15
+ * Loader that merges page-level SEO props with app-level defaults.
16
+ * When called by the framework, `seo` comes from the app state.
17
+ * When called standalone (e.g. asJson handler), falls back to the
18
+ * globalThis-backed singleton set by `configureWebsite()`.
19
+ */
20
+ export function loader(props: Props, seo?: WebsiteConfig["seo"]) {
21
+ const resolvedSeo =
22
+ seo ??
23
+ (() => {
24
+ try {
25
+ return getWebsiteConfig().seo;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ })();
30
+
31
+ const {
32
+ titleTemplate = "",
33
+ descriptionTemplate = "",
34
+ title: appTitle = "",
35
+ description: appDescription = "",
36
+ ...seoSiteProps
37
+ } = resolvedSeo ?? {};
38
+
39
+ const { title: _title, description: _description, ...seoProps } = props;
40
+
41
+ const title = renderTemplateString(
42
+ (titleTemplate ?? "").trim().length === 0 ? "%s" : titleTemplate,
43
+ _title ?? appTitle,
44
+ );
45
+
46
+ const description = renderTemplateString(
47
+ (descriptionTemplate ?? "").trim().length === 0 ? "%s" : descriptionTemplate,
48
+ _description ?? appDescription,
49
+ );
50
+
51
+ return { ...seoSiteProps, ...seoProps, title, description };
52
+ }
53
+
54
+ function Section(props: Props): SEOSection {
55
+ return <SeoComponent {...props} />;
56
+ }
57
+
58
+ export default Section;