ultimate-jekyll-manager 1.0.22 → 1.1.1

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/CHANGELOG.md CHANGED
@@ -15,6 +15,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
17
  ---
18
+ ## [1.1.1] - 2026-04-06
19
+ ### Security
20
+ - Fix open redirect via `authReturnUrl` URL parameter in core/auth.js — now validated with `isValidRedirectUrl()`
21
+ - Fix cross-origin redirect via unvalidated postMessage in vert.js — added origin allowlist
22
+ - Replace `new Function()` code execution in redirect.js with safe named modifier lookup
23
+ - Sanitize markdown-it output with DOMPurify in campaign-preview.js (newsletter-safe tag allowlist)
24
+ - Validate OAuth redirect URL scheme in connections.js
25
+ - Escape `classes` parameter in prerendered-icons.js to prevent attribute breakout
26
+ - Defense-in-depth: escape `formatDate()` outputs in security.js, team.js, referrals.js
27
+ - Defense-in-depth: escape cancel/refund reason strings in billing.js, refund.js
28
+ - Defense-in-depth: escape `submittingText` in form-manager.js spinner
29
+ - Document redirect validation, postMessage origin checks, eval prohibition, and DOMPurify rules in CLAUDE.md
30
+
31
+ ### Added
32
+ - `dompurify` dependency for HTML sanitization
33
+
34
+ ## [1.1.0] - 2026-04-06
35
+ ### Added
36
+ - `payment-config.js` shared library for reading payment data from build-time config
37
+ - Pricing layout resolves prices and feature limits from `_config.yml` when not set in frontmatter
38
+ - `oauth2` config injected into client-side Configuration object via `foot.html`
39
+ - Pricing page shows "Switch to This Plan" on other paid plans when user has active subscription
40
+
41
+ ### Changed
42
+ - Move `payment` under `web_manager` in default `_config.yml` so it serializes into client-side config
43
+ - Checkout page uses `payment-config.js` instead of fetching `/backend-manager/brand`
44
+ - Account billing section uses config for products/limits/currency instead of brand API
45
+ - Account connections section reads `oauth2` from config instead of brand API
46
+ - Admin dashboard uses config for product list in MRR calculations
47
+ - Remove `/backend-manager/brand` fetch from account page entirely
48
+ - "Everything in [plan]" now uses dynamic previous plan name instead of hardcoded index
49
+
50
+ ### Fixed
51
+ - Liquid 4.x compatibility: use loop-based hash lookup instead of bracket notation for config limits
52
+
18
53
  ## [1.0.22] - 2026-04-05
19
54
  ### Changed
20
55
  - Bump web-manager from ^4.1.36 to ^4.1.37
package/CLAUDE.md CHANGED
@@ -429,6 +429,69 @@ $el.textContent = data.message; // Safe — no escaping needed
429
429
 
430
430
  Only use `innerHTML` when you need actual HTML structure (tags, classes, etc.), and escape every dynamic value in it.
431
431
 
432
+ ### Even "Safe" Values Must Be Escaped
433
+ Even values that *seem* safe (like `Date.toLocaleDateString()` output, numeric calculations, or hardcoded config strings) MUST be escaped when inserted via `innerHTML`. This is defense-in-depth — if the data source ever changes, the escaping is already in place.
434
+
435
+ ```javascript
436
+ // ✅ CORRECT — escape even "safe" values in innerHTML
437
+ $el.innerHTML = `<small>${webManager.utilities().escapeHTML(formatDate(timestamp))}</small>`;
438
+ $el.innerHTML = `<span>${webManager.utilities().escapeHTML(reason)}</span>`;
439
+
440
+ // ❌ WRONG — assuming the value is safe because it's from a date formatter
441
+ $el.innerHTML = `<small>${formatDate(timestamp)}</small>`;
442
+ ```
443
+
444
+ ### Redirects Must Be Validated
445
+ Never redirect to a URL from untrusted sources without validation:
446
+
447
+ ```javascript
448
+ // ✅ CORRECT — validate before redirect
449
+ const url = urlParams.get('returnUrl');
450
+ if (url && webManager.isValidRedirectUrl(url)) {
451
+ window.location.href = url;
452
+ }
453
+
454
+ // ✅ CORRECT — validate API response URLs have safe scheme
455
+ if (response.url && /^https?:\/\//i.test(response.url)) {
456
+ window.location.href = response.url;
457
+ }
458
+
459
+ // ❌ WRONG — redirect to unvalidated input
460
+ window.location.href = urlParams.get('returnUrl');
461
+ ```
462
+
463
+ ### postMessage Handlers Must Check Origin
464
+ Always validate `event.origin` when handling `window.addEventListener('message', ...)`:
465
+
466
+ ```javascript
467
+ // ✅ CORRECT
468
+ window.addEventListener('message', (event) => {
469
+ if (event.origin !== window.location.origin && event.origin !== 'https://trusted-domain.com') {
470
+ return;
471
+ }
472
+ // handle message
473
+ });
474
+
475
+ // ❌ WRONG — any origin can send messages
476
+ window.addEventListener('message', (event) => {
477
+ window.location.href = event.data.url; // attacker-controlled redirect
478
+ });
479
+ ```
480
+
481
+ ### Never Use eval() or new Function()
482
+ Do not use `eval()`, `new Function()`, `setTimeout(string)`, or `setInterval(string)`. These execute arbitrary code and violate CSP policies.
483
+
484
+ ### Sanitize Markdown/Rich Text Output
485
+ When rendering user-authored markdown or rich text, use DOMPurify to sanitize the output:
486
+
487
+ ```javascript
488
+ import DOMPurify from 'dompurify';
489
+ const safeHTML = DOMPurify.sanitize(md.render(userContent), {
490
+ ALLOWED_TAGS: ['h1', 'h2', 'h3', 'p', 'br', 'a', 'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'img', 'code', 'pre'],
491
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target', 'rel'],
492
+ });
493
+ ```
494
+
432
495
  ### Do NOT Escape Values Passed to textContent-Based APIs
433
496
  `showNotification()`, `formManager.showSuccess()`, `formManager.showError()`, and `textContent` assignments use safe text insertion internally. Pre-escaping these causes double-encoding (e.g., `We'll` displays as `We&#039;ll`).
434
497
 
@@ -1108,12 +1171,24 @@ const response = await authorizedFetch(url, options);
1108
1171
  - No need to manually call `webManager.auth().getIdToken()`
1109
1172
  - Automatic token injection as Authorization Bearer header
1110
1173
  - Centralized authentication handling
1174
+ - Automatic usage sync: extracts `bm-properties` header from every response and updates `webManager.bindings()` with fresh usage data under the `usage` key
1175
+
1176
+ **Options pass-through:** All `wonderful-fetch` options (`response`, `output`, `body`, `timeout`, etc.) are passed through untouched. Internally, `authorizedFetch` uses `output: 'complete'` to read response headers, then returns only the body by default. If the caller passes `output: 'complete'`, they get the full `{ status, headers, body }` response.
1177
+
1178
+ **Automatic Usage Binding Sync:**
1179
+
1180
+ After every successful response, `authorizedFetch` reads the `bm-properties` header and updates the `usage` bindings key:
1181
+ ```javascript
1182
+ // After an API call, bindings are automatically updated:
1183
+ // usage.credits = { monthly: 5, daily: 2, limit: 100 }
1184
+ ```
1185
+ This means any `data-wm-bind` elements bound to `usage.*` paths are automatically kept in sync without any manual work. See "Usage Bindings" below.
1111
1186
 
1112
1187
  **⚠️ IMPORTANT: Auth State Requirement**
1113
1188
 
1114
1189
  `authorizedFetch` requires Firebase Auth to have determined the current user's authentication state before being called. On fresh page loads (e.g., OAuth callback pages, deep links), Firebase Auth needs time to restore the session from IndexedDB/localStorage.
1115
1190
 
1116
- **If called before auth state is determined, it will throw: `"No authenticated user found"`**
1191
+ **If called before auth state is determined, it will warn: `"No authenticated user found"`**
1117
1192
 
1118
1193
  **Solution:** Wait for auth state before calling `authorizedFetch`:
1119
1194
 
@@ -1136,6 +1211,142 @@ webManager.auth().listen({ once: true }, async () => {
1136
1211
 
1137
1212
  **Reference:** `src/assets/js/libs/authorized-fetch.js`
1138
1213
 
1214
+ #### Usage Bindings
1215
+
1216
+ Usage data is available in the `usage` bindings key. It is populated from two sources:
1217
+
1218
+ 1. **On page load (auth settle):** `web-manager` reads `account.usage` from Firestore and resolves plan limits from `config.payment.plans`, then sets `usage` bindings with the merged data.
1219
+ 2. **After API calls:** `authorizedFetch` reads the `bm-properties` response header and merges fresh usage counters + limits into the existing `usage` bindings.
1220
+
1221
+ **Bindings structure:**
1222
+ ```javascript
1223
+ // usage.credits = { monthly: 5, daily: 2, limit: 100 }
1224
+ // usage.requests = { monthly: 20, limit: 500 }
1225
+ ```
1226
+
1227
+ **HTML usage:**
1228
+ ```html
1229
+ <!-- Show usage counter: "5/100" -->
1230
+ <span data-wm-bind="@show usage.credits">
1231
+ <span data-wm-bind="usage.credits.monthly">–</span>/<span data-wm-bind="usage.credits.limit">–</span>
1232
+ </span>
1233
+ ```
1234
+
1235
+ **Config requirement:** Plan limits must be defined in `_config.yml` under `web_manager.payment.plans`:
1236
+ ```yaml
1237
+ web_manager:
1238
+ payment:
1239
+ plans:
1240
+ - id: basic
1241
+ limits:
1242
+ credits: 100
1243
+ - id: premium
1244
+ limits:
1245
+ credits: 500
1246
+ ```
1247
+
1248
+ #### Payment Config Library
1249
+
1250
+ Reads payment configuration (products, processors, prices, limits) from `webManager.config.payment` — populated from `_config.yml` at build time. **Do NOT fetch `/backend-manager/brand` to get payment data.** It's already available instantly via this library.
1251
+
1252
+ **Import:**
1253
+ ```javascript
1254
+ import { getPaymentConfig, getProcessors, getProducts, getProductById, getProductLimits, getCurrency } from '__main_assets__/js/libs/payment-config.js';
1255
+ ```
1256
+
1257
+ **Usage:**
1258
+ ```javascript
1259
+ // Get all products
1260
+ const products = getProducts();
1261
+
1262
+ // Find a specific product
1263
+ const product = getProductById('plus');
1264
+
1265
+ // Get product limits
1266
+ const limits = getProductLimits('plus'); // { credits: 500, agents: 3, ... }
1267
+
1268
+ // Get processors (stripe, paypal, etc.)
1269
+ const processors = getProcessors();
1270
+ ```
1271
+
1272
+ **Config location in `_config.yml`:**
1273
+ ```yaml
1274
+ web_manager:
1275
+ payment:
1276
+ processors:
1277
+ stripe:
1278
+ publishableKey: pk_live_...
1279
+ paypal:
1280
+ clientId: ...
1281
+ products:
1282
+ - id: basic
1283
+ name: Basic
1284
+ limits:
1285
+ credits: 100
1286
+ - id: plus
1287
+ name: Plus
1288
+ limits:
1289
+ credits: 500
1290
+ prices:
1291
+ monthly: 19
1292
+ annually: 190
1293
+ ```
1294
+
1295
+ **How it works:** The `foot.html` Configuration injection serializes all `web_manager` properties into `window.Configuration`, which `webManager.initialize()` stores in `webManager.config`. The payment config is available immediately — no API call needed.
1296
+
1297
+ **When to still use the brand API:**
1298
+ - `oauth2` provider configuration (used by the connections section on the account page)
1299
+ - Any data that is NOT in `_config.yml` and only exists server-side
1300
+
1301
+ **Reference:** `src/assets/js/libs/payment-config.js`
1302
+
1303
+ #### Pricing Page: Config-Resolved Values
1304
+
1305
+ The pricing layout automatically resolves prices and feature limits from `_config.yml` when not explicitly set in frontmatter. This means consuming projects can define ONLY display metadata (name, tagline, icon, features list) and let prices/limits come from the single source of truth.
1306
+
1307
+ **Resolution order (frontmatter wins):**
1308
+ 1. `plan.pricing.monthly` / `plan.pricing.annually` from page frontmatter
1309
+ 2. `site.web_manager.payment.products[matching_id].prices.monthly` / `.annually` from config
1310
+ 3. `0` (default)
1311
+
1312
+ **Feature value resolution:**
1313
+ 1. `feature.value` from page frontmatter
1314
+ 2. `site.web_manager.payment.products[matching_id].limits[feature.id]` from config (with `-1` → `"Unlimited"`)
1315
+
1316
+ **Example: Minimal pricing.md (prices/limits come from config):**
1317
+ ```yaml
1318
+ ---
1319
+ layout: blueprint/pricing
1320
+ permalink: /pricing
1321
+
1322
+ pricing:
1323
+ plans:
1324
+ - id: "basic"
1325
+ name: "Basic"
1326
+ tagline: "best for getting started"
1327
+ url: "/dashboard"
1328
+ features:
1329
+ - id: "credits"
1330
+ name: "Credits"
1331
+ icon: "sparkles"
1332
+ - id: "agents"
1333
+ name: "Agents"
1334
+ icon: "robot"
1335
+ - id: "plus"
1336
+ name: "Plus"
1337
+ tagline: "best for small websites"
1338
+ features:
1339
+ - id: "credits"
1340
+ name: "Credits"
1341
+ icon: "sparkles"
1342
+ - id: "agents"
1343
+ name: "Agents"
1344
+ icon: "robot"
1345
+ ---
1346
+ ```
1347
+
1348
+ In this example, `credits` value of 100 and price of $19/mo come from `_config.yml`'s `web_manager.payment.products` — no hardcoding needed.
1349
+
1139
1350
  #### FormManager Library
1140
1351
 
1141
1352
  Lightweight form state management library with built-in validation, state machine, and event system.
@@ -38,7 +38,8 @@ export default function () {
38
38
  webManager.auth().listen({}, async (state) => {
39
39
  const user = state.user;
40
40
  const url = new URL(window.location.href);
41
- const authReturnUrl = url.searchParams.get('authReturnUrl');
41
+ const authReturnUrlRaw = url.searchParams.get('authReturnUrl');
42
+ const authReturnUrl = authReturnUrlRaw && webManager.isValidRedirectUrl(authReturnUrlRaw) ? authReturnUrlRaw : null;
42
43
  const authSignout = url.searchParams.get('authSignout');
43
44
 
44
45
  // Log
@@ -803,7 +803,7 @@ export class FormManager {
803
803
  if (show) {
804
804
  // Store original content
805
805
  $btn._originalHTML = $btn.innerHTML;
806
- $btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${this.config.submittingText}`;
806
+ $btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${webManager.utilities().escapeHTML(this.config.submittingText)}`;
807
807
  } else if ($btn._originalHTML) {
808
808
  $btn.innerHTML = $btn._originalHTML;
809
809
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Payment Config Library
3
+ *
4
+ * Reads payment configuration (products, processors, prices, limits) from
5
+ * webManager.config.payment — which is populated from _config.yml at build time.
6
+ * This eliminates the need to fetch /backend-manager/brand at runtime.
7
+ */
8
+
9
+ import webManager from 'web-manager';
10
+
11
+ // Get the full payment config object
12
+ export function getPaymentConfig() {
13
+ return webManager.config?.payment || {};
14
+ }
15
+
16
+ // Get payment processors
17
+ export function getProcessors() {
18
+ return getPaymentConfig().processors || {};
19
+ }
20
+
21
+ // Get all products
22
+ export function getProducts() {
23
+ return getPaymentConfig().products || [];
24
+ }
25
+
26
+ // Find a product by ID
27
+ export function getProductById(productId) {
28
+ return getProducts().find(p => p.id === productId) || null;
29
+ }
30
+
31
+ // Get a product's limits
32
+ export function getProductLimits(productId) {
33
+ return getProductById(productId)?.limits || {};
34
+ }
35
+
36
+ // Get a product's prices
37
+ export function getProductPrices(productId) {
38
+ return getProductById(productId)?.prices || {};
39
+ }
40
+
41
+ // Get payment currency
42
+ export function getCurrency() {
43
+ return getPaymentConfig().currency || 'USD';
44
+ }
@@ -2,6 +2,7 @@
2
2
  * Prerendered Icons Library
3
3
  * Retrieves pre-rendered icon HTML from the frontmatter icon system
4
4
  */
5
+ import webManager from 'web-manager';
5
6
 
6
7
  /**
7
8
  * Get pre-rendered icon by name from frontmatter icon system.
@@ -31,5 +32,5 @@ export function getPrerenderedIcon(iconName, classes) {
31
32
  return $iconTemplate.innerHTML;
32
33
  }
33
34
 
34
- return $iconTemplate.innerHTML.replace('class="fa"', `class="fa ${classes}"`);
35
+ return $iconTemplate.innerHTML.replace('class="fa"', `class="fa ${webManager.utilities().escapeHTML(classes)}"`);
35
36
  }
@@ -34,15 +34,23 @@ const performRedirect = () => {
34
34
  const currentUrl = new URL(window.location.href);
35
35
  const siteUrl = new URL(config.siteUrl);
36
36
 
37
- // Parse modifier function
37
+ // Named modifier lookup (safe alternative to eval/new Function)
38
+ const MODIFIERS = {
39
+ 'search-cse': (url) => {
40
+ const q = url.searchParams.get('q');
41
+ url.searchParams.set('q', 'site:' + window.location.origin + ' ' + q);
42
+ return url;
43
+ },
44
+ };
45
+
46
+ // Resolve modifier by name
38
47
  let modifierFunction = (url) => url;
39
48
  if (config.modifier && config.modifier !== '""' && config.modifier !== '') {
40
- try {
41
- // Safely evaluate modifier function
42
- modifierFunction = new Function('url', `return (${config.modifier})(url)`);
43
- } catch (error) {
44
- console.warn('[Redirect] Failed to parse modifier function:', error);
45
- console.warn('[Redirect] Modifier string:', config.modifier);
49
+ const modifierName = config.modifier.trim();
50
+ if (MODIFIERS[modifierName]) {
51
+ modifierFunction = MODIFIERS[modifierName];
52
+ } else {
53
+ console.warn('[Redirect] Unknown modifier:', modifierName);
46
54
  }
47
55
  }
48
56
 
@@ -90,8 +90,12 @@ const setupMessageHandler = () => {
90
90
  // Flag as set up
91
91
  window.__ujVertMessageHandlerSetup = true;
92
92
 
93
- // Listen for messages from iframes
93
+ // Listen for messages from vert iframes (validate origin)
94
94
  window.addEventListener('message', (event) => {
95
+ if (event.origin !== window.location.origin && event.origin !== 'https://promo-server.itwcreativeworks.com') {
96
+ return;
97
+ }
98
+
95
99
  const message = event.data || {};
96
100
  const command = message.command || '';
97
101
  const payload = message.payload || {};
@@ -1,5 +1,4 @@
1
1
  // Libraries
2
- import fetch from 'wonderful-fetch';
3
2
  import * as profileSection from './sections/profile.js';
4
3
  import * as notificationsSection from './sections/notifications.js';
5
4
  import * as securitySection from './sections/security.js';
@@ -12,6 +11,7 @@ import * as dataRequestSection from './sections/data-request.js';
12
11
  import * as connectionsSection from './sections/connections.js';
13
12
  import * as refundSection from './sections/refund.js';
14
13
  import webManager from 'web-manager';
14
+ import { getPaymentConfig } from '__main_assets__/js/libs/payment-config.js';
15
15
 
16
16
  // Module
17
17
  export default () => {
@@ -33,8 +33,9 @@ export default () => {
33
33
  // Global state
34
34
  let $navLinks = null;
35
35
  let $sections = null;
36
- let brandData = null;
37
- let fetchBrandDataPromise = null;
36
+
37
+ // Config from _config.yml (available instantly, no fetch needed)
38
+ const paymentConfig = getPaymentConfig();
38
39
 
39
40
  // Section modules map
40
41
  const sectionModules = {
@@ -85,10 +86,6 @@ async function initializeAccount() {
85
86
  webManager.auth().listen({}, async (state) => {
86
87
  console.log('Auth state with account data:', state);
87
88
 
88
- // Load user data with the account information
89
- // Wait for brand data to be fetched before loading section data
90
- await fetchBrandData();
91
-
92
89
  /* @dev-only:start */
93
90
  {
94
91
  // Check for test subscription parameter
@@ -128,32 +125,6 @@ async function initializeAccount() {
128
125
  });
129
126
  }
130
127
 
131
- // Fetch brand data to get configuration and OAuth settings
132
- async function fetchBrandData() {
133
- if (fetchBrandDataPromise) return fetchBrandDataPromise;
134
-
135
- fetchBrandDataPromise = (async () => {
136
- try {
137
- const serverApiURL = `${webManager.getApiUrl()}/backend-manager/brand`;
138
-
139
- // Fetch brand data
140
- const response = await fetch(serverApiURL, {
141
- response: 'json',
142
- });
143
-
144
- console.log('Fetched brand data:', response);
145
- brandData = response;
146
-
147
- return response;
148
- } catch (error) {
149
- webManager.sentry().captureException(new Error('Failed to fetch brand data', { cause: error }));
150
- return null;
151
- }
152
- })();
153
-
154
- return fetchBrandDataPromise;
155
- }
156
-
157
128
  // Load data for all sections
158
129
  function loadAllSectionData(authState) {
159
130
  const { user, account } = authState;
@@ -172,7 +143,7 @@ function loadAllSectionData(authState) {
172
143
  }
173
144
 
174
145
  if (sectionModules.billing.loadData) {
175
- sectionModules.billing.loadData(account, brandData);
146
+ sectionModules.billing.loadData(account, paymentConfig);
176
147
  }
177
148
 
178
149
  if (sectionModules.team && sectionModules.team.loadData) {
@@ -196,7 +167,7 @@ function loadAllSectionData(authState) {
196
167
  }
197
168
 
198
169
  if (sectionModules.connections.loadData) {
199
- sectionModules.connections.loadData(account, brandData);
170
+ sectionModules.connections.loadData(account, webManager.config?.oauth2 || {});
200
171
  }
201
172
 
202
173
  if (sectionModules.refund.loadData) {
@@ -7,7 +7,7 @@ import { FormManager } from '__main_assets__/js/libs/form-manager.js';
7
7
  import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
8
8
  import webManager from 'web-manager';
9
9
 
10
- let brandData = null;
10
+ let paymentConfig = null;
11
11
  let cancelFormManager = null;
12
12
  let currentAccount = null;
13
13
 
@@ -41,12 +41,12 @@ export async function init() {
41
41
  }
42
42
 
43
43
  // Load billing data
44
- export async function loadData(account, sharedBrandData) {
44
+ export async function loadData(account, sharedPaymentConfig) {
45
45
  if (!account) {
46
46
  return;
47
47
  }
48
48
 
49
- brandData = sharedBrandData;
49
+ paymentConfig = sharedPaymentConfig;
50
50
  currentAccount = account;
51
51
 
52
52
  updateUI(account);
@@ -104,7 +104,7 @@ function buildBillingState(account) {
104
104
  // Pre-format billing details
105
105
  const nextBillingUnix = subscription.expires?.timestampUNIX;
106
106
  const amount = subscription.payment?.price;
107
- const currency = brandData?.payment?.currency || 'USD';
107
+ const currency = paymentConfig?.currency || 'USD';
108
108
  const frequency = subscription.payment?.frequency;
109
109
  const hasValidBilling = nextBillingUnix && nextBillingUnix > 0 && amount;
110
110
 
@@ -320,8 +320,8 @@ function populateCancelReasons() {
320
320
 
321
321
  $container.innerHTML = shuffled.map((reason, i) => `
322
322
  <div class="form-check mb-2">
323
- <input class="form-check-input" type="radio" name="cancel_reason" id="cancel-reason-${i}" value="${reason}">
324
- <label class="form-check-label" for="cancel-reason-${i}">${reason}</label>
323
+ <input class="form-check-input" type="radio" name="cancel_reason" id="cancel-reason-${i}" value="${webManager.utilities().escapeHTML(reason)}">
324
+ <label class="form-check-label" for="cancel-reason-${i}">${webManager.utilities().escapeHTML(reason)}</label>
325
325
  </div>
326
326
  `).join('');
327
327
  }
@@ -338,7 +338,7 @@ function updateUsageInfo(account) {
338
338
 
339
339
  // Use the effective plan for usage limits (basic if cancelled/suspended)
340
340
  const resolved = webManager.auth().resolveSubscription(account);
341
- const product = brandData?.payment?.products?.find(p => p.id === resolved.plan);
341
+ const product = paymentConfig?.products?.find(p => p.id === resolved.plan);
342
342
  const limits = product?.limits || {};
343
343
 
344
344
  // Clear container
@@ -404,9 +404,9 @@ function getDisplayName(subscription) {
404
404
  return subscription.product.name;
405
405
  }
406
406
 
407
- // Fall back to brandData product name
407
+ // Fall back to config product name
408
408
  const productId = subscription.product?.id || 'basic';
409
- const product = brandData?.payment?.products?.find(p => p.id === productId);
409
+ const product = paymentConfig?.products?.find(p => p.id === productId);
410
410
  return product?.name || 'Free';
411
411
  }
412
412
 
@@ -7,7 +7,7 @@ import { FormManager } from '__main_assets__/js/libs/form-manager.js';
7
7
  import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
8
8
  import webManager from 'web-manager';
9
9
 
10
- let brandData = null;
10
+ let oauth2Config = null;
11
11
  let accountData = null;
12
12
  let connectionForms = new Map();
13
13
 
@@ -24,13 +24,13 @@ export async function init() {
24
24
  }
25
25
 
26
26
  // Load connections data
27
- export async function loadData(account, sharedBrandData) {
27
+ export async function loadData(account, sharedOAuth2Config) {
28
28
  if (!account) {
29
29
  return;
30
30
  }
31
31
 
32
32
  accountData = account;
33
- brandData = sharedBrandData;
33
+ oauth2Config = sharedOAuth2Config;
34
34
 
35
35
  displayConnections();
36
36
  }
@@ -43,14 +43,14 @@ function displayConnections() {
43
43
  $loading.classList.add('d-none');
44
44
  }
45
45
 
46
- if (!brandData) {
46
+ if (!oauth2Config) {
47
47
  if ($loading) {
48
48
  $loading.classList.remove('d-none');
49
49
  }
50
50
  return;
51
51
  }
52
52
 
53
- const availableProviders = brandData?.oauth2 || {};
53
+ const availableProviders = oauth2Config;
54
54
  const userConnections = accountData?.oauth2 || {};
55
55
 
56
56
  let hasEnabledProviders = false;
@@ -237,7 +237,7 @@ function initializeProviderForm(providerId) {
237
237
  formManager.on('statechange', ({ state }) => {
238
238
  if (state === 'ready') {
239
239
  const userConnection = accountData?.oauth2?.[providerId];
240
- const providerSettings = brandData?.oauth2?.[providerId];
240
+ const providerSettings = oauth2Config?.[providerId];
241
241
  updateProviderStatus(providerId, userConnection, providerSettings);
242
242
  }
243
243
  });
@@ -252,7 +252,7 @@ function initializeProviderForm(providerId) {
252
252
  const success = await handleDisconnect(provider);
253
253
 
254
254
  if (success) {
255
- const providerSettings = brandData?.oauth2?.[provider];
255
+ const providerSettings = oauth2Config?.[provider];
256
256
  updateProviderStatus(provider, null, providerSettings);
257
257
  }
258
258
  }
@@ -261,7 +261,7 @@ function initializeProviderForm(providerId) {
261
261
 
262
262
  // Handle connect action
263
263
  async function handleConnect(providerId) {
264
- const provider = brandData?.oauth2?.[providerId];
264
+ const provider = oauth2Config?.[providerId];
265
265
 
266
266
  if (!provider || provider.enabled === false) {
267
267
  throw new Error('This connection service is not available.');
@@ -281,7 +281,7 @@ async function handleConnect(providerId) {
281
281
  tries: 2,
282
282
  });
283
283
 
284
- if (response.url) {
284
+ if (response.url && /^https?:\/\//i.test(response.url)) {
285
285
  window.location.href = response.url;
286
286
  } else {
287
287
  throw new Error(response.message || 'Failed to get authorization URL');
@@ -322,7 +322,7 @@ async function handleDisconnect(providerId) {
322
322
 
323
323
  // Called when section is shown
324
324
  export function onShow() {
325
- if (accountData && brandData) {
325
+ if (accountData && oauth2Config) {
326
326
  displayConnections();
327
327
  }
328
328
  }
@@ -110,12 +110,12 @@ function updateReferralsList(referrals) {
110
110
  <span class="badge bg-secondary me-2">#${sortedReferrals.length - index}</span>
111
111
  <div>
112
112
  <strong class="font-monospace small">${webManager.utilities().escapeHTML(referral.uid || 'Unknown User')}</strong>
113
- <div class="text-muted small">${dateStr}${timeStr ? ` at ${timeStr}` : ''}</div>
113
+ <div class="text-muted small">${webManager.utilities().escapeHTML(dateStr)}${timeStr ? ` at ${webManager.utilities().escapeHTML(timeStr)}` : ''}</div>
114
114
  </div>
115
115
  </div>
116
116
  </div>
117
117
  <div class="text-end">
118
- ${getTimeSince(timestamp)}
118
+ <small class="text-muted">${webManager.utilities().escapeHTML(getTimeSince(timestamp))}</small>
119
119
  </div>
120
120
  </div>
121
121
  </div>
@@ -146,48 +146,40 @@ function formatTime(date) {
146
146
 
147
147
  // Get time since string
148
148
  function getTimeSince(timestamp) {
149
- if (!timestamp) return '<small class="text-muted">Unknown</small>';
149
+ if (!timestamp) return 'Unknown';
150
150
 
151
151
  const now = Date.now();
152
152
  const diff = now - timestamp;
153
153
 
154
- // Less than 1 minute
155
- if (diff < 60000) {
156
- return '<small class="text-success">Just now</small>';
157
- }
154
+ if (diff < 60000) return 'Just now';
158
155
 
159
- // Less than 1 hour
160
156
  if (diff < 3600000) {
161
157
  const minutes = Math.floor(diff / 60000);
162
- return `<small class="text-muted">${minutes} min${minutes > 1 ? 's' : ''} ago</small>`;
158
+ return `${minutes} min${minutes > 1 ? 's' : ''} ago`;
163
159
  }
164
160
 
165
- // Less than 24 hours
166
161
  if (diff < 86400000) {
167
162
  const hours = Math.floor(diff / 3600000);
168
- return `<small class="text-muted">${hours} hour${hours > 1 ? 's' : ''} ago</small>`;
163
+ return `${hours} hour${hours > 1 ? 's' : ''} ago`;
169
164
  }
170
165
 
171
- // Less than 7 days
172
166
  if (diff < 604800000) {
173
167
  const days = Math.floor(diff / 86400000);
174
- return `<small class="text-muted">${days} day${days > 1 ? 's' : ''} ago</small>`;
168
+ return `${days} day${days > 1 ? 's' : ''} ago`;
175
169
  }
176
170
 
177
- // Less than 30 days
178
171
  if (diff < 2592000000) {
179
172
  const weeks = Math.floor(diff / 604800000);
180
- return `<small class="text-muted">${weeks} week${weeks > 1 ? 's' : ''} ago</small>`;
173
+ return `${weeks} week${weeks > 1 ? 's' : ''} ago`;
181
174
  }
182
175
 
183
- // More than 30 days
184
176
  const months = Math.floor(diff / 2592000000);
185
177
  if (months < 12) {
186
- return `<small class="text-muted">${months} month${months > 1 ? 's' : ''} ago</small>`;
178
+ return `${months} month${months > 1 ? 's' : ''} ago`;
187
179
  }
188
180
 
189
181
  const years = Math.floor(months / 12);
190
- return `<small class="text-muted">${years} year${years > 1 ? 's' : ''} ago</small>`;
182
+ return `${years} year${years > 1 ? 's' : ''} ago`;
191
183
  }
192
184
 
193
185
  // Setup button handlers
@@ -128,8 +128,8 @@ function populateRefundReasons() {
128
128
 
129
129
  $container.innerHTML = shuffled.map((reason, i) => `
130
130
  <div class="form-check mb-2">
131
- <input class="form-check-input" type="radio" name="refund_reason" id="refund-reason-${i}" value="${reason}" required>
132
- <label class="form-check-label" for="refund-reason-${i}">${reason}</label>
131
+ <input class="form-check-input" type="radio" name="refund_reason" id="refund-reason-${i}" value="${webManager.utilities().escapeHTML(reason)}" required>
132
+ <label class="form-check-label" for="refund-reason-${i}">${webManager.utilities().escapeHTML(reason)}</label>
133
133
  </div>
134
134
  `).join('');
135
135
  }
@@ -301,7 +301,7 @@ async function updateActiveSessions(account) {
301
301
  </div>
302
302
  </div>
303
303
  <div class="text-end">
304
- <small class="text-muted">${formatDate(session.timestamp || (session.timestampUNIX * 1000))}</small>
304
+ <small class="text-muted">${webManager.utilities().escapeHTML(formatDate(session.timestamp || (session.timestampUNIX * 1000)))}</small>
305
305
  ${session.isCurrent ? '<span class="badge bg-primary ms-2">Current</span>' : ''}
306
306
  </div>
307
307
  </div>
@@ -66,7 +66,7 @@ function updateInviteStatus(invites) {
66
66
  <div class="d-flex justify-content-between align-items-center">
67
67
  <div>
68
68
  <strong>${webManager.utilities().escapeHTML(invite.email)}</strong>
69
- <small class="text-muted d-block">Invited ${formatDate(invite.invitedAt)}</small>
69
+ <small class="text-muted d-block">Invited ${webManager.utilities().escapeHTML(formatDate(invite.invitedAt))}</small>
70
70
  </div>
71
71
  <div>
72
72
  <button class="btn btn-sm btn-outline-danger" data-action="cancel-invite" data-invite-id="${webManager.utilities().escapeHTML(invite.id)}">
@@ -26,7 +26,13 @@ async function renderEmailPreview(formData) {
26
26
  md = new MarkdownIt({ html: true, breaks: true, linkify: true });
27
27
  }
28
28
 
29
- const renderedContent = content ? md.render(content) : '<p class="text-muted">No content yet</p>';
29
+ const DOMPurify = (await import('dompurify')).default;
30
+ const renderedContent = content
31
+ ? DOMPurify.sanitize(md.render(content), {
32
+ ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'ul', 'ol', 'li', 'a', 'b', 'strong', 'i', 'em', 'u', 's', 'del', 'blockquote', 'pre', 'code', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'div', 'span', 'sup', 'sub'],
33
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'width', 'height', 'class', 'style', 'target', 'rel'],
34
+ })
35
+ : '<p class="text-muted">No content yet</p>';
30
36
 
31
37
  return `
32
38
  <div class="email-preview">
@@ -4,8 +4,8 @@
4
4
 
5
5
  // Libraries
6
6
  import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
7
- import fetch from 'wonderful-fetch';
8
7
  import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
8
+ import { getProducts } from '__main_assets__/js/libs/payment-config.js';
9
9
  import { formatTimeAgo, capitalize, setStatValue, setStatSubValue } from '__main_assets__/js/libs/admin-helpers.js';
10
10
  import { Chart, DoughnutController, BarController, ArcElement, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
11
11
  import webManager from 'web-manager';
@@ -80,13 +80,8 @@ async function loadSubscriberData() {
80
80
  const { collection, query, where, getCountFromServer } = await import('firebase/firestore');
81
81
  const db = webManager.firebaseFirestore;
82
82
 
83
- // Fetch brand config to get product list and available frequencies
84
- const brandConfig = await fetch(`${webManager.getApiUrl()}/backend-manager/brand`, {
85
- response: 'json',
86
- tries: 2,
87
- });
88
-
89
- const products = (brandConfig?.payment?.products || []).filter((p) => p.id !== 'basic');
83
+ // Get product list from _config.yml (available instantly via webManager.config)
84
+ const products = getProducts().filter((p) => p.id !== 'basic');
90
85
  const frequencyIds = [...new Set(products.flatMap((p) => Object.keys(p.prices || {})))];
91
86
 
92
87
  // Run count queries for each product × frequency in parallel
@@ -1,6 +1,7 @@
1
1
  // Payment Checkout Page
2
2
  import { FormManager } from '__main_assets__/js/libs/form-manager.js';
3
- import { fetchBrandConfig, fetchTrialEligibility, warmupServer, createPaymentIntent } from './modules/api.js';
3
+ import { getPaymentConfig, getProcessors, getProductById } from '__main_assets__/js/libs/payment-config.js';
4
+ import { fetchTrialEligibility, warmupServer, createPaymentIntent } from './modules/api.js';
4
5
  import { state, buildBindingsState, resolveProcessor, FREQUENCIES, getAvailableFrequencies } from './modules/state.js';
5
6
  import { applyDiscountCode } from './modules/discount.js';
6
7
  import { initializeRecaptcha } from './modules/recaptcha.js';
@@ -71,15 +72,24 @@ async function initializeCheckout() {
71
72
  throw new Error('Product ID is missing from URL.');
72
73
  }
73
74
 
75
+ // Read payment config from _config.yml (available instantly via webManager.config)
76
+ state.processors = getProcessors();
77
+
78
+ // Find product
79
+ const product = getProductById(productId);
80
+ if (!product) {
81
+ throw new Error(`Product "${productId}" not found.`);
82
+ }
83
+ state.product = product;
84
+
74
85
  // Wait for auth state to settle before any authorized calls
75
86
  await new Promise((resolve) => webManager.auth().listen({ once: true }, resolve));
76
87
 
77
88
  // Fire-and-forget server warmup
78
89
  warmupServer();
79
90
 
80
- // Parallel fetch: brand config + trial eligibility + reCAPTCHA
81
- const [brandConfigResult, trialResult, recaptchaResult] = await Promise.allSettled([
82
- fetchBrandConfig(),
91
+ // Parallel fetch: trial eligibility + reCAPTCHA
92
+ const [trialResult, recaptchaResult] = await Promise.allSettled([
83
93
  fetchTrialEligibility(),
84
94
  initializeRecaptcha(webManager.config?.recaptcha?.['site-key']),
85
95
  ]);
@@ -96,23 +106,6 @@ async function initializeCheckout() {
96
106
  }
97
107
  /* @dev-only:end */
98
108
 
99
- // Brand config is required
100
- if (brandConfigResult.status === 'rejected') {
101
- const reason = brandConfigResult.reason?.message || brandConfigResult.reason || 'Unknown error';
102
- throw new Error(`Failed to load checkout brand config: ${reason}`);
103
- }
104
-
105
- const brandConfig = brandConfigResult.value;
106
- state.brandConfig = brandConfig;
107
- state.processors = brandConfig.payment?.processors || {};
108
-
109
- // Find product
110
- const product = brandConfig.payment?.products?.find(p => p.id === productId);
111
- if (!product) {
112
- throw new Error(`Product "${productId}" not found.`);
113
- }
114
- state.product = product;
115
-
116
109
  // Resolve frequency: URL param if valid, otherwise longest available term
117
110
  const available = getAvailableFrequencies(product);
118
111
  if (frequencyParam && FREQUENCIES.includes(frequencyParam) && available.includes(frequencyParam)) {
@@ -286,7 +279,7 @@ function initDevPanel() {
286
279
  // Show the panel
287
280
  $panel.hidden = false;
288
281
 
289
- const products = state.brandConfig?.payment?.products || [];
282
+ const products = getPaymentConfig().products || [];
290
283
  const params = new URLSearchParams(window.location.search);
291
284
 
292
285
  // Populate product dropdown
@@ -4,17 +4,6 @@ import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
4
4
  import { getRecaptchaToken } from './recaptcha.js';
5
5
  import webManager from 'web-manager';
6
6
 
7
- // Fetch brand config (products + processors)
8
- export async function fetchBrandConfig() {
9
- const response = await fetch(`${webManager.getApiUrl()}/backend-manager/brand`, {
10
- response: 'json',
11
- tries: 2,
12
- });
13
-
14
- console.log('Fetched brand config:', response);
15
- return response;
16
- }
17
-
18
7
  // Check trial eligibility via backend endpoint
19
8
  export async function fetchTrialEligibility() {
20
9
  try {
@@ -9,8 +9,7 @@ export const FREQUENCIES = ['daily', 'weekly', 'monthly', 'annually'];
9
9
 
10
10
  // Minimal mutable state
11
11
  export const state = {
12
- // From API (stored once, never transformed)
13
- brandConfig: null,
12
+ // From config (stored once, never transformed)
14
13
  product: null,
15
14
  processors: null,
16
15
 
@@ -319,7 +319,7 @@ function setupPromoCountdown() {
319
319
  adjustNavbarOffset();
320
320
  }
321
321
 
322
- // Disable button for the user's current active plan
322
+ // Update buttons based on the user's current active plan
323
323
  function setupCurrentPlanIndicator() {
324
324
  webManager.auth().listen({ once: true }, (state) => {
325
325
  const resolved = webManager.auth().resolveSubscription(state.account);
@@ -328,16 +328,22 @@ function setupCurrentPlanIndicator() {
328
328
  return;
329
329
  }
330
330
 
331
- const $button = document.querySelector(`button[data-plan-id="${resolved.plan}"]`);
332
-
333
- if (!$button) {
334
- return;
331
+ // Mark current plan button
332
+ const $currentButton = document.querySelector(`button[data-plan-id="${resolved.plan}"]`);
333
+ if ($currentButton) {
334
+ $currentButton.disabled = true;
335
+ $currentButton.textContent = 'Current Plan';
336
+ $currentButton.classList.remove('btn-primary', 'btn-gradient-rainbow', 'gradient-animated');
337
+ $currentButton.classList.add('btn-adaptive');
335
338
  }
336
339
 
337
- $button.disabled = true;
338
- $button.textContent = 'Current Plan';
339
- $button.classList.remove('btn-primary', 'btn-gradient-rainbow', 'gradient-animated');
340
- $button.classList.add('btn-adaptive');
340
+ // Update other paid plan buttons to "Switch to this plan"
341
+ document.querySelectorAll('button[data-plan-id]').forEach(($button) => {
342
+ if ($button.dataset.planId === resolved.plan || $button.dataset.planId === 'basic' || $button.dataset.planId === 'enterprise') {
343
+ return;
344
+ }
345
+ $button.textContent = 'Switch to This Plan';
346
+ });
341
347
  });
342
348
  }
343
349
 
@@ -102,6 +102,7 @@
102
102
  {% endfor %}
103
103
  env: {{ page.resolved.web_manager.env | jsonify }},
104
104
  recaptcha: {{ page.resolved.recaptcha | jsonify }},
105
+ oauth2: {{ page.resolved.oauth2 | jsonify }},
105
106
  };
106
107
  </script>
107
108
 
@@ -328,6 +328,32 @@ faqs:
328
328
 
329
329
  <div class="row g-4 mb-5 justify-content-center">
330
330
  {% for plan in page.resolved.pricing.plans %}
331
+ {% comment %} Look up matching product in _config.yml payment products {% endcomment %}
332
+ {% assign _config_product = nil %}
333
+ {% for p in site.web_manager.payment.products %}
334
+ {% if p.id == plan.id %}
335
+ {% assign _config_product = p %}
336
+ {% break %}
337
+ {% endif %}
338
+ {% endfor %}
339
+
340
+ {% comment %} Resolve prices: frontmatter pricing takes precedence, then config prices {% endcomment %}
341
+ {% if plan.pricing.monthly or plan.pricing.monthly == 0 %}
342
+ {% assign _plan_monthly = plan.pricing.monthly %}
343
+ {% elsif _config_product.prices.monthly or _config_product.prices.monthly == 0 %}
344
+ {% assign _plan_monthly = _config_product.prices.monthly %}
345
+ {% else %}
346
+ {% assign _plan_monthly = 0 %}
347
+ {% endif %}
348
+
349
+ {% if plan.pricing.annually or plan.pricing.annually == 0 %}
350
+ {% assign _plan_annually = plan.pricing.annually %}
351
+ {% elsif _config_product.prices.annually or _config_product.prices.annually == 0 %}
352
+ {% assign _plan_annually = _config_product.prices.annually %}
353
+ {% else %}
354
+ {% assign _plan_annually = 0 %}
355
+ {% endif %}
356
+
331
357
  {% if plan.popular %}
332
358
  {% assign border_classes = "border-gradient-rainbow border-3" %}
333
359
  {% else %}
@@ -349,12 +375,12 @@ faqs:
349
375
  <p class="text-muted small mb-3">{{ plan.tagline }}</p>
350
376
 
351
377
  <!-- Price -->
352
- {% if plan.pricing.monthly == 0 %}
378
+ {% if _plan_monthly == 0 %}
353
379
  <p class="display-6 fw-bold mb-0">Free</p>
354
380
  {% else %}
355
381
  <p class="mb-0">
356
382
  <span class="display-6 fw-bold">
357
- <span class="fs-3">$</span><span class="amount" data-monthly="{{ plan.pricing.monthly }}" data-annually="{{ plan.pricing.annually | divided_by: 12 | round }}">{{ plan.pricing.annually | divided_by: 12 | round }}</span>
383
+ <span class="fs-3">$</span><span class="amount" data-monthly="{{ _plan_monthly }}" data-annually="{{ _plan_annually | divided_by: 12 | round }}">{{ _plan_annually | divided_by: 12 | round }}</span>
358
384
  </span>
359
385
  <small class="text-muted">/month</small>
360
386
  </p>
@@ -363,12 +389,17 @@ faqs:
363
389
  <!-- Price per unit (only shown when enabled) -->
364
390
  {% if page.resolved.pricing.price_per_unit.enabled %}
365
391
  <p class="text-muted small mb-4">
366
- {% if plan.pricing.monthly > 0 %}
392
+ {% if _plan_monthly > 0 %}
367
393
  {% for feature in plan.features %}
368
394
  {% if feature.id == page.resolved.pricing.price_per_unit.feature_id %}
369
- {% assign monthly_price_per_unit = plan.pricing.monthly | times: 1.0 | divided_by: feature.value | round: 2 %}
370
- {% assign annual_monthly_price = plan.pricing.annually | divided_by: 12.0 %}
371
- {% assign annual_price_per_unit = annual_monthly_price | divided_by: feature.value | round: 2 %}
395
+ {% comment %} Resolve feature value from config limits if not set in frontmatter {% endcomment %}
396
+ {% assign _ppu_value = feature.value %}
397
+ {% if _config_product and _ppu_value == nil %}
398
+ {% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _ppu_value = _lim[1] %}{% break %}{% endif %}{% endfor %}
399
+ {% endif %}
400
+ {% assign monthly_price_per_unit = _plan_monthly | times: 1.0 | divided_by: _ppu_value | round: 2 %}
401
+ {% assign annual_monthly_price = _plan_annually | divided_by: 12.0 %}
402
+ {% assign annual_price_per_unit = annual_monthly_price | divided_by: _ppu_value | round: 2 %}
372
403
  <span class="price-per-unit" data-monthly="${{ monthly_price_per_unit }}" data-annually="${{ annual_price_per_unit }}">${{ annual_price_per_unit }}</span> per {{ page.resolved.pricing.price_per_unit.label }}
373
404
  {% endif %}
374
405
  {% endfor %}
@@ -389,8 +420,8 @@ faqs:
389
420
  {% assign btn_style = "btn-primary" %}
390
421
 
391
422
  {% iftruthy plan.url %}
392
- <a href="{{ plan.url }}" class="btn {% if plan.pricing.monthly == 0 %}btn-adaptive{% else %}{{ btn_style }}{% endif %} btn-md fw-semibold px-2 fs-5">
393
- {% if plan.pricing.monthly == 0 %}
423
+ <a href="{{ plan.url }}" class="btn {% if _plan_monthly == 0 %}btn-adaptive{% else %}{{ btn_style }}{% endif %} btn-md fw-semibold px-2 fs-5">
424
+ {% if _plan_monthly == 0 %}
394
425
  Get Started
395
426
  {% else %}
396
427
  Get Free Trial
@@ -406,11 +437,11 @@ faqs:
406
437
 
407
438
  <!-- Billing info -->
408
439
  <p class="text-center text-muted small mb-3">
409
- {% if plan.pricing.monthly == 0 %}
440
+ {% if _plan_monthly == 0 %}
410
441
  <span>No credit card required</span>
411
442
  {% else %}
412
- <span class="billing-info" data-monthly="Billed ${{ plan.pricing.monthly | uj_commaify }} monthly" data-annually="Billed ${{ plan.pricing.annually | uj_commaify }} annually">
413
- Billed ${{ plan.pricing.annually | uj_commaify }} annually
443
+ <span class="billing-info" data-monthly="Billed ${{ _plan_monthly | uj_commaify }} monthly" data-annually="Billed ${{ _plan_annually | uj_commaify }} annually">
444
+ Billed ${{ _plan_annually | uj_commaify }} annually
414
445
  </span>
415
446
  {% endif %}
416
447
  </p>
@@ -423,6 +454,16 @@ faqs:
423
454
  <ul class="list-unstyled mb-3">
424
455
  {% for feature in plan.features %}
425
456
  {% if common_feature_ids contains feature.id %}
457
+ {% comment %} Resolve feature value: frontmatter > config limits {% endcomment %}
458
+ {% assign _feature_value = feature.value %}
459
+ {% if _config_product and _feature_value == nil %}
460
+ {% assign _config_limit = nil %}{% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _config_limit = _lim[1] %}{% break %}{% endif %}{% endfor %}
461
+ {% if _config_limit == -1 %}
462
+ {% assign _feature_value = "Unlimited" %}
463
+ {% elsif _config_limit %}
464
+ {% assign _feature_value = _config_limit %}
465
+ {% endif %}
466
+ {% endif %}
426
467
  {% assign feature_definition = nil %}
427
468
  {% for def in page.resolved.pricing.definitions %}
428
469
  {% if def.id == feature.id %}
@@ -433,10 +474,10 @@ faqs:
433
474
  <li class="d-flex align-items-start mb-2">
434
475
  <span class="me-3">{% uj_icon feature.icon, "fa-md" %}</span>
435
476
  <span>
436
- {% if feature.value == "Unlimited" %}
477
+ {% if _feature_value == "Unlimited" %}
437
478
  Unlimited
438
479
  {% else %}
439
- {{ feature.value | uj_commaify }}
480
+ {{ _feature_value | uj_commaify }}
440
481
  {% endif %}
441
482
  {% iftruthy feature_definition %}
442
483
  <span class="text-decoration-underline text-decoration-dotted cursor-help" data-bs-toggle="tooltip" data-bs-title="{{ feature_definition }}">{{ feature.name }}</span>
@@ -467,6 +508,16 @@ faqs:
467
508
  <ul class="list-unstyled mb-0">
468
509
  {% for feature in plan.features %}
469
510
  {% unless common_feature_ids contains feature.id %}
511
+ {% comment %} Resolve feature value: frontmatter > config limits {% endcomment %}
512
+ {% assign _feature_value = feature.value %}
513
+ {% if _config_product and _feature_value == nil %}
514
+ {% assign _config_limit = nil %}{% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _config_limit = _lim[1] %}{% break %}{% endif %}{% endfor %}
515
+ {% if _config_limit == -1 %}
516
+ {% assign _feature_value = "Unlimited" %}
517
+ {% elsif _config_limit %}
518
+ {% assign _feature_value = _config_limit %}
519
+ {% endif %}
520
+ {% endif %}
470
521
  {% assign feature_definition = nil %}
471
522
  {% for def in page.resolved.pricing.definitions %}
472
523
  {% if def.id == feature.id %}
@@ -477,12 +528,12 @@ faqs:
477
528
  <li class="d-flex align-items-start mb-2 {% if forloop.last %}mb-0{% endif %}">
478
529
  <span class="me-3">{% uj_icon feature.icon, "fa-md" %}</span>
479
530
  <span>
480
- {% if feature.value == "24/7" %}
481
- {{ feature.value }}
482
- {% elsif feature.value == "Included" or feature.value == "Available" or feature.value == "Full" %}
531
+ {% if _feature_value == "24/7" %}
532
+ {{ _feature_value }}
533
+ {% elsif _feature_value == "Included" or _feature_value == "Available" or _feature_value == "Full" %}
483
534
  <!-- No prefix for these values -->
484
535
  {% else %}
485
- {{ feature.value | uj_commaify }}
536
+ {{ _feature_value | uj_commaify }}
486
537
  {% endif %}
487
538
  {% iftruthy feature_definition %}
488
539
  <span class="text-decoration-underline text-decoration-dotted cursor-help" data-bs-toggle="tooltip" data-bs-title="{{ feature_definition }}">{{ feature.name }}</span>
@@ -509,16 +560,12 @@ faqs:
509
560
  {% endunless %}
510
561
  {% endfor %}
511
562
 
563
+ {% assign _prev_index = forloop.index0 | minus: 1 %}
564
+ {% assign _prev_plan = page.resolved.pricing.plans[_prev_index] %}
512
565
  <div class="text-muted small mb-2">
513
566
  <em>Everything in</em>
514
567
  <em>
515
- <strong>
516
- {%- if forloop.index == 1 -%}Free{%- endif -%}
517
- {%- if forloop.index == 2 -%}Basic{%- endif -%}
518
- {%- if forloop.index == 3 -%}Starter{%- endif -%}
519
- {%- if forloop.index == 4 -%}Pro{%- endif -%}
520
- {%- if forloop.index == 5 -%}Max{% endif -%}
521
- </strong>
568
+ <strong>{{ _prev_plan.name }}</strong>
522
569
  {%- if has_additional -%}, plus:{%- endif -%}
523
570
  </em>
524
571
  </div>
@@ -528,6 +575,16 @@ faqs:
528
575
  <ul class="list-unstyled mb-0">
529
576
  {% for feature in plan.features %}
530
577
  {% unless common_feature_ids contains feature.id %}
578
+ {% comment %} Resolve feature value: frontmatter > config limits {% endcomment %}
579
+ {% assign _feature_value = feature.value %}
580
+ {% if _config_product and _feature_value == nil %}
581
+ {% assign _config_limit = nil %}{% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _config_limit = _lim[1] %}{% break %}{% endif %}{% endfor %}
582
+ {% if _config_limit == -1 %}
583
+ {% assign _feature_value = "Unlimited" %}
584
+ {% elsif _config_limit %}
585
+ {% assign _feature_value = _config_limit %}
586
+ {% endif %}
587
+ {% endif %}
531
588
  {% assign feature_definition = nil %}
532
589
  {% for def in page.resolved.pricing.definitions %}
533
590
  {% if def.id == feature.id %}
@@ -538,12 +595,12 @@ faqs:
538
595
  <li class="d-flex align-items-start mb-2 {% if forloop.last %}mb-0{% endif %}">
539
596
  <span class="me-3">{% uj_icon feature.icon, "fa-md" %}</span>
540
597
  <span>
541
- {% if feature.value == "24/7" %}
542
- {{ feature.value }}
543
- {% elsif feature.value == "Included" or feature.value == "Available" or feature.value == "Full" %}
598
+ {% if _feature_value == "24/7" %}
599
+ {{ _feature_value }}
600
+ {% elsif _feature_value == "Included" or _feature_value == "Available" or _feature_value == "Full" %}
544
601
  <!-- No prefix for these values -->
545
602
  {% else %}
546
- {{ feature.value | uj_commaify }}
603
+ {{ _feature_value | uj_commaify }}
547
604
  {% endif %}
548
605
  {% iftruthy feature_definition %}
549
606
  <span class="text-decoration-underline text-decoration-dotted cursor-help" data-bs-toggle="tooltip" data-bs-title="{{ feature_definition }}">{{ feature.name }}</span>
@@ -664,6 +721,14 @@ faqs:
664
721
  {% endiffalsy %}
665
722
  </th>
666
723
  {% for plan in page.resolved.pricing.plans %}
724
+ {% comment %} Look up config product for this plan {% endcomment %}
725
+ {% assign _tbl_config_product = nil %}
726
+ {% for p in site.web_manager.payment.products %}
727
+ {% if p.id == plan.id %}
728
+ {% assign _tbl_config_product = p %}
729
+ {% break %}
730
+ {% endif %}
731
+ {% endfor %}
667
732
  <td>
668
733
  {% assign has_feature = false %}
669
734
  {% assign feature_value = nil %}
@@ -678,6 +743,16 @@ faqs:
678
743
  {% endif %}
679
744
  {% endfor %}
680
745
 
746
+ {% comment %} Resolve feature value from config limits if not set {% endcomment %}
747
+ {% if has_feature and feature_value == nil and _tbl_config_product %}
748
+ {% assign _tbl_limit = nil %}{% for _lim in _tbl_config_product.limits %}{% if _lim[0] == feature_def.id %}{% assign _tbl_limit = _lim[1] %}{% break %}{% endif %}{% endfor %}
749
+ {% if _tbl_limit == -1 %}
750
+ {% assign feature_value = "Unlimited" %}
751
+ {% elsif _tbl_limit %}
752
+ {% assign feature_value = _tbl_limit %}
753
+ {% endif %}
754
+ {% endif %}
755
+
681
756
  {% unless has_feature %}
682
757
  {% for check_plan in page.resolved.pricing.plans %}
683
758
  {% if check_plan.id == plan.id %}
@@ -690,6 +765,26 @@ faqs:
690
765
  {% break %}
691
766
  {% endif %}
692
767
  {% endfor %}
768
+
769
+ {% comment %} Resolve inherited value from config limits if not set {% endcomment %}
770
+ {% if inherited_feature and inherited_value == nil %}
771
+ {% assign _inh_config_product = nil %}
772
+ {% for p in site.web_manager.payment.products %}
773
+ {% if p.id == check_plan.id %}
774
+ {% assign _inh_config_product = p %}
775
+ {% break %}
776
+ {% endif %}
777
+ {% endfor %}
778
+ {% if _inh_config_product %}
779
+ {% assign _inh_limit = nil %}{% for _lim in _inh_config_product.limits %}{% if _lim[0] == feature_def.id %}{% assign _inh_limit = _lim[1] %}{% break %}{% endif %}{% endfor %}
780
+ {% if _inh_limit == -1 %}
781
+ {% assign inherited_value = "Unlimited" %}
782
+ {% elsif _inh_limit %}
783
+ {% assign inherited_value = _inh_limit %}
784
+ {% endif %}
785
+ {% endif %}
786
+ {% endif %}
787
+
693
788
  {% if inherited_feature %}
694
789
  {% break %}
695
790
  {% endif %}
@@ -6,13 +6,5 @@ permalink: /search/cse
6
6
  ### REGULAR PAGES ###
7
7
  redirect:
8
8
  url: "https://cse.google.com/cse?cx={{ page.resolved.advertising.cse.site-id }}&ie=UTF-8"
9
- modifier: "
10
- function (url) {
11
- var q = url.searchParams.get('q');
12
-
13
- url.searchParams.set('q', 'site:' + window.location.origin + ' ' + q);
14
-
15
- return url;
16
- }
17
- "
9
+ modifier: "search-cse"
18
10
  ---
@@ -134,18 +134,17 @@ web_manager:
134
134
  config:
135
135
  autoRequest: 1000 * 60
136
136
  validRedirectHosts: []
137
-
138
- # Payment
139
- payment:
140
- processors:
141
- stripe:
142
- publishableKey: null
143
- paypal:
144
- clientId: null
145
- chargebee:
146
- site: null
147
- coinbase:
148
- enabled: false
137
+ payment:
138
+ processors:
139
+ stripe:
140
+ publishableKey: null
141
+ paypal:
142
+ clientId: null
143
+ chargebee:
144
+ site: null
145
+ coinbase:
146
+ enabled: false
147
+ products: []
149
148
 
150
149
  # OAuth2
151
150
  oauth2:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.0.22",
3
+ "version": "1.1.1",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -78,6 +78,7 @@
78
78
  "chart.js": "^4.5.1",
79
79
  "cheerio": "^1.2.0",
80
80
  "chrome-launcher": "^1.2.1",
81
+ "dompurify": "^3.3.3",
81
82
  "dotenv": "^17.4.1",
82
83
  "fast-xml-parser": "^5.5.10",
83
84
  "fs-jetpack": "^5.1.0",