packages/server/src/lib/theme.ts 4.8 K raw
1
import { existsSync, readFileSync } from "fs";
2
3
interface ThemeVars {
4
	fgColor: string;
5
	bgColor: string;
6
	accentColor: string;
7
	borderColor: string;
8
	errorColor: string;
9
	borderRadius: string;
10
	fontFamily: string;
11
	darkBgColor: string;
12
	darkFgColor: string;
13
	darkBorderColor: string;
14
	darkErrorColor: string;
15
}
16
17
function getThemeVars(): ThemeVars {
18
	return {
19
		fgColor: process.env.THEME_FG_COLOR || "#2C2C2C",
20
		bgColor: process.env.THEME_BG_COLOR || "#F5F3EF",
21
		accentColor: process.env.THEME_ACCENT_COLOR || "#3A5A40",
22
		borderColor: process.env.THEME_BORDER_COLOR || "#D5D1C8",
23
		errorColor: process.env.THEME_ERROR_COLOR || "#8B3A3A",
24
		borderRadius: process.env.THEME_BORDER_RADIUS || "6px",
25
		fontFamily: process.env.THEME_FONT_FAMILY || "system-ui, sans-serif",
26
		darkBgColor: process.env.THEME_DARK_BG_COLOR || "#1A1A1A",
27
		darkFgColor: process.env.THEME_DARK_FG_COLOR || "#E5E5E5",
28
		darkBorderColor: process.env.THEME_DARK_BORDER_COLOR || "#3A3A3A",
29
		darkErrorColor: process.env.THEME_DARK_ERROR_COLOR || "#E57373",
30
	};
31
}
32
33
function getCustomCss(): string {
34
	const cssPath = process.env.THEME_CSS_PATH;
35
	if (!cssPath) return "";
36
	try {
37
		if (existsSync(cssPath)) {
38
			return readFileSync(cssPath, "utf-8");
39
		}
40
	} catch {
41
		console.warn(`Failed to read custom CSS file: ${cssPath}`);
42
	}
43
	return "";
44
}
45
46
export function generateStyleBlock(): string {
47
	const t = getThemeVars();
48
	const customCss = getCustomCss();
49
50
	return `<style>
51
    :root {
52
      --sequoia-fg-color: ${t.fgColor};
53
      --sequoia-bg-color: ${t.bgColor};
54
      --sequoia-accent-color: ${t.accentColor};
55
      --sequoia-border-color: ${t.borderColor};
56
      --sequoia-error-color: ${t.errorColor};
57
      --sequoia-border-radius: ${t.borderRadius};
58
      --sequoia-font-family: ${t.fontFamily};
59
    }
60
61
    @media (prefers-color-scheme: dark) {
62
      :root {
63
        --sequoia-fg-color: ${t.darkFgColor};
64
        --sequoia-bg-color: ${t.darkBgColor};
65
        --sequoia-border-color: ${t.darkBorderColor};
66
        --sequoia-error-color: ${t.darkErrorColor};
67
      }
68
    }
69
70
    * { box-sizing: border-box; margin: 0; padding: 0; }
71
72
    body {
73
      font-family: var(--sequoia-font-family);
74
      background: var(--sequoia-bg-color);
75
      color: var(--sequoia-fg-color);
76
      line-height: 1.6;
77
    }
78
79
    .page-container {
80
      max-width: 480px;
81
      margin: 4rem auto;
82
      padding: 0 1.25rem;
83
    }
84
85
    h1 {
86
      font-size: 1.75rem;
87
      font-weight: 700;
88
      margin-bottom: 0.75rem;
89
    }
90
91
    p { margin-bottom: 1rem; }
92
93
    a {
94
      color: var(--sequoia-accent-color);
95
      text-decoration: underline;
96
    }
97
98
    a:hover { text-decoration: none; }
99
100
    form { display: flex; flex-direction: column; }
101
102
    input[type="text"] {
103
      padding: 0.5rem 0.75rem;
104
      border: 1px solid var(--sequoia-border-color);
105
      border-radius: var(--sequoia-border-radius);
106
      margin-bottom: 1.25rem;
107
      width: 100%;
108
      font-size: 1rem;
109
      font-family: inherit;
110
      background: var(--sequoia-bg-color);
111
      color: var(--sequoia-fg-color);
112
    }
113
114
    input[type="text"]:focus {
115
      border-color: var(--sequoia-accent-color);
116
      outline: 2px solid var(--sequoia-accent-color);
117
      outline-offset: 2px;
118
    }
119
120
    button {
121
      padding: 0.625rem 1.25rem;
122
      background: var(--sequoia-accent-color);
123
      color: #fff;
124
      border: none;
125
      border-radius: var(--sequoia-border-radius);
126
      font-size: 1rem;
127
      font-family: inherit;
128
      font-weight: 600;
129
      cursor: pointer;
130
      transition: opacity 0.15s;
131
    }
132
133
    button:hover { opacity: 0.9; }
134
135
    button:focus-visible {
136
      outline: 2px solid var(--sequoia-accent-color);
137
      outline-offset: 2px;
138
    }
139
140
    table {
141
      width: 100%;
142
      border-collapse: collapse;
143
      table-layout: fixed;
144
      margin-top: 1rem;
145
    }
146
147
    td {
148
      padding: 0.5rem 0.75rem;
149
      border-bottom: 1px solid var(--sequoia-border-color);
150
      vertical-align: top;
151
    }
152
153
    td:first-child {
154
      width: 7rem;
155
      font-weight: 600;
156
    }
157
158
    td:last-child { overflow: hidden; }
159
160
    td code {
161
      font-size: 0.85rem;
162
      word-break: break-all;
163
    }
164
165
    td div {
166
      overflow-x: auto;
167
      white-space: nowrap;
168
    }
169
170
    .error { color: var(--sequoia-error-color); }
171
    ${customCss ? `\n    /* Custom CSS */\n    ${customCss}` : ""}
172
  </style>`;
173
}
174
175
export function page(body: string, headExtra = ""): string {
176
	return `<!DOCTYPE html>
177
<html lang="en">
178
<head>
179
  <meta charset="UTF-8" />
180
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
181
  <title>Sequoia · Subscribe</title>
182
  ${generateStyleBlock()}
183
  ${headExtra}
184
</head>
185
<body>
186
  <div class="page-container">
187
    ${body}
188
  </div>
189
</body>
190
</html>`;
191
}
192
193
export function escapeHtml(text: string): string {
194
	return text
195
		.replace(/&/g, "&amp;")
196
		.replace(/</g, "&lt;")
197
		.replace(/>/g, "&gt;")
198
		.replace(/"/g, "&quot;");
199
}