ultimate-jekyll-manager 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/assets/js/core/auth.js +24 -39
  3. package/dist/defaults/dist/_alternatives/example-competitor.md +6 -6
  4. package/dist/defaults/dist/_includes/admin/sections/sidebar.json +2 -2
  5. package/dist/defaults/dist/_includes/themes/classy/backend/sections/topbar.html +1 -1
  6. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/footer.html +7 -4
  7. package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +13 -13
  8. package/dist/defaults/dist/_layouts/blueprint/admin/firebase/index.html +1 -1
  9. package/dist/defaults/dist/_layouts/blueprint/admin/users/index.html +1 -1
  10. package/dist/defaults/dist/_layouts/blueprint/admin/users/new.html +5 -5
  11. package/dist/defaults/dist/_layouts/blueprint/auth/oauth2.html +1 -1
  12. package/dist/defaults/dist/_layouts/themes/classy/backend/pages/dashboard/index.html +12 -12
  13. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/about.html +1 -1
  14. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/alternatives/alternative.html +4 -4
  15. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/alternatives/index.html +5 -5
  16. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/download.html +2 -2
  17. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/payment/confirmation.html +1 -1
  18. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/pricing.html +3 -3
  19. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/team/index.html +2 -2
  20. package/dist/defaults/dist/_updates/v0.0.1.md +3 -0
  21. package/dist/defaults/dist/pages/test/account/dashboard.html +1 -1
  22. package/dist/defaults/dist/pages/test/libraries/ads.html +9 -9
  23. package/dist/defaults/dist/pages/test/libraries/bootstrap.html +6 -6
  24. package/dist/defaults/dist/pages/test/libraries/firestore.html +1 -1
  25. package/dist/defaults/dist/pages/test/libraries/form-manager.html +2 -2
  26. package/dist/defaults/dist/pages/test/libraries/lazy-loading.html +8 -8
  27. package/dist/defaults/dist/sitemap.html +2 -2
  28. package/dist/gulp/tasks/imagemin.js +30 -5
  29. package/dist/utils/attach-log-file.js +24 -16
  30. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -14,6 +14,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ ---
18
+ ## [1.4.3] - 2026-05-28
19
+
20
+ ### Fixed
21
+
22
+ - **Imagemin: uppercase-extension images (e.g. `IMG_3119.JPG`) now build end to end.** v1.4.2 made the glob case-insensitive so the file was discovered, but `gulp-responsive-modern`'s `lib/format.js` does a case-sensitive `switch` on `path.extname()` and returns the string `'unsupported'` for `.JPG`, which then crashes `sharp.toFormat()`. [src/gulp/tasks/imagemin.js](src/gulp/tasks/imagemin.js) now pipes each file through an in-stream `Transform` that lowercases the extension on the Vinyl path before the responsive plugin sees it (the on-disk source is left untouched).
23
+ - **Log files no longer truncate before the crash that caused them.** [src/utils/attach-log-file.js](src/utils/attach-log-file.js) switched from `fs.createWriteStream` (async-buffered) to synchronous `fs.writeSync` against an open fd. The buffered stream dropped its tail when a gulp task threw and the process exited — so the lines describing the failure never reached `logs/build.log`. Synchronous writes guarantee the full error + stack survive an immediate exit.
24
+
25
+ ### Changed
26
+
27
+ - **Auth: signup-consent gating now keys off the user doc's `flags.signupProcessed` instead of a time window.** [src/assets/js/core/auth.js](src/assets/js/core/auth.js) drops the `SIGNUP_MAX_AGE` (5-minute) heuristic and the client-only `localStorage` flag. `sendUserSignupMetadata` fires whenever the doc shows signup unprocessed (the server is idempotent), and the consent guard only signs a user out once signup has actually been processed — removing the risk of locking users out on a transient metadata-send failure.
28
+ - **Footer language dropdown always renders.** No longer gated on `site.translation.enabled`; falls back to `site.translation.default` (or `"en"`) when no extra languages are configured. [src/defaults/dist/_includes/themes/classy/frontend/sections/footer.html](src/defaults/dist/_includes/themes/classy/frontend/sections/footer.html)
29
+ - **Sentence-case copy normalization** across default pages (pricing, alternatives, admin/test pages, sitemap section labels): "API access", "Flash sale", "Root pages", "…and more:" etc.
30
+ - **Updates feed:** the `v0.0.1` sample entry is marked `draft: true` so it's hidden from the listing and sitemap (dev-only).
31
+
17
32
  ---
18
33
  ## [1.4.2] - 2026-05-27
19
34
 
@@ -1,9 +1,6 @@
1
1
  import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
2
2
  import webManager from 'web-manager';
3
3
 
4
- // Constants
5
- const SIGNUP_MAX_AGE = 5 * 60 * 1000;
6
-
7
4
  // Enforce page-load consent guard. When true, any authenticated user whose doc has
8
5
  // consent.legal.status !== 'granted' is silently signed out. Keep FALSE until the
9
6
  // legacy user migration runs (sets all existing docs to status='granted',
@@ -81,7 +78,7 @@ export default function () {
81
78
  // by the on-create auth event). sendUserSignupMetadata is what flips it
82
79
  // to 'granted' with the captured consent payload. If we gate first, every
83
80
  // fresh signup would be signed out before consent ever lands.
84
- await sendUserSignupMetadata(user);
81
+ await sendUserSignupMetadata(state.account);
85
82
 
86
83
  // Consent guard: if the user is authenticated but their account doc shows
87
84
  // no legal consent on record, they're an orphan from a reversed Google signup
@@ -89,16 +86,15 @@ export default function () {
89
86
  // user knows what happened.
90
87
  //
91
88
  // Gated by ENFORCE_CONSENT_GUARD (off until the legacy-user migration runs).
92
- // Also skipped for accounts younger than SIGNUP_MAX_AGE — sendUserSignupMetadata
93
- // above is responsible for the consent write on that path, but if it failed
94
- // (network error, server 500, etc.) the guard would otherwise lock the user
95
- // out forever. The 5min grace window lets a retry / refresh recover; after
96
- // that, the doc legitimately has no legal consent and the guard fires.
89
+ // Only fires once signup has been processed — sendUserSignupMetadata above is what
90
+ // writes consent, and it runs whenever flags.signupProcessed is false. If signup
91
+ // hasn't been processed yet (or just failed and will retry next load), we must NOT
92
+ // sign the user out; a processed doc with no legal consent is a genuine orphan
93
+ // (e.g. a reversed Google signup that failed to delete cleanly).
97
94
  if (ENFORCE_CONSENT_GUARD) {
98
- const accountAge = Date.now() - new Date(user.metadata.creationTime).getTime();
99
- const isFreshAccount = accountAge < SIGNUP_MAX_AGE;
95
+ const signupProcessed = state.account?.flags?.signupProcessed === true;
100
96
  const legalStatus = state.account?.consent?.legal?.status;
101
- if (!isFreshAccount && legalStatus && legalStatus !== 'granted') {
97
+ if (signupProcessed && legalStatus && legalStatus !== 'granted') {
102
98
  console.warn('[Auth] Signing out user with no legal consent on record');
103
99
  await webManager.auth().signOut();
104
100
  webManager.utilities().showNotification(
@@ -260,7 +256,7 @@ function setAnalyticsUserId(user) {
260
256
  }
261
257
 
262
258
  // Send user metadata to server (affiliate, UTM params, etc.)
263
- async function sendUserSignupMetadata(user) {
259
+ async function sendUserSignupMetadata(account) {
264
260
  try {
265
261
  // Skip on auth pages to avoid blocking redirect (metadata will be sent on destination page)
266
262
  const pagePath = document.documentElement.getAttribute('data-page-path');
@@ -269,20 +265,17 @@ async function sendUserSignupMetadata(user) {
269
265
  return;
270
266
  }
271
267
 
272
- // Check if this is a new user account (created in last X minutes)
273
- const accountAge = Date.now() - new Date(user.metadata.creationTime).getTime();
274
- const signupProcessed = webManager.storage().get('flags.signupProcessed', null) === user.uid;
268
+ // The user doc's flags.signupProcessed is the single source of truth. We have the full
269
+ // account doc on every page load, so gate on it directly — no account-age window, no
270
+ // client-only localStorage flag. Fire whenever the doc shows signup is unprocessed; the
271
+ // server is idempotent and rejects if it was already processed.
272
+ const signupProcessed = account?.flags?.signupProcessed === true;
275
273
 
276
274
  /* @dev-only:start */
277
- {
278
- // Log account age for debugging
279
- const ageInMinutes = Math.floor(accountAge / 1000 / 60);
280
- console.log('[Auth] Account age:', ageInMinutes, 'minutes, signupProcessed:', signupProcessed);
281
- }
275
+ console.log('[Auth] signupProcessed:', signupProcessed);
282
276
  /* @dev-only:end */
283
277
 
284
- // Only proceed if account is new and we haven't sent signup metadata yet
285
- if (accountAge >= SIGNUP_MAX_AGE || signupProcessed) {
278
+ if (signupProcessed) {
286
279
  return;
287
280
  }
288
281
 
@@ -312,27 +305,19 @@ async function sendUserSignupMetadata(user) {
312
305
  body: payload,
313
306
  });
314
307
 
315
- // Log
308
+ // Log — the server set flags.signupProcessed on the doc, so the next page load's
309
+ // state.account reflects it and this won't fire again. No client-side flag needed.
316
310
  console.log('[Auth] User metadata sent successfully:', response);
317
-
318
- // Mark signup as sent for this user (keep the attribution data for reference)
319
- webManager.storage().set('flags.signupProcessed', user.uid);
320
311
  } catch (error) {
321
312
  console.error('[Auth] Error sending user metadata:', error);
322
- // Don't throw - we don't want to block the signup flow
313
+ // Don't throw - we don't want to block the signup flow. The doc still shows
314
+ // signupProcessed=false, so a refresh / next page load retries automatically.
323
315
 
324
316
  /* @dev-only:start */
325
- {
326
- const accountAge = Date.now() - new Date(user.metadata.creationTime).getTime();
327
- const msRemaining = Math.max(0, SIGNUP_MAX_AGE - accountAge);
328
- const signoutAt = new Date(Date.now() + msRemaining).toLocaleTimeString();
329
- const minutes = Math.floor(msRemaining / 1000 / 60);
330
- const seconds = Math.floor((msRemaining / 1000) % 60);
331
- webManager.utilities().showNotification(
332
- `[DEV] Failed to send signup metadata. User will be signed out by consent guard at ${signoutAt} (in ${minutes}m ${seconds}s) unless retried.`,
333
- { type: 'warning', timeout: 0 }
334
- );
335
- }
317
+ webManager.utilities().showNotification(
318
+ `[DEV] Failed to send signup metadata. Will retry on next page load (flags.signupProcessed is still false).`,
319
+ { type: 'warning', timeout: 0 }
320
+ );
336
321
  /* @dev-only:end */
337
322
  }
338
323
  }
@@ -18,25 +18,25 @@ alternative:
18
18
 
19
19
  comparison:
20
20
  features:
21
- - name: "Free Plan"
21
+ - name: "Free plan"
22
22
  icon: "gift"
23
23
  ours:
24
24
  value: true
25
25
  theirs:
26
26
  value: true
27
- - name: "AI-Powered Features"
27
+ - name: "AI-powered features"
28
28
  icon: "sparkles"
29
29
  ours:
30
30
  value: "Advanced"
31
31
  theirs:
32
32
  value: "Basic"
33
- - name: "Real-time Collaboration"
33
+ - name: "Real-time collaboration"
34
34
  icon: "users"
35
35
  ours:
36
36
  value: true
37
37
  theirs:
38
38
  value: false
39
- - name: "API Access"
39
+ - name: "API access"
40
40
  icon: "code"
41
41
  ours:
42
42
  value: "Full REST API"
@@ -60,13 +60,13 @@ alternative:
60
60
  value: "200+"
61
61
  theirs:
62
62
  value: "50+"
63
- - name: "Mobile App"
63
+ - name: "Mobile app"
64
64
  icon: "mobile"
65
65
  ours:
66
66
  value: true
67
67
  theirs:
68
68
  value: true
69
- - name: "Export Formats"
69
+ - name: "Export formats"
70
70
  icon: "download"
71
71
  ours:
72
72
  value: "PDF, CSV, JSON"
@@ -19,7 +19,7 @@
19
19
  icon: 'users',
20
20
  dropdown: [
21
21
  {
22
- label: 'All Users',
22
+ label: 'All users',
23
23
  href: '/admin/users',
24
24
  icon: 'list'
25
25
  },
@@ -46,7 +46,7 @@
46
46
  },
47
47
  {
48
48
  header: true,
49
- label: 'External Tools'
49
+ label: 'External tools'
50
50
  },
51
51
  {
52
52
  label: 'Stackblitz',
@@ -36,7 +36,7 @@
36
36
  {% capture action_attributes %}{% if action.attributes %}{% for attr in action.attributes %} {{ attr[0] }}="{{ attr[1] }}"{% endfor %}{% endif %}{% endcapture %}
37
37
  <div class="dropdown">
38
38
  <button class="btn btn-link position-relative p-2" type="button" data-bs-toggle="dropdown" {{ action_attributes }} aria-expanded="false">
39
- {% uj_icon action.icon | default: "bell", "fa-lg text-body" %}
39
+ {% uj_icon action.icon, "fa-lg text-body" %}
40
40
  {% if action.badge %}
41
41
  <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill {{ action.badge.class | default: 'bg-danger' }}">
42
42
  {{ action.badge.text }}
@@ -73,8 +73,7 @@
73
73
  <div class="row mt-4 pt-3 border-top">
74
74
  <div class="col-md-6 d-flex align-items-center">
75
75
  <!-- Language Dropdown -->
76
- {% if site.translation.enabled and site.translation.languages.size > 0 %}
77
- <div class="d-inline-block me-3">
76
+ <div class="d-inline-block me-3">
78
77
  <div class="dropup uj-language-dropdown">
79
78
  <button class="btn btn-sm btn-outline-adaptive dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
80
79
  <i class="fa fa-sm me-1">
@@ -85,7 +84,12 @@
85
84
  </span>
86
85
  </button>
87
86
  <ul class="dropdown-menu">
88
- {% assign all_languages = site.translation.languages | push: site.translation.default %}
87
+ {% assign default_language = site.translation.default | default: "en" %}
88
+ {% if site.translation.languages.size > 0 %}
89
+ {% assign all_languages = site.translation.languages | push: default_language %}
90
+ {% else %}
91
+ {% assign all_languages = "" | split: "" | push: default_language %}
92
+ {% endif %}
89
93
  {% for language in all_languages %}
90
94
  <li>
91
95
  {% capture lang_name %}{% uj_language language, "native" %}{% endcapture %}
@@ -98,7 +102,6 @@
98
102
  </ul>
99
103
  </div>
100
104
  </div>
101
- {% endif %}
102
105
 
103
106
  <!-- Social Links -->
104
107
  {% if data.socials.enabled %}
@@ -6,7 +6,7 @@ layout: themes/classy/admin/core/minimal-viewport-locked
6
6
  theme:
7
7
  header:
8
8
  title:
9
- content: "Marketing Calendar"
9
+ content: "Marketing calendar"
10
10
  icon: "calendar-days"
11
11
  breadcrumbs:
12
12
  items:
@@ -16,7 +16,7 @@ theme:
16
16
  - label: "Calendar"
17
17
  actions:
18
18
  items:
19
- - label: "Create Campaign"
19
+ - label: "Create campaign"
20
20
  icon: "plus"
21
21
  color: "primary"
22
22
  attributes:
@@ -24,7 +24,7 @@ theme:
24
24
 
25
25
  ### REGULAR PAGES ###
26
26
  meta:
27
- title: "Marketing Calendar - Admin"
27
+ title: "Marketing calendar - Admin"
28
28
  description: "Schedule and manage marketing campaigns — emails and push notifications"
29
29
  breadcrumb: "Calendar"
30
30
 
@@ -88,7 +88,7 @@ prerender_icons:
88
88
  <!-- Campaign Type -->
89
89
  <h6 class="mb-3">
90
90
  {% uj_icon "bullhorn", "fa-sm me-2 text-info" %}
91
- Campaign Type
91
+ Campaign type
92
92
  </h6>
93
93
  <div class="row mb-4">
94
94
  <div class="col-6">
@@ -102,7 +102,7 @@ prerender_icons:
102
102
  <input type="radio" class="btn-check" name="campaign.type" id="campaign-type-push" value="push" autocomplete="off">
103
103
  <label class="btn btn-outline-adaptive w-100 d-flex align-items-center justify-content-center" for="campaign-type-push">
104
104
  {% uj_icon "bell", "fa-sm me-2" %}
105
- Push Notification
105
+ Push notification
106
106
  </label>
107
107
  </div>
108
108
  </div>
@@ -126,7 +126,7 @@ prerender_icons:
126
126
  <div class="tab-pane fade show active" id="panel-edit" role="tabpanel">
127
127
  <div class="mb-3">
128
128
  <label for="campaign-name" class="form-label">
129
- Campaign Name <span class="text-danger">*</span>
129
+ Campaign name <span class="text-danger">*</span>
130
130
  </label>
131
131
  <input type="text"
132
132
  class="form-control"
@@ -221,7 +221,7 @@ prerender_icons:
221
221
 
222
222
  <div class="mb-3">
223
223
  <label for="campaign-click-action" class="form-label">
224
- Click Action URL
224
+ Click action URL
225
225
  </label>
226
226
  <input type="url"
227
227
  class="form-control"
@@ -236,7 +236,7 @@ prerender_icons:
236
236
  <div class="mb-3">
237
237
  <label for="campaign-push-tags" class="form-label">
238
238
  {% uj_icon "tags", "fa-sm me-1 text-info" %}
239
- Filter by Tags
239
+ Filter by tags
240
240
  <small class="text-muted fw-normal ms-1">(comma-separated)</small>
241
241
  </label>
242
242
  <input type="text"
@@ -274,7 +274,7 @@ prerender_icons:
274
274
  <!-- Discount Code -->
275
275
  <div class="mb-3">
276
276
  <label for="campaign-discount-code" class="form-label">
277
- Discount Code
277
+ Discount code
278
278
  <small class="text-muted fw-normal ms-1">(optional)</small>
279
279
  </label>
280
280
  <input type="text"
@@ -332,7 +332,7 @@ prerender_icons:
332
332
  <div class="col-md-3 d-flex align-items-end">
333
333
  <button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-send-now">
334
334
  {% uj_icon "paper-plane", "fa-sm me-1" %}
335
- Send Now
335
+ Send now
336
336
  </button>
337
337
  </div>
338
338
  </div>
@@ -484,7 +484,7 @@ prerender_icons:
484
484
 
485
485
  <div class="mb-3">
486
486
  <label for="campaign-exclude-segments" class="form-label">
487
- Exclude Segments
487
+ Exclude segments
488
488
  <small class="text-muted fw-normal ms-1">(comma-separated IDs)</small>
489
489
  </label>
490
490
  <input type="text"
@@ -498,7 +498,7 @@ prerender_icons:
498
498
  <h6 class="mb-3 mt-4">
499
499
  <a class="text-decoration-none" data-bs-toggle="collapse" href="#advanced-settings" role="button" aria-expanded="false">
500
500
  {% uj_icon "sliders", "fa-sm me-2 text-info" %}
501
- Advanced Settings
501
+ Advanced settings
502
502
  <small class="text-muted fw-normal ms-1">(optional)</small>
503
503
  </a>
504
504
  </h6>
@@ -527,7 +527,7 @@ prerender_icons:
527
527
  </div>
528
528
 
529
529
  <div class="mb-3">
530
- <label class="form-label">UTM Overrides</label>
530
+ <label class="form-label">UTM overrides</label>
531
531
  <div class="row g-2" id="utm-fields">
532
532
  <div class="col-md-6">
533
533
  <input type="text" class="form-control form-control-sm" name="campaign.utm.utm_source" placeholder="utm_source">
@@ -131,7 +131,7 @@ prerender_icons:
131
131
  </button>
132
132
  </form>
133
133
  <button class="btn btn-sm btn-outline-adaptive w-100 mt-2 d-none" id="btn-clear-query">
134
- Clear Query
134
+ Clear query
135
135
  </button>
136
136
  </div>
137
137
  </div>
@@ -18,7 +18,7 @@ theme:
18
18
  meta:
19
19
  title: "Users - Admin"
20
20
  description: "Manage system users"
21
- breadcrumb: "All Users"
21
+ breadcrumb: "All users"
22
22
 
23
23
  prerender_icons:
24
24
  - name: "user"
@@ -6,7 +6,7 @@ layout: themes/classy/admin/core/minimal
6
6
  theme:
7
7
  header:
8
8
  title:
9
- content: "Create User"
9
+ content: "Create user"
10
10
  icon: "user-plus"
11
11
  breadcrumbs:
12
12
  items:
@@ -14,20 +14,20 @@ theme:
14
14
  href: "/admin"
15
15
  - label: "Users"
16
16
  href: "/admin/users"
17
- - label: "Create User"
17
+ - label: "Create user"
18
18
 
19
19
  ### REGULAR PAGES ###
20
20
  meta:
21
21
  title: "Create User - Admin"
22
22
  description: "Create a new user account"
23
- breadcrumb: "Create User"
23
+ breadcrumb: "Create user"
24
24
  ---
25
25
 
26
26
  <!-- Back Link -->
27
27
  <div class="mb-3">
28
28
  <a href="/admin/users" class="text-decoration-none">
29
29
  {% uj_icon "arrow-left", "fa-sm me-2" %}
30
- Back to Users
30
+ Back to users
31
31
  </a>
32
32
  </div>
33
33
 
@@ -51,7 +51,7 @@ meta:
51
51
  <div class="form-text">Must be at least 6 characters</div>
52
52
  </div>
53
53
  <div class="col-md-6 mb-3">
54
- <label for="confirmPassword" class="form-label">Confirm Password <span class="text-danger">*</span></label>
54
+ <label for="confirmPassword" class="form-label">Confirm password <span class="text-danger">*</span></label>
55
55
  <input type="password" class="form-control" id="confirmPassword" name="user.confirmPassword" required minlength="6">
56
56
  </div>
57
57
  </div>
@@ -6,7 +6,7 @@ layout: themes/[ site.theme.id ]/frontend/pages/auth/oauth2
6
6
  meta:
7
7
  title: "OAuth2 Authentication - {{ site.brand.name }}"
8
8
  description: "Connect your account using OAuth2 for secure authentication."
9
- breadcrumb: "OAuth2 Authentication"
9
+ breadcrumb: "OAuth2 authentication"
10
10
  ---
11
11
 
12
12
  {{ content | uj_content_format }}
@@ -16,21 +16,21 @@ theme:
16
16
  - label: Dashboard
17
17
  actions:
18
18
  items:
19
- - label: New Project
19
+ - label: New project
20
20
  icon: plus
21
21
  color: primary
22
22
  - label: Reports
23
23
  icon: chart-line
24
24
  color: adaptive
25
25
  dropdown:
26
- - label: Sales Report
26
+ - label: Sales report
27
27
  icon: dollar-sign
28
28
  href: /reports/sales
29
- - label: Analytics Report
29
+ - label: Analytics report
30
30
  icon: chart-bar
31
31
  href: /reports/analytics
32
32
  - divider: true
33
- - label: Export All
33
+ - label: Export all
34
34
  icon: download
35
35
  href: /reports/export
36
36
 
@@ -43,21 +43,21 @@ meta:
43
43
  ### PAGE CONFIG ###
44
44
  stats_cards:
45
45
  - id: "total_revenue"
46
- title: "Total Revenue"
46
+ title: "Total revenue"
47
47
  value: "$24,300"
48
48
  change: "+12.5%"
49
49
  change_type: "success"
50
50
  icon: "dollar-sign"
51
51
 
52
52
  - id: "new_users"
53
- title: "New Users"
53
+ title: "New users"
54
54
  value: "1,423"
55
55
  change: "+5.8%"
56
56
  change_type: "success"
57
57
  icon: "users"
58
58
 
59
59
  - id: "active_projects"
60
- title: "Active Projects"
60
+ title: "Active projects"
61
61
  value: "14"
62
62
  change: "2 pending"
63
63
  change_type: "warning"
@@ -107,7 +107,7 @@ quick_actions:
107
107
  href: "#"
108
108
 
109
109
  - id: "upload_files"
110
- label: "Upload Files"
110
+ label: "Upload files"
111
111
  icon: "upload"
112
112
  style: "outline-adaptive"
113
113
  href: "#"
@@ -119,7 +119,7 @@ quick_actions:
119
119
  href: "#"
120
120
 
121
121
  - id: "view_analytics"
122
- label: "View Analytics"
122
+ label: "View analytics"
123
123
  icon: "chart-bar"
124
124
  style: "outline-adaptive"
125
125
  href: "/analytics"
@@ -132,17 +132,17 @@ quick_actions:
132
132
 
133
133
  progress_items:
134
134
  - id: "project_alpha"
135
- name: "Project Alpha"
135
+ name: "Project alpha"
136
136
  progress: 75
137
137
  color: "primary"
138
138
 
139
139
  - id: "website_redesign"
140
- name: "Website Redesign"
140
+ name: "Website redesign"
141
141
  progress: 50
142
142
  color: "info"
143
143
 
144
144
  - id: "mobile_app"
145
- name: "Mobile App"
145
+ name: "Mobile app"
146
146
  progress: 90
147
147
  color: "success"
148
148
  ---
@@ -40,7 +40,7 @@ story:
40
40
  description: "Reached customers in over 100 countries and opened international offices"
41
41
  - year: "2023"
42
42
  title: "Innovation award"
43
- description: "Recognized as Industry Leader and received multiple awards for innovation"
43
+ description: "Recognized as industry leader and received multiple awards for innovation"
44
44
  - year: "{{ site.uj.date.year }}"
45
45
  title: "The future"
46
46
  description: "Continuing to push boundaries and build the future of business technology"
@@ -31,7 +31,7 @@ alternative:
31
31
  comparison:
32
32
  superheadline:
33
33
  icon: "scale-balanced"
34
- text: "Head to Head"
34
+ text: "Head to head"
35
35
  headline: "See how we"
36
36
  headline_accent: "compare"
37
37
  subheadline: "A side-by-side look at {{ site.brand.name }} vs {{ page.resolved.alternative.competitor.name }}"
@@ -87,7 +87,7 @@ alternative:
87
87
  stats:
88
88
  - number: "50,000+"
89
89
  label: "Active users"
90
- sublabel: "From 120+ Countries"
90
+ sublabel: "From 120+ countries"
91
91
  icon: "users"
92
92
  color: "primary"
93
93
  - number: "4.9"
@@ -97,7 +97,7 @@ alternative:
97
97
  show_stars: true
98
98
  - number: "99.99%"
99
99
  label: "Uptime"
100
- sublabel: "Enterprise-Grade"
100
+ sublabel: "Enterprise-grade"
101
101
  icon: "shield-check"
102
102
  color: "success"
103
103
  - number: "24/7"
@@ -130,7 +130,7 @@ alternative:
130
130
  cta:
131
131
  superheadline:
132
132
  icon: "rocket"
133
- text: "Make the Switch"
133
+ text: "Make the switch"
134
134
  headline: "Ready to leave"
135
135
  headline_accent: "{{ page.resolved.alternative.competitor.name }} behind?"
136
136
  description: "Join thousands who chose {{ site.brand.name }}. Start your free trial today — no credit card required."
@@ -10,7 +10,7 @@ theme:
10
10
  ### PAGE CONFIG ###
11
11
  # Hero Section
12
12
  hero:
13
- tagline: "Compare & Choose"
13
+ tagline: "Compare & choose"
14
14
  headline: "{{ site.brand.name }} vs the"
15
15
  headline_accent: "competition"
16
16
  description: "Not sure if {{ site.brand.name }} is right for you? Compare us head-to-head with the most popular alternatives and see why thousands are making the switch."
@@ -48,10 +48,10 @@ value_props:
48
48
  - title: "Lightning fast"
49
49
  description: "Built on modern infrastructure for blazing performance. No more waiting around."
50
50
  icon: "bolt"
51
- - title: "AI-Powered"
51
+ - title: "AI-powered"
52
52
  description: "Smart automation built into every workflow to save you hours every week."
53
53
  icon: "sparkles"
54
- - title: "24/7 Support"
54
+ - title: "24/7 support"
55
55
  description: "Real humans ready to help whenever you need it. Average response time under 5 minutes."
56
56
  icon: "headset"
57
57
 
@@ -59,7 +59,7 @@ value_props:
59
59
  stats:
60
60
  - number: "50,000+"
61
61
  label: "Active users"
62
- sublabel: "From 120+ Countries"
62
+ sublabel: "From 120+ countries"
63
63
  icon: "users"
64
64
  color: "primary"
65
65
  - number: "4.9"
@@ -69,7 +69,7 @@ stats:
69
69
  show_stars: true
70
70
  - number: "99.99%"
71
71
  label: "Uptime"
72
- sublabel: "Enterprise-Grade"
72
+ sublabel: "Enterprise-grade"
73
73
  icon: "shield-check"
74
74
  color: "success"
75
75
  - number: "24/7"
@@ -28,7 +28,7 @@ downloads:
28
28
  description: "Determining your operating system..."
29
29
  button_text: "Please wait"
30
30
  coming_soon_mobile:
31
- headline: "Coming Soon to Mobile"
31
+ headline: "Coming soon to mobile"
32
32
  description: "Enter your email below and we'll send you the desktop version download link."
33
33
  not_available:
34
34
  headline: "Not available yet"
@@ -614,7 +614,7 @@ cta:
614
614
  <div class="alert alert-success d-flex align-items-center mb-4" role="alert">
615
615
  {% uj_icon "check-circle", "fs-4 me-3" %}
616
616
  <div>
617
- <h6 class="mb-0">Download Started!</h6>
617
+ <h6 class="mb-0">Download started!</h6>
618
618
  <small class="text-muted">Your download should begin automatically.</small>
619
619
  </div>
620
620
  </div>
@@ -78,7 +78,7 @@ layout: themes/[ site.theme.id ]/frontend/core/cover
78
78
 
79
79
  <!-- Support Section -->
80
80
  <div class="text-center mt-5 pt-4 border-top">
81
- <h5 class="mb-3">Need Help?</h5>
81
+ <h5 class="mb-3">Need help?</h5>
82
82
  <p class="text-muted mb-2">Our support team is here to assist you</p>
83
83
  <div class="d-flex flex-column flex-sm-row justify-content-center gap-3">
84
84
  <a href="/contact" class="text-decoration-none link-primary">
@@ -60,7 +60,7 @@ pricing:
60
60
  icon: "download"
61
61
  # Additional features
62
62
  - id: "api_access"
63
- name: "API Access"
63
+ name: "API access"
64
64
  icon: "code"
65
65
  - id: "priority_support"
66
66
  name: "Priority support"
@@ -201,7 +201,7 @@ faqs:
201
201
  15% OFF!
202
202
  </span>
203
203
  <span id="pricing-promo-text" class="fw-semibold">
204
- Flash Sale
204
+ Flash sale
205
205
  </span>
206
206
  <span class="text-white-50">
207
207
  Ending in <span id="pricing-promo-countdown" class="fw-semibold text-white">--</span>
@@ -561,7 +561,7 @@ faqs:
561
561
  <em>Everything in</em>
562
562
  <em>
563
563
  <strong>{{ _prev_plan.name }}</strong>
564
- {%- if has_additional -%}, plus:{%- endif -%}
564
+ {%- if has_additional -%}, and more:{%- endif -%}
565
565
  </em>
566
566
  </div>
567
567
 
@@ -149,7 +149,7 @@ company_values:
149
149
  <div class="container">
150
150
  <div class="text-center mb-5" data-lazy="@class animation-slide-up">
151
151
  <h2 class="h2 mb-3">
152
- Our <span class="">Core values</span>
152
+ Our <span class="">core values</span>
153
153
  </h2>
154
154
  <p class="fs-5 text-muted">The principles that guide everything we do</p>
155
155
  </div>
@@ -181,7 +181,7 @@ company_values:
181
181
  {% uj_icon "briefcase", "text-primary display-4 mb-4" %}
182
182
  </div>
183
183
  <h2 class="h2 mb-4">
184
- Want to Join Our <span class="">Team</span>?
184
+ Want to join our <span class="">team</span>?
185
185
  </h2>
186
186
  <p class="fs-5 text-muted mb-4">
187
187
  We're always looking for talented, passionate people to join our mission. Check out our open positions and become part of something amazing.
@@ -2,6 +2,9 @@
2
2
  ### ALL PAGES ###
3
3
  layout: blueprint/updates/update
4
4
 
5
+ ### PAGE CONFIG ###
6
+ draft: true # Hide from listing and sitemap (only visible in development)
7
+
5
8
  ### UPDATE CONFIG ###
6
9
  update:
7
10
  version: "0.0.1"
@@ -7,7 +7,7 @@ permalink: /test/account/dashboard
7
7
  meta:
8
8
  title: "Dashboard account test page"
9
9
  description: "Test"
10
- breadcrumb: "Dashboard Test page"
10
+ breadcrumb: "Dashboard test page"
11
11
  index: false
12
12
 
13
13
  ### WEB MANAGER CONFIG ###
@@ -7,9 +7,9 @@ permalink: /test/libraries/ads
7
7
  sitemap:
8
8
  include: false
9
9
  meta:
10
- title: "AdSense Test Page"
10
+ title: "AdSense test page"
11
11
  description: "Test page for demonstrating AdSense ad units"
12
- breadcrumb: "AdSense Test"
12
+ breadcrumb: "AdSense test"
13
13
  index: false
14
14
  ---
15
15
 
@@ -17,12 +17,12 @@ meta:
17
17
  <div class="container">
18
18
  <div class="row">
19
19
  <div class="col-lg-8 mx-auto">
20
- <h1 class="h2 mb-4">AdSense Test Page</h1>
20
+ <h1 class="h2 mb-4">AdSense test page</h1>
21
21
  <p class="lead mb-5">This page demonstrates different AdSense ad unit types and lazy loading behavior.</p>
22
22
 
23
23
  <!-- Display Ad -->
24
24
  <section>
25
- <h2 class="h4 mb-3">1. Display Ad</h2>
25
+ <h2 class="h4 mb-3">1. Display ad</h2>
26
26
  {% include /modules/adunits/adsense.html type="display" %}
27
27
  </section>
28
28
 
@@ -30,7 +30,7 @@ meta:
30
30
 
31
31
  <!-- In-Article Ad -->
32
32
  <section>
33
- <h2 class="h4 mb-3">2. In-Article Ad</h2>
33
+ <h2 class="h4 mb-3">2. In-article ad</h2>
34
34
  {% include /modules/adunits/adsense.html type="in-article" %}
35
35
  </section>
36
36
 
@@ -38,7 +38,7 @@ meta:
38
38
 
39
39
  <!-- In-Feed Ad (Image Above) -->
40
40
  <section>
41
- <h2 class="h4 mb-3">3. In-Feed Ad (Image Above)</h2>
41
+ <h2 class="h4 mb-3">3. In-feed ad (image above)</h2>
42
42
  {% include /modules/adunits/adsense.html type="in-feed" layout="image-above" %}
43
43
  </section>
44
44
 
@@ -46,7 +46,7 @@ meta:
46
46
 
47
47
  <!-- In-Feed Ad (Image Side) -->
48
48
  <section>
49
- <h2 class="h4 mb-3">4. In-Feed Ad (Image Side)</h2>
49
+ <h2 class="h4 mb-3">4. In-feed ad (image side)</h2>
50
50
  {% include /modules/adunits/adsense.html type="in-feed" layout="image-side" %}
51
51
  </section>
52
52
 
@@ -54,7 +54,7 @@ meta:
54
54
 
55
55
  <!-- Multiplex Ad -->
56
56
  <section>
57
- <h2 class="h4 mb-3">5. Multiplex Ad</h2>
57
+ <h2 class="h4 mb-3">5. Multiplex ad</h2>
58
58
  {% include /modules/adunits/adsense.html type="multiplex" %}
59
59
  </section>
60
60
 
@@ -63,7 +63,7 @@ meta:
63
63
 
64
64
  <!-- Lazy loaded display ad -->
65
65
  <section>
66
- <h2 class="h4 mb-3">6. Lazy Loaded Display Ad (Below the Fold)</h2>
66
+ <h2 class="h4 mb-3">6. Lazy loaded display ad (below the fold)</h2>
67
67
  <p class="text-muted mb-3">This ad should only load when scrolled into view.</p>
68
68
  {% include /modules/adunits/adsense.html type="display" %}
69
69
  </section>
@@ -24,10 +24,10 @@ meta:
24
24
  <h3 class="mb-3">Rainbow gradient effects</h3>
25
25
  <h1 class="text-gradient-rainbow">Static rainbow text</h1>
26
26
  <h2 class="text-gradient-rainbow gradient-animated">Animated rainbow text</h2>
27
- <p class="fs-4">Button Examples:</p>
27
+ <p class="fs-4">Button examples:</p>
28
28
  <button class="btn btn-gradient-rainbow me-2">Static rainbow button</button>
29
29
  <button class="btn btn-gradient-rainbow gradient-animated">Animated rainbow button</button>
30
- <p class="fs-4 mt-3">Background Example:</p>
30
+ <p class="fs-4 mt-3">Background example:</p>
31
31
  <div class="p-3 bg-gradient-rainbow gradient-animated text-light rounded">Animated rainbow background</div>
32
32
 
33
33
  <h3 class="mt-4 mb-3">Core animations</h3>
@@ -151,7 +151,7 @@ meta:
151
151
 
152
152
  <!-- Classy Theme Specific -->
153
153
  <section class="p-4 text-light rounded">
154
- <h2 class="border-bottom border-light pb-2 mb-4">✨ Classy Theme Components</h2>
154
+ <h2 class="border-bottom border-light pb-2 mb-4">✨ Classy theme components</h2>
155
155
 
156
156
  <h3 class="mb-3">Glassy effects</h3>
157
157
  <div class="p-5 bg-gradient-rainbow gradient-animated rounded mb-4">
@@ -312,7 +312,7 @@ meta:
312
312
  <p class="fs-5">Font size 5</p>
313
313
  <p class="fs-6">Font size 6</p>
314
314
 
315
- <h3 class="mt-4 mb-3">Font Weight & Style</h3>
315
+ <h3 class="mt-4 mb-3">Font weight & style</h3>
316
316
  <p class="fw-bold">Bold text</p>
317
317
  <p class="fw-bolder">Bolder weight text</p>
318
318
  <p class="fw-semibold">Semibold weight text</p>
@@ -604,7 +604,7 @@ meta:
604
604
  <input class="form-control" type="file" id="formFile">
605
605
  </div>
606
606
 
607
- <h3 class="mt-4 mb-3">Form & Button Size Comparison</h3>
607
+ <h3 class="mt-4 mb-3">Form & button size comparison</h3>
608
608
  <div class="card">
609
609
  <div class="card-body">
610
610
  <div class="row mb-3">
@@ -853,7 +853,7 @@ meta:
853
853
  </ul>
854
854
  </div>
855
855
  <div class="col-md-6">
856
- <h3 class="mb-3">List Group with Badges</h3>
856
+ <h3 class="mb-3">List group with badges</h3>
857
857
  <ul class="list-group">
858
858
  <li class="list-group-item d-flex justify-content-between align-items-center">
859
859
  A list item
@@ -25,7 +25,7 @@ sitemap:
25
25
  </style>
26
26
  </head>
27
27
  <body>
28
- <h1>Firestore Version + Transport Test</h1>
28
+ <h1>Firestore version + transport test</h1>
29
29
  <div id="ua"></div>
30
30
  <div id="status">Loading...</div>
31
31
  <div id="sw-status" style="color: #f80; margin-bottom: 12px;"></div>
@@ -15,7 +15,7 @@ meta:
15
15
 
16
16
  <section>
17
17
  <div class="container">
18
- <h1 class="mb-4">FormManager Test Page</h1>
18
+ <h1 class="mb-4">FormManager test page</h1>
19
19
  <p class="text-muted mb-5">Testing different configurations of the FormManager library.</p>
20
20
 
21
21
  <div class="row g-4">
@@ -204,7 +204,7 @@ meta:
204
204
  <div class="row">
205
205
  <!-- Global settings (no data-input-group = always included) -->
206
206
  <div class="col-md-4">
207
- <h6 class="text-muted mb-3">Global Settings <span class="badge bg-secondary">Always included</span></h6>
207
+ <h6 class="text-muted mb-3">Global settings <span class="badge bg-secondary">Always included</span></h6>
208
208
  <div class="mb-3">
209
209
  <label for="groups-name" class="form-label">settings.name</label>
210
210
  <input type="text" class="form-control" id="groups-name" name="settings.name" value="My project" disabled>
@@ -33,7 +33,7 @@ meta:
33
33
 
34
34
  <!-- First visible image (should load immediately) -->
35
35
  <section>
36
- <h2 class="h3 mb-3">1. Above the Fold Image (Lazy Loading Starts Immediately)</h2>
36
+ <h2 class="h3 mb-3">1. Above the fold image (lazy loading starts immediately)</h2>
37
37
  <p>This image is visible on page load, so it should start loading immediately:</p>
38
38
  <img data-lazy="@src https://placehold.co/800x400?text=Immediate+Load"
39
39
  class="card-img-top lazy"
@@ -50,7 +50,7 @@ meta:
50
50
 
51
51
  <!-- Lazy loaded images with data-src -->
52
52
  <section>
53
- <h2 class="h3 mb-3">2. Standard Lazy Loading (data-src)</h2>
53
+ <h2 class="h3 mb-3">2. Standard lazy loading (data-src)</h2>
54
54
  <p>These images use <code>data-src</code> attribute and will load when scrolled into view:</p>
55
55
 
56
56
  <div class="row g-4 mb-4">
@@ -153,7 +153,7 @@ meta:
153
153
 
154
154
  <!-- Lazy Classes -->
155
155
  <section>
156
- <h2 class="h3 mb-3">6. Lazy Loaded Classes</h2>
156
+ <h2 class="h3 mb-3">6. Lazy loaded classes</h2>
157
157
  <p>These elements get classes added when they come into view:</p>
158
158
 
159
159
  <div class="row g-4">
@@ -194,7 +194,7 @@ meta:
194
194
 
195
195
  <!-- Iframes -->
196
196
  <section>
197
- <h2 class="h3 mb-3">7. Lazy Loaded Iframes</h2>
197
+ <h2 class="h3 mb-3">7. Lazy loaded iframes</h2>
198
198
  <p>YouTube videos and other iframes that load on scroll:</p>
199
199
 
200
200
  <div class="ratio ratio-16x9 mb-4">
@@ -242,7 +242,7 @@ meta:
242
242
 
243
243
  <!-- Video -->
244
244
  <section>
245
- <h2 class="h3 mb-3">9. Lazy Loaded Video</h2>
245
+ <h2 class="h3 mb-3">9. Lazy loaded video</h2>
246
246
  <p>HTML5 video that loads when scrolled into view:</p>
247
247
 
248
248
  <video data-lazy="@src https://www.w3schools.com/html/mov_bbb.mp4"
@@ -257,7 +257,7 @@ meta:
257
257
 
258
258
  <!-- Slow Loading Test with Picsum -->
259
259
  <section>
260
- <h2 class="h3 mb-3">10. Slow Loading Test (Loading Animation Demo)</h2>
260
+ <h2 class="h3 mb-3">10. Slow loading test (loading animation demo)</h2>
261
261
  <p>These images from picsum.photos load slowly, so you can see the loading animation:</p>
262
262
 
263
263
  <div class="row g-4">
@@ -325,7 +325,7 @@ meta:
325
325
 
326
326
  <!-- Error handling test -->
327
327
  <section>
328
- <h2 class="h3 mb-3">12. Error Handling Test</h2>
328
+ <h2 class="h3 mb-3">12. Error handling test</h2>
329
329
  <p>These images have invalid URLs to test error handling:</p>
330
330
 
331
331
  <div class="row g-4">
@@ -373,7 +373,7 @@ meta:
373
373
 
374
374
  <!-- Dynamic content test -->
375
375
  <section>
376
- <h2 class="h3 mb-3">14. Dynamic Content Test</h2>
376
+ <h2 class="h3 mb-3">14. Dynamic content test</h2>
377
377
  <p>Click the button below to dynamically add new lazy-loaded images to test MutationObserver:</p>
378
378
 
379
379
  <button id="add-dynamic-images" class="btn btn-primary mb-4">Add dynamic images</button>
@@ -54,7 +54,7 @@ web_manager:
54
54
  {%- endif -%}
55
55
 
56
56
  {% assign url_parts = page.url | split: '/' %}
57
- {% assign section_name = "Root Pages" %}
57
+ {% assign section_name = "Root pages" %}
58
58
 
59
59
  {% if url_parts.size > 2 %}
60
60
  {% assign section_name = url_parts[1] | replace: '-', ' ' | replace: '_', ' ' | capitalize %}
@@ -104,7 +104,7 @@ web_manager:
104
104
  {%- endif -%}
105
105
 
106
106
  {% assign url_parts = page.url | split: '/' %}
107
- {% assign page_section = "Root Pages" %}
107
+ {% assign page_section = "Root pages" %}
108
108
 
109
109
  {% if url_parts.size > 2 %}
110
110
  {% assign page_section = url_parts[1] | replace: '-', ' ' | replace: '_', ' ' | capitalize %}
@@ -6,6 +6,7 @@ const glob = require('glob').globSync;
6
6
  const responsive = require('gulp-responsive-modern');
7
7
  const sharp = require('sharp');
8
8
  const path = require('path');
9
+ const { Transform } = require('stream');
9
10
  const jetpack = require('fs-jetpack');
10
11
  const GitHubCache = require('./utils/github-cache');
11
12
 
@@ -188,6 +189,7 @@ async function imagemin(complete) {
188
189
  // above (so `npm start` never blocks on this), letting BrowserSync reload as images land later.
189
190
  await new Promise((resolve, reject) => {
190
191
  src(filesToProcess, { base: 'src/assets/images' })
192
+ .pipe(lowercaseExtTransform())
191
193
  .pipe(responsive({
192
194
  [`**/${RESPONSIVE_GLOB}`]: responsiveConfigs
193
195
  }, {
@@ -358,6 +360,24 @@ async function rewriteOversizedSources(files) {
358
360
  }
359
361
  }
360
362
 
363
+ // Lowercase the extension on each Vinyl file's path before piping into gulp-responsive-modern.
364
+ // gulp-responsive-modern's lib/format.js uses a case-sensitive switch on path.extname() and returns
365
+ // the string 'unsupported' for anything else, which then crashes sharp.toFormat(). Files saved
366
+ // straight off a camera (IMG_3119.JPG) hit this. Rewriting the Vinyl path in-stream keeps the
367
+ // on-disk file untouched while letting the plugin recognize the format.
368
+ function lowercaseExtTransform() {
369
+ return new Transform({
370
+ objectMode: true,
371
+ transform(file, _enc, cb) {
372
+ const ext = path.extname(file.path);
373
+ if (ext && ext !== ext.toLowerCase()) {
374
+ file.path = file.path.slice(0, -ext.length) + ext.toLowerCase();
375
+ }
376
+ cb(null, file);
377
+ },
378
+ });
379
+ }
380
+
361
381
  // Build responsive configurations from PICTURE_SIZES
362
382
  function getResponsiveConfigs() {
363
383
  const configs = [];
@@ -552,21 +572,26 @@ function logImageStatistics(stats, startTime, endTime) {
552
572
  // Size reduction stats
553
573
  if (stats.sizeBefore > 0 && stats.sizeAfter > 0) {
554
574
  const savedPercent = ((stats.savedBytes / stats.sizeBefore) * 100).toFixed(1);
575
+ const label = stats.savedBytes < 0 ? 'Total added' : 'Total saved';
555
576
  logger.log('\n💾 Size Reduction:');
556
577
  logger.log(` Original size: ${formatBytes(stats.sizeBefore)}`);
557
578
  logger.log(` Optimized size: ${formatBytes(stats.sizeAfter)}`);
558
- logger.log(` Total saved: ${formatBytes(stats.savedBytes)} (${savedPercent}%)`);
579
+ logger.log(` ${label}: ${formatBytes(Math.abs(stats.savedBytes))} (${savedPercent}%)`);
559
580
  }
560
581
 
561
582
  logger.log('═══════════════════════════════════════\n');
562
583
  }
563
584
 
564
- // Helper to format bytes
585
+ // Helper to format bytes. Handles negative inputs — when responsive variants (8 per source)
586
+ // sum to more than the cached original, savedBytes goes negative; without the absolute-value
587
+ // guard, Math.log(negative) is NaN and the suffix index becomes NaN -> "NaN undefined".
565
588
  function formatBytes(bytes, decimals = 2) {
566
589
  if (bytes === 0) return '0 Bytes';
590
+ const sign = bytes < 0 ? '-' : '';
591
+ const abs = Math.abs(bytes);
567
592
  const k = 1024;
568
593
  const dm = decimals < 0 ? 0 : decimals;
569
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
570
- const i = Math.floor(Math.log(bytes) / Math.log(k));
571
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
594
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
595
+ const i = Math.min(Math.floor(Math.log(abs) / Math.log(k)), sizes.length - 1);
596
+ return sign + parseFloat((abs / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
572
597
  }
@@ -9,18 +9,24 @@
9
9
  // Skipped entirely when Manager.isServer() returns true — CI/cloud runs don't need a logs/
10
10
  // directory left behind in the workspace.
11
11
  //
12
- // Truncates fresh on each call (flags: 'w'), so a new `npm start` doesn't accumulate stale
12
+ // Truncates fresh on each call (O_TRUNC), so a new `npm start` doesn't accumulate stale
13
13
  // lines from the previous run.
14
14
  //
15
- // Idempotent: calling twice with the same path just returns the existing stream.
15
+ // Idempotent: calling twice with the same path just returns the existing fd.
16
+ //
17
+ // Uses synchronous fs.writeSync(fd, ...) rather than createWriteStream(). Reason: gulp tasks
18
+ // crash via thrown errors that propagate to process.exit, and createWriteStream's internal
19
+ // buffer was being dropped before the kernel could flush it — so the very lines describing
20
+ // the crash (the most important ones) never made it to disk. Synchronous writes incur a
21
+ // per-line syscall but guarantee the tail of the log survives an immediate exit.
16
22
 
17
23
  const fs = require('fs');
18
24
  const path = require('path');
19
25
 
20
26
  const ANSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
21
27
 
22
- let activeStream = null;
23
- let activePath = null;
28
+ let activeFd = null;
29
+ let activePath = null;
24
30
  let originalStdoutWrite = null;
25
31
  let originalStderrWrite = null;
26
32
 
@@ -33,38 +39,40 @@ function attachLogFile(name) {
33
39
 
34
40
  const abs = path.resolve(process.cwd(), 'logs', `${name}.log`);
35
41
 
36
- if (activeStream && activePath === abs) return activeStream;
37
- if (activeStream) detach();
42
+ if (activeFd !== null && activePath === abs) return activeFd;
43
+ if (activeFd !== null) detach();
38
44
 
39
45
  fs.mkdirSync(path.dirname(abs), { recursive: true });
40
- const stream = fs.createWriteStream(abs, { flags: 'w' });
46
+ const fd = fs.openSync(abs, 'w');
41
47
 
42
- stream.write(`# ujm log — ${new Date().toISOString()} — pid=${process.pid}\n`);
48
+ fs.writeSync(fd, `# ujm log — ${new Date().toISOString()} — pid=${process.pid}\n`);
43
49
 
44
50
  originalStdoutWrite = process.stdout.write.bind(process.stdout);
45
51
  originalStderrWrite = process.stderr.write.bind(process.stderr);
46
52
 
47
53
  process.stdout.write = function (chunk, ...rest) {
48
- try { stream.write(stripAnsi(String(chunk))); } catch (e) { /* ignore */ }
54
+ try { fs.writeSync(fd, stripAnsi(String(chunk))); } catch (e) { /* ignore */ }
49
55
  return originalStdoutWrite(chunk, ...rest);
50
56
  };
51
57
  process.stderr.write = function (chunk, ...rest) {
52
- try { stream.write(stripAnsi(String(chunk))); } catch (e) { /* ignore */ }
58
+ try { fs.writeSync(fd, stripAnsi(String(chunk))); } catch (e) { /* ignore */ }
53
59
  return originalStderrWrite(chunk, ...rest);
54
60
  };
55
61
 
56
- activeStream = stream;
57
- activePath = abs;
62
+ activeFd = fd;
63
+ activePath = abs;
58
64
 
59
- return stream;
65
+ return fd;
60
66
  }
61
67
 
62
68
  function detach() {
63
69
  if (originalStdoutWrite) process.stdout.write = originalStdoutWrite;
64
70
  if (originalStderrWrite) process.stderr.write = originalStderrWrite;
65
- if (activeStream) activeStream.end();
66
- activeStream = null;
67
- activePath = null;
71
+ if (activeFd !== null) {
72
+ try { fs.closeSync(activeFd); } catch (e) { /* ignore */ }
73
+ }
74
+ activeFd = null;
75
+ activePath = null;
68
76
  originalStdoutWrite = null;
69
77
  originalStderrWrite = null;
70
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {