ultimate-jekyll-manager 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CLAUDE-ATTRIBUTION.md +215 -0
  3. package/CLAUDE.md +7 -6
  4. package/README.md +1 -0
  5. package/dist/assets/css/pages/test/libraries/layers/index.scss +28 -0
  6. package/dist/assets/js/core/auth.js +24 -39
  7. package/dist/assets/js/modules/redirect.js +5 -4
  8. package/dist/assets/js/pages/download/index.js +1 -1
  9. package/dist/assets/js/pages/feedback/index.js +7 -1
  10. package/dist/assets/js/pages/test/libraries/layers/index.js +11 -0
  11. package/dist/assets/themes/_template/README.md +50 -0
  12. package/dist/assets/themes/_template/_config.scss +60 -0
  13. package/dist/assets/themes/_template/_theme.js +13 -4
  14. package/dist/assets/themes/_template/_theme.scss +16 -4
  15. package/dist/assets/themes/_template/css/base/_root.scss +19 -0
  16. package/dist/assets/themes/_template/css/components/_components.scss +23 -0
  17. package/dist/assets/themes/classy/README.md +18 -6
  18. package/dist/assets/themes/neobrutalism/README.md +98 -0
  19. package/dist/assets/themes/neobrutalism/_config.scss +139 -0
  20. package/dist/assets/themes/neobrutalism/_theme.js +27 -0
  21. package/dist/assets/themes/neobrutalism/_theme.scss +33 -0
  22. package/dist/assets/themes/neobrutalism/css/base/_mixins.scss +46 -0
  23. package/dist/assets/themes/neobrutalism/css/base/_root.scss +80 -0
  24. package/dist/assets/themes/neobrutalism/css/base/_typography.scss +77 -0
  25. package/dist/assets/themes/neobrutalism/css/base/_utilities.scss +25 -0
  26. package/dist/assets/themes/neobrutalism/css/components/_buttons.scss +148 -0
  27. package/dist/assets/themes/neobrutalism/css/components/_cards.scss +69 -0
  28. package/dist/assets/themes/neobrutalism/css/components/_forms.scss +88 -0
  29. package/dist/assets/themes/neobrutalism/css/components/_infinite-scroll.scss +94 -0
  30. package/dist/assets/themes/neobrutalism/css/layout/_general.scss +200 -0
  31. package/dist/assets/themes/neobrutalism/css/layout/_navigation.scss +153 -0
  32. package/dist/assets/themes/neobrutalism/js/initialize-tooltips.js +20 -0
  33. package/dist/assets/themes/neobrutalism/js/navbar-scroll.js +29 -0
  34. package/dist/assets/themes/neobrutalism/pages/index.scss +227 -0
  35. package/dist/assets/themes/neobrutalism/pages/pricing/index.scss +267 -0
  36. package/dist/assets/themes/neobrutalism/pages/test/libraries/layers/index.js +9 -0
  37. package/dist/assets/themes/neobrutalism/pages/test/libraries/layers/index.scss +7 -0
  38. package/dist/build.js +2 -5
  39. package/dist/commands/install.js +1 -1
  40. package/dist/commands/setup.js +41 -0
  41. package/dist/defaults/CLAUDE.md +5 -1
  42. package/dist/defaults/dist/_alternatives/example-competitor.md +6 -6
  43. package/dist/defaults/dist/_includes/admin/sections/sidebar.json +2 -2
  44. package/dist/defaults/dist/_includes/core/head.html +17 -0
  45. package/dist/defaults/dist/_includes/themes/classy/backend/sections/topbar.html +1 -1
  46. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/footer.html +9 -6
  47. package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +13 -13
  48. package/dist/defaults/dist/_layouts/blueprint/admin/firebase/index.html +1 -1
  49. package/dist/defaults/dist/_layouts/blueprint/admin/users/index.html +1 -1
  50. package/dist/defaults/dist/_layouts/blueprint/admin/users/new.html +5 -5
  51. package/dist/defaults/dist/_layouts/blueprint/auth/oauth2.html +1 -1
  52. package/dist/defaults/dist/_layouts/themes/classy/backend/pages/dashboard/index.html +12 -12
  53. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/about.html +1 -1
  54. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/alternatives/alternative.html +4 -4
  55. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/alternatives/index.html +5 -5
  56. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/download.html +4 -2
  57. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/feedback.html +7 -3
  58. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/payment/confirmation.html +1 -1
  59. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/pricing.html +3 -3
  60. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/team/index.html +2 -2
  61. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/core/base.html +31 -0
  62. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/pages/index.html +345 -0
  63. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/pages/pricing.html +483 -0
  64. package/dist/defaults/dist/_updates/v0.0.1.md +3 -0
  65. package/dist/defaults/dist/pages/test/account/dashboard.html +1 -1
  66. package/dist/defaults/dist/pages/test/libraries/ads.html +9 -9
  67. package/dist/defaults/dist/pages/test/libraries/bootstrap.html +6 -6
  68. package/dist/defaults/dist/pages/test/libraries/firestore.html +1 -1
  69. package/dist/defaults/dist/pages/test/libraries/form-manager.html +2 -2
  70. package/dist/defaults/dist/pages/test/libraries/layers.html +57 -0
  71. package/dist/defaults/dist/pages/test/libraries/lazy-loading.html +8 -8
  72. package/dist/defaults/dist/sitemap.html +2 -2
  73. package/dist/defaults/src/_config.yml +2 -0
  74. package/dist/defaults/test/_init.js +10 -0
  75. package/dist/gulp/tasks/defaults.js +8 -0
  76. package/dist/gulp/tasks/imagemin.js +30 -5
  77. package/dist/gulp/tasks/sass.js +43 -2
  78. package/dist/gulp/tasks/translation.js +11 -0
  79. package/dist/gulp/tasks/utils/manage-test-layers.js +97 -0
  80. package/dist/index.js +30 -4
  81. package/dist/test/runner.js +62 -0
  82. package/dist/test/suites/build/manager.test.js +11 -4
  83. package/dist/test/suites/build/mode-helpers.test.js +54 -2
  84. package/dist/utils/attach-log-file.js +24 -16
  85. package/dist/utils/mode-helpers.js +65 -40
  86. package/docs/assets.md +6 -1
  87. package/docs/environment-detection.md +85 -0
  88. package/docs/test-framework.md +48 -3
  89. package/docs/themes.md +451 -0
  90. package/package.json +2 -1
  91. package/docs/cross-context-helpers.md +0 -75
package/CHANGELOG.md CHANGED
@@ -14,6 +14,35 @@ 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.5.0] - 2026-06-02
19
+
20
+ ### Added
21
+
22
+ - **Neobrutalism theme** — a complete second shipped theme (alongside `classy`), not a recolor: hard ink borders, offset "press" shadows, chunky display type, flat color-blocks, square controls. Custom homepage + pricing layouts. It restyles **standard Bootstrap classes** (`.btn`, `.card`, `.text-bg-*`) and a **universal semantic layout vocabulary** (`.section-hero`, `.showcase-row`, `.stat-block`, `.pricing-plan`, `.cta-panel`, …) — **no theme-prefixed classes** — so the same markup is swappable across themes (change `theme.id`, done). [src/assets/themes/neobrutalism/](src/assets/themes/neobrutalism/)
23
+ - **Theme system: three-layer page-asset cascade (Global → Theme → Consumer) for BOTH CSS and JS.**
24
+ - Theme page CSS: `themes/<id>/pages/<path>/index.scss` compiles to a theme-suffixed bundle (`index.<themeId>.bundle.css`) via [src/gulp/tasks/sass.js](src/gulp/tasks/sass.js), linked by [head.html](src/defaults/dist/_includes/core/head.html) with `{% iffile %}`. Missing = nothing loads (component styles handle it), no fallback needed.
25
+ - Theme page JS: `__theme__/pages/<path>/index.js` loaded as the `#theme` layer in [src/index.js](src/index.js), executed `main → theme → project` (a `webpackInclude: /\.js$/` guard stops `.scss` from being pulled into the JS import context).
26
+ - Per-page HTML layout overrides under `_layouts/themes/<id>/` reuse classy's `page.resolved.*` frontmatter data contract and fall back to classy when a layout is absent.
27
+ - **Theme-authoring template** at [src/assets/themes/_template/](src/assets/themes/_template/) + a full **[docs/themes.md](docs/themes.md)** reference for building a theme inside UJM or in a consumer project (selection/resolution, classy fallback, page CSS/JS layers, the no-prefix vocabulary, adaptive-button + interactive-accent gotchas).
28
+ - **Single interactive-accent token** (`--nb-accent-interactive` = `var(--bs-primary)`) drives all hover/active states (blue), distinct from the yellow signature accent reserved for static blocks. Adaptive buttons (`btn-adaptive`/`-inverse`) get explicit theme overrides so they render solid and press like every other button.
29
+ - **Asset-layer test harness** — `UJ_TEST_LAYERS` flag + [manage-test-layers.js](src/gulp/tasks/utils/manage-test-layers.js) generate real consumer-side test files; the `/test/libraries/layers` page renders the Global/Theme/Consumer cascade as red/green dots to prove all three layers load in order. `npm run start:test-layers` enables it.
30
+
31
+ ---
32
+ ## [1.4.3] - 2026-05-28
33
+
34
+ ### Fixed
35
+
36
+ - **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).
37
+ - **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.
38
+
39
+ ### Changed
40
+
41
+ - **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.
42
+ - **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)
43
+ - **Sentence-case copy normalization** across default pages (pricing, alternatives, admin/test pages, sitemap section labels): "API access", "Flash sale", "Root pages", "…and more:" etc.
44
+ - **Updates feed:** the `v0.0.1` sample entry is marked `draft: true` so it's hidden from the listing and sitemap (dev-only).
45
+
17
46
  ---
18
47
  ## [1.4.2] - 2026-05-27
19
48
 
@@ -0,0 +1,215 @@
1
+ # Attribution Tracking System — Design & Implementation Plan
2
+
3
+ > Working design doc for the unified attribution system. Captures UTM/ITM tags, affiliate codes, and ad click IDs with a per-type "first-touch with 30-day refresh" model, plus auto-sync to the server when authenticated.
4
+
5
+ ## Goals
6
+
7
+ An all-in-one system to track how users arrive at the site so we can:
8
+ 1. Do UTM tracking (external marketing attribution)
9
+ 2. Do ITM tracking (internal mechanisms — exit popups, extension prompts, etc.)
10
+ 3. Capture ad click IDs for server-side conversion events (GA, Meta CAPI, TikTok Events API)
11
+ 4. Properly attribute recurring purchases that happen offline (subscription renewals)
12
+
13
+ ---
14
+
15
+ ## Design Decisions (settled)
16
+
17
+ - **Attribution Model**: First-touch with 30-day refresh, **per-type basis**
18
+ - Each category (UTM, ITM, affiliate, adClicks) has independent freshness
19
+ - If no data exists for a type → save it
20
+ - If data exists AND is < 30 days old → preserve (first-touch protection)
21
+ - If data exists AND is >= 30 days old → overwrite (new journey)
22
+ - Categories are independent: can overwrite stale UTM while keeping fresh affiliate
23
+ - **Freshness TTL**: 30 days globally for all types
24
+ - **Ad click IDs**: lumped into a single `adClicks` object (user only arrives from one ad at a time; they share one timestamp)
25
+ - **Server Sync**: on attribution change (if signed in) + signup + checkout
26
+ - `_meta.needsSync` flag for deferred sync when user later signs in (mirrors `notifications.syncSubscription()` pattern in web-manager)
27
+ - **Storage**: User doc (rolling latest) + Subscription doc (frozen snapshot at purchase)
28
+ - **`getFresh()` returns ALL fresh data** — server decides what's recent enough to attribute. Subscription gets a frozen snapshot used for ALL recurring conversions, even years later.
29
+ - **No backwards compatibility** — replace the old shape cleanly, remove the ad-hoc 30-day check.
30
+
31
+ ---
32
+
33
+ ## Storage Structure
34
+
35
+ ```javascript
36
+ // localStorage key: "attribution"
37
+ {
38
+ utm: {
39
+ tags: { utm_source, utm_medium, utm_campaign, utm_term, utm_content },
40
+ timestamp: "ISO string",
41
+ url: "full landing URL",
42
+ page: "/path"
43
+ },
44
+ itm: {
45
+ tags: { itm_source, itm_medium, itm_campaign, itm_content },
46
+ timestamp: "ISO string",
47
+ url: "full URL",
48
+ page: "/path"
49
+ },
50
+ affiliate: {
51
+ code: "partner123",
52
+ timestamp: "ISO string",
53
+ url: "full URL",
54
+ page: "/path"
55
+ },
56
+ adClicks: {
57
+ // All ad click IDs lumped together (user only arrives from one ad at a time)
58
+ fbclid: "from URL param",
59
+ fbc: "from _fbc cookie",
60
+ gclid: "from URL param",
61
+ ttclid: "from URL param",
62
+ timestamp: "ISO string",
63
+ url: "full URL",
64
+ page: "/path"
65
+ },
66
+ _meta: {
67
+ needsSync: false, // true if attribution changed while signed out
68
+ lastSynced: "ISO string" // last successful server sync
69
+ }
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Data Captured
76
+
77
+ | Category | Source | Params |
78
+ |---|---|---|
79
+ | **UTM** | URL query | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` |
80
+ | **ITM** | URL query (internal links, e.g. exit popup) | `itm_source`, `itm_medium`, `itm_campaign`, `itm_content` |
81
+ | **Affiliate** | URL query | `aff` or `ref` |
82
+ | **Ad Clicks** | URL query + cookie | `fbclid`, `fbc` (`_fbc` cookie), `gclid`, `ttclid` |
83
+
84
+ ITM tags are emitted by internal mechanisms. Example: `exit-popup.js` already sets `itm_source=website&itm_medium=modal&itm_campaign=exit-popup&itm_content=<pathname>` on its CTA link. Those land as a query string on the next page and get captured like UTM.
85
+
86
+ ---
87
+
88
+ ## Files to Modify
89
+
90
+ ### 1. `src/assets/js/core/query-strings.js` — Core refactor
91
+
92
+ Currently captures UTM + affiliate only (no freshness, no ITM, no ad clicks, no sync). Becomes the single owner of attribution capture + sync.
93
+
94
+ Add:
95
+ 1. Constants: `ATTRIBUTION_KEY = 'attribution'`, `FRESHNESS_DAYS = 30`, `FRESHNESS_MS = 30 * 24 * 60 * 60 * 1000`
96
+ 2. `shouldPreserveAttribution(existingData)` — returns `true` if existing data is < 30 days old (preserve), `false` if missing or stale (allow overwrite)
97
+ 3. `processUTMParams()` / `processITMParams()` / `processAffiliateParams()` / `processAdClickParams()` — each:
98
+ - Reads its params, quits if none present
99
+ - Applies `shouldPreserveAttribution()` first-touch check
100
+ - Writes its category + metadata (timestamp/url/page)
101
+ - Returns `true` if it changed anything
102
+ 4. `getFbcCookie()` — parse `document.cookie` for `_fbc`
103
+ 5. `getAttribution()` — raw stored object (all, regardless of age)
104
+ 6. `getFreshAttribution()` — only categories < 30 days old, excludes `_meta`. This is the canonical thing sent to the server.
105
+ 7. `syncAttribution()`:
106
+ - If user signed in → POST fresh attribution to backend-manager, set `_meta.lastSynced`, clear `_meta.needsSync`
107
+ - If not signed in → set `_meta.needsSync = true`
108
+ 8. Auth-state listener for deferred sync: on sign-in, if `_meta.needsSync` is true, call `syncAttribution()`
109
+ 9. Public API on `webManager._ujLibrary.attribution`:
110
+ ```javascript
111
+ {
112
+ get: () => getAttribution(), // raw, all ages — debugging
113
+ getFresh: () => getFreshAttribution(), // < 30 days — USE THIS
114
+ sync: () => syncAttribution(), // manual sync (usually automatic)
115
+ clear: () => clearAttribution() // wipe all attribution
116
+ }
117
+ ```
118
+
119
+ `processQueryStrings()` orchestrates: run each processor, OR their return values; if anything changed, save to storage and call `syncAttribution()`.
120
+
121
+ ### 2. `src/assets/js/core/auth.js` — Use fresh attribution in signup
122
+
123
+ `sendUserSignupMetadata(account)` currently reads raw `webManager.storage().get('attribution', {})` (line ~283). Change to:
124
+ ```javascript
125
+ const attribution = webManager.uj().attribution.getFresh();
126
+ ```
127
+ Everything else (the `flags.signupProcessed` SSOT gate, `/backend-manager/user/signup` endpoint, consent payload) stays as-is. Sync-on-change in query-strings.js is complementary — signup still sends attribution + context + consent in one shot.
128
+
129
+ ### 3. `src/assets/js/pages/payment/checkout/modules/api.js` — Use fresh attribution
130
+
131
+ Line ~60 currently sends raw storage:
132
+ ```javascript
133
+ attribution: webManager.storage().get('attribution', {}),
134
+ ```
135
+ Change to:
136
+ ```javascript
137
+ attribution: webManager.uj().attribution.getFresh(),
138
+ ```
139
+ (The old ad-hoc 30-day UTM check from the former `session.js` is already gone — this just swaps raw → fresh so stale categories are dropped before the server snapshots them onto the subscription.)
140
+
141
+ ---
142
+
143
+ ## Sync Flow
144
+
145
+ ```
146
+ User lands with ?utm_source=google (or itm_/fbclid/etc.)
147
+
148
+
149
+ processQueryStrings()
150
+ - per-type freshness check (first-touch w/ 30-day refresh)
151
+ - update localStorage
152
+ - did anything change?
153
+ │ yes
154
+
155
+ Is user signed in?
156
+ ├── YES → syncAttribution() now → set _meta.lastSynced
157
+ └── NO → set _meta.needsSync = true
158
+
159
+ ▼ (later) user signs in
160
+ auth-state listener
161
+ - _meta.needsSync? → syncAttribution() → clear flag
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Server-Side Data Flow (informational — backend out of scope here)
167
+
168
+ ```
169
+ USER DOC /users/{uid}
170
+ attribution: { utm, itm, affiliate, adClicks } ← rolling update on each sync
171
+
172
+ │ on purchase, snapshot →
173
+
174
+ SUBSCRIPTION DOC /users/{uid}/subscriptions/{subId}
175
+ attribution: { ... } ← FROZEN at purchase time
176
+ → used for ALL recurring billing conversion events (even years later)
177
+ ```
178
+
179
+ When a renewal fires months later, the server reads the subscription's frozen `attribution.adClicks.fbclid` (or gclid/ttclid) to send a server-side conversion event, attributing the recurring revenue to the original campaign.
180
+
181
+ ---
182
+
183
+ ## Public API Reference
184
+
185
+ ```javascript
186
+ webManager.uj().attribution.get() // raw stored attribution (all ages)
187
+ webManager.uj().attribution.getFresh() // only < 30 days — send this to server
188
+ webManager.uj().attribution.sync() // manual sync (normally automatic)
189
+ webManager.uj().attribution.clear() // wipe all attribution
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Testing Checklist
195
+
196
+ - [ ] UTM params captured on landing
197
+ - [ ] ITM params captured (via exit-popup CTA link)
198
+ - [ ] Affiliate code captured (`aff` / `ref`)
199
+ - [ ] Ad click IDs captured (`fbclid`, `gclid`, `ttclid`)
200
+ - [ ] `_fbc` cookie read correctly
201
+ - [ ] First-touch protected per-type (revisit doesn't overwrite fresh data)
202
+ - [ ] 30-day refresh per-type (stale category overwritten, fresh ones kept)
203
+ - [ ] `getFresh()` excludes stale categories and `_meta`
204
+ - [ ] Sync fires when signed in + attribution changes
205
+ - [ ] `_meta.needsSync` set when signed out + attribution changes
206
+ - [ ] Deferred sync fires on sign-in when `needsSync` is true
207
+ - [ ] Signup (`auth.js`) sends `getFresh()` attribution
208
+ - [ ] Checkout (`api.js`) sends `getFresh()` attribution
209
+
210
+ ---
211
+
212
+ ## Open Questions / Notes
213
+
214
+ - Confirm the backend `user/signup` and checkout endpoints accept the new nested `adClicks` / `itm` shape (no backwards-compat shim — server should be updated in lockstep).
215
+ - Decide the sync command/endpoint for the standalone `syncAttribution()` call (e.g. `user/attribution` vs reusing signup). Signup already carries attribution, so sync-on-change is mainly for users who change attribution *after* signup but *before* checkout.
package/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@ Ultimate Jekyll Manager (UJM) is a comprehensive framework for building modern J
8
8
 
9
9
  - One-line bootstrap per context (build / frontend / service-worker)
10
10
  - Multi-stage gulp pipeline (15 tasks: defaults / distribute / webpack / sass / imagemin / jekyll / jsonToHtml / preprocess / audit / translation / minifyHtml / serve / setup / developmentRebuild)
11
- - Default Jekyll layouts + themes (`classy` shipped; per-theme SCSS load paths)
11
+ - Default Jekyll layouts + themes (`classy` + `neobrutalism` shipped; new themes inherit classy's layouts via the build-time fallback see [docs/themes.md](docs/themes.md))
12
12
  - Frontend ES-module Manager with dynamic per-page module loading
13
13
  - Service worker with Firebase Messaging + cache management
14
14
  - A built-in **three-layer test framework** (build / page / boot)
@@ -48,7 +48,7 @@ The only things that ARE safe to run inside UJM itself:
48
48
 
49
49
  1. `npm install` — install UJM's own deps
50
50
  2. `npm start` (≡ `npm run prepare:watch`) — copies `src/` → `dist/` on file change
51
- 3. Test in a consumer project: from inside the consumer, run `npx mgr install local` (swaps UJM to the local repo via the `install` CLI). Reverse with `npx mgr install prod`.
51
+ 3. Test in a consumer project: from inside the consumer, run `npx mgr install dev` to swap UJM to this local repo required whenever you edit the framework source and want the consumer to pick up the changes (the consumer otherwise keeps its installed `node_modules/ultimate-jekyll-manager`). Reverse with `npx mgr install live`.
52
52
  4. `npm test` — runs UJM's own 60 test suites
53
53
 
54
54
  ## Architecture
@@ -63,7 +63,7 @@ UJM exposes three Manager entry points:
63
63
  | Frontend (browser ES module) | `import Manager from 'ultimate-jekyll-manager'` | `new Manager().initialize()` → wires webManager + loads page module |
64
64
  | Service worker | `importScripts('/build.js')` then construct `Manager` | Manages cache + Firebase Messaging |
65
65
 
66
- All three Managers mix in shared helpers via `attachTo(Manager)` from [src/utils/mode-helpers.js](src/utils/mode-helpers.js): `isDevelopment()`, `isProduction()`, `isTesting()`, `getVersion()`. See [docs/cross-context-helpers.md](docs/cross-context-helpers.md).
66
+ All three Managers mix in shared helpers via `attachTo(Manager)` from [src/utils/mode-helpers.js](src/utils/mode-helpers.js): `isDevelopment()`, `isProduction()`, `isTesting()`, `getVersion()`. `getEnvironment()` returns `'development' | 'testing' | 'production'` (mutually exclusive — testing wins over dev); gate side effects on the INTENTIONAL check (`isProduction()` for prod-only, `isDevelopment() || isTesting()` for local-or-test) — never `!isDevelopment()`. See [docs/environment-detection.md](docs/environment-detection.md).
67
67
 
68
68
  ### Gulp pipeline
69
69
 
@@ -100,8 +100,8 @@ ES module class. Constructor stores `this.webManager`. `initialize()`:
100
100
 
101
101
  1. Calls `webManager.initialize(window.Configuration)`
102
102
  2. Reads `document.documentElement.dataset.pagePath` + `.assetPath`
103
- 3. Loads (in parallel) `__main_assets__/js/ultimate-jekyll-manager.js` + page-specific modules from both `__main_assets__/js/pages/<path>/index.js` (UJM defaults) AND `__project_assets__/js/pages/<path>/index.js` (consumer overrides)
104
- 4. Sequentially executes loaded modules (stops on first error)
103
+ 3. Loads (in parallel) `__main_assets__/js/ultimate-jekyll-manager.js` + page-specific modules from three layers: `__main_assets__/js/pages/<path>/index.js` (UJM default), `__theme__/pages/<path>/index.js` (active theme), and `__project_assets__/js/pages/<path>/index.js` (consumer). Missing at any layer is a no-op. See [docs/themes.md](docs/themes.md#5-page-specific-js-theme-aware-additive--mirrors-page-css).
104
+ 4. Sequentially executes loaded modules in order **main → theme → project** (stops on first error)
105
105
 
106
106
  Webpack aliases:
107
107
  - `__main_assets__` → UJM's `dist/assets/`
@@ -185,7 +185,7 @@ Deep references live in `docs/`. Treat docs as a first-class deliverable. **When
185
185
 
186
186
  - [docs/test-framework.md](docs/test-framework.md) — three-layer test harness reference (build / page / boot)
187
187
  - [docs/test-boot-layer.md](docs/test-boot-layer.md) — boot layer deep-dive (_site/ discovery, HTTP server, fixture vs consumer)
188
- - [docs/cross-context-helpers.md](docs/cross-context-helpers.md) — `isTesting`/`isDevelopment`/`isProduction`/`getVersion`
188
+ - [docs/environment-detection.md](docs/environment-detection.md) — `isTesting`/`isDevelopment`/`isProduction`/`getVersion`
189
189
  - [docs/jekyll-plugin.md](docs/jekyll-plugin.md) — UJ Powertools gem: filters, tags, page variables (`page.resolved`, `uj_icon`, `uj_hash`, `iftruthy`, etc.)
190
190
  - [docs/audit.md](docs/audit.md) — workflow for fixing issues raised by `gulp/tasks/audit.js`
191
191
 
@@ -197,6 +197,7 @@ Deep references live in `docs/`. Treat docs as a first-class deliverable. **When
197
197
 
198
198
  ### Pages, layouts, content
199
199
 
200
+ - [docs/themes.md](docs/themes.md) — theme system: selection + resolution (SCSS loadPaths, `__theme__`, classy layout fallback), shared vs per-theme layers, authoring a theme inside UJM OR in a consumer project, live validation
200
201
  - [docs/layouts-and-pages.md](docs/layouts-and-pages.md) — page types, layout chain, `asset_path` frontmatter
201
202
  - [docs/images.md](docs/images.md) — `@post/` shortcut for blog post images, BEM admin/post image handling, imagemin pipeline + source-size constraints + `UJ_IMAGEMIN_REWRITE_SOURCES` cleanup flag
202
203
  - [docs/icons.md](docs/icons.md) — Font Awesome conventions, `{% uj_icon %}` vs prerendered icons in JS, size reference
package/README.md CHANGED
@@ -28,6 +28,7 @@
28
28
  * **SEO Optimized**: Ultimate Jekyll is fully SEO optimized.
29
29
  * **Blazingly Fast**: Ultimate Jekyll is blazingly fast.
30
30
  * **NPM & Gulp**: Ultimate Jekyll is fueled by an intuitive incorporation of npm and gulp.
31
+ * **Themes**: Pick a shipped theme with `theme.id` (`classy` or `neobrutalism`), or author your own. New themes inherit all default page layouts automatically and only restyle/override what differs. See [docs/themes.md](docs/themes.md).
31
32
  * **Built-in test framework**: three layers (`build` / `page` / `boot`) — plain Node, headless Chromium tab, headless Chromium against real `_site/` with SW registration verification.
32
33
 
33
34
  ## 🚀 Getting started
@@ -0,0 +1,28 @@
1
+ /**
2
+ * /test — Global (framework default) page CSS — the first layer.
3
+ * Owns the base dot styling (every dot starts RED) and turns the
4
+ * "css-global" dot green to prove this layer loaded.
5
+ */
6
+
7
+ // Base: every layer dot is red until its layer turns it green.
8
+ .layer-row {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 0.6rem;
12
+ font-size: 1.05rem;
13
+ padding: 0.35rem 0;
14
+ }
15
+
16
+ .layer-dot {
17
+ display: inline-block;
18
+ width: 1.1rem;
19
+ height: 1.1rem;
20
+ border-radius: 50%;
21
+ background: #e5484d; // red = layer not loaded
22
+ flex-shrink: 0;
23
+ }
24
+
25
+ // This layer loaded → its own dot goes green.
26
+ .layer-dot[data-layer="css-global"] {
27
+ background: #30a46c; // green
28
+ }
@@ -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
  }
@@ -54,9 +54,10 @@ const performRedirect = () => {
54
54
  }
55
55
  }
56
56
 
57
- // Determine redirect delay
58
- const isDevelopment = config.environment === 'development';
59
- const timeout = isDevelopment ? 3000 : 1;
57
+ // Determine redirect delay — slow in any non-production environment (development OR
58
+ // testing) so the redirect is observable; near-instant in production.
59
+ const isNonProduction = config.environment !== 'production';
60
+ const timeout = isNonProduction ? 3000 : 1;
60
61
 
61
62
  // Build redirect URL
62
63
  let redirectUrl;
@@ -111,7 +112,7 @@ const performRedirect = () => {
111
112
  console.groupEnd();
112
113
 
113
114
  // Show user-friendly message in development
114
- if (isDevelopment) {
115
+ if (config.environment === 'development') {
115
116
  console.log(`[Redirect] Delaying redirect by ${timeout}ms for development mode`);
116
117
  }
117
118
 
@@ -38,7 +38,7 @@ const config = {
38
38
  selectors: {
39
39
  platformButtons: '.platform-btn',
40
40
  platformDownloads: '[data-platform]',
41
- downloadButtons: '.tab-pane[data-platform] .btn-primary:not([type="submit"])',
41
+ downloadButtons: '.tab-pane[data-platform] [data-download]',
42
42
  },
43
43
  };
44
44
 
@@ -17,6 +17,12 @@ export default () => {
17
17
  setupForm();
18
18
  setupRatingButtons();
19
19
 
20
+ // TEMP: expose for manual modal testing — remove before commit
21
+ window.__testReviewModal = () => showReviewModal('https://www.trustpilot.com/review/example.com', {
22
+ positive: 'This product is amazing and saved me hours!',
23
+ comments: 'Highly recommend to anyone.',
24
+ });
25
+
20
26
  // Resolve after initialization
21
27
  return resolve();
22
28
  });
@@ -120,7 +126,7 @@ function showReviewModal(reviewURL, data) {
120
126
  // Extract site name for display
121
127
  try {
122
128
  const siteName = new URL(fullURL).hostname.replace('www.', '');
123
- $link.innerHTML = `${getPrerenderedIcon('arrow-up-right-from-square', 'me-2')} Write a Review on ${webManager.utilities().escapeHTML(siteName)}`;
129
+ $link.innerHTML = `${getPrerenderedIcon('arrow-up-right-from-square', 'me-2')} Post your review on ${webManager.utilities().escapeHTML(siteName)}`;
124
130
  } catch (e) {
125
131
  // Use default text
126
132
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * /test — Global (framework default) page JS — the first layer.
3
+ * Turns the "js-global" dot green to prove this layer loaded + ran.
4
+ */
5
+ export default ({ manager, options }) => {
6
+ const dot = document.querySelector('.layer-dot[data-layer="js-global"]');
7
+ if (dot) {
8
+ dot.style.background = '#30a46c'; // green
9
+ }
10
+ console.log('[test-layer] global JS ran → js-global dot green');
11
+ };
@@ -0,0 +1,50 @@
1
+ # Theme Template
2
+
3
+ A minimal, copy-paste starting point for a **brand-new UJM theme** — whether
4
+ you're adding one inside UJM (`src/assets/themes/<id>/`) or creating one in your
5
+ own consumer project (`<project>/src/assets/themes/<id>/`).
6
+
7
+ > Full guide: [`docs/themes.md`](../../../../docs/themes.md).
8
+
9
+ ## Create a theme from this template
10
+
11
+ 1. **Copy this folder** to your theme id (the `_` prefix excludes this template
12
+ from selection, so rename it):
13
+ - Inside UJM: `src/assets/themes/my-theme/`
14
+ - In a consumer: `<project>/src/assets/themes/my-theme/`
15
+ 2. **Select it** in your consumer's `src/_config.yml`:
16
+ ```yaml
17
+ theme:
18
+ id: "my-theme"
19
+ ```
20
+ 3. **Customize** `_config.scss` (tokens), `css/` (styles), and `_theme.js`
21
+ (behaviors). Restyle Bootstrap's own classes (`.btn`, `.card`, `.navbar`,
22
+ `.form-control`) so the shared layouts pick up your look with no HTML edits.
23
+ 4. **Layouts/includes are inherited automatically.** You do NOT need to copy the
24
+ ~40 page layouts — UJM's build copies any missing layout/include from the
25
+ `classy` theme and rewrites the paths to your theme id. Override a layout only
26
+ when its *markup* (not just CSS) must differ — create
27
+ `src/defaults/dist/_layouts/themes/my-theme/<path>` (in UJM) or
28
+ `src/_layouts/themes/my-theme/<path>` (in a consumer) for just that file.
29
+ A common one: override `frontend/core/base.html` to load your theme's fonts.
30
+
31
+ ## What's here
32
+
33
+ ```
34
+ _template/
35
+ ├── _config.scss ← design tokens (!default) + Bootstrap forward
36
+ ├── _theme.scss ← SCSS entry (config → root → styles → bootstrap overrides)
37
+ ├── _theme.js ← JS entry (Bootstrap UMD + DOM-ready behaviors)
38
+ ├── README.md ← this file
39
+ └── css/
40
+ ├── base/_root.scss ← SCSS → CSS-variable bridge (light/dark)
41
+ └── components/_components.scss ← restyle Bootstrap classes here
42
+ ```
43
+
44
+ ## Principles (see docs/themes.md for the full list)
45
+
46
+ - **Tokens are `!default`** so consumers can override without forking your theme.
47
+ - **Bridge to CSS variables** in `_root.scss` for free dark-mode switching.
48
+ - **Don't duplicate the shared layers** — `core/` CSS (animations, alerts, lazy
49
+ loading) and `bootstrap/overrides` are injected for every theme already.
50
+ - **Namespace your own components** (`.mytheme-*`) to avoid collisions.
@@ -0,0 +1,60 @@
1
+ // <Theme Name> Configuration
2
+ // ALL customizable variables live here with !default so consuming projects can
3
+ // override any of them BEFORE the theme is imported. Keep this file as the
4
+ // single source of truth for your theme's design tokens.
5
+
6
+ // ============================================
7
+ // Bootstrap Color Overrides
8
+ // ============================================
9
+ $primary: #3B82F6 !default;
10
+ $secondary: #6B7280 !default;
11
+ $success: #10B981 !default;
12
+ $info: #06B6D4 !default;
13
+ $warning: #F59E0B !default;
14
+ $danger: #EF4444 !default;
15
+ $light: #F9FAFB !default;
16
+ $dark: #111827 !default;
17
+
18
+ // ============================================
19
+ // Surfaces (light + dark mode)
20
+ // ============================================
21
+ // Define your page/card backgrounds for each mode. _root.scss bridges these
22
+ // into CSS variables so dark-mode switching needs no recompilation.
23
+ $theme-bg-light: #FFFFFF !default;
24
+ $theme-surface-light: #F3F4F6 !default;
25
+ $theme-bg-dark: #0B0B0F !default;
26
+ $theme-surface-dark: #16161C !default;
27
+
28
+ // ============================================
29
+ // Typography
30
+ // ============================================
31
+ $font-family-sans-serif: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !default;
32
+ $headings-font-weight: 700 !default;
33
+
34
+ // ============================================
35
+ // Component sizing
36
+ // ============================================
37
+ // Match Classy's avatar map so the shared layouts/includes render correctly.
38
+ $avatar-sizes: (
39
+ null: 3rem, 2xs: 0.5rem, xs: 1.5rem, sm: 2rem, md: 2.5rem,
40
+ lg: 3.5rem, xl: 5rem, 2xl: 7.5rem, 3xl: 10rem, 4xl: 12.5rem, 5xl: 15rem
41
+ ) !default;
42
+
43
+ // ============================================
44
+ // Forward Bootstrap with our configuration
45
+ // ============================================
46
+ // Pass your overridden tokens into Bootstrap so .btn/.card/.form-control etc.
47
+ // are generated with your colors. Add/remove forwards as your theme needs.
48
+ @forward '../bootstrap/scss/bootstrap.scss' with (
49
+ $primary: $primary,
50
+ $secondary: $secondary,
51
+ $success: $success,
52
+ $info: $info,
53
+ $warning: $warning,
54
+ $danger: $danger,
55
+ $light: $light,
56
+ $dark: $dark,
57
+ $font-family-sans-serif: $font-family-sans-serif,
58
+ $headings-font-weight: $headings-font-weight,
59
+ $enable-negative-margins: true
60
+ );