ultimate-jekyll-manager 1.4.3 → 1.6.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 (66) hide show
  1. package/CHANGELOG.md +36 -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/modules/redirect.js +5 -4
  7. package/dist/assets/js/pages/download/index.js +1 -1
  8. package/dist/assets/js/pages/feedback/index.js +1 -1
  9. package/dist/assets/js/pages/test/libraries/layers/index.js +11 -0
  10. package/dist/assets/themes/_template/README.md +50 -0
  11. package/dist/assets/themes/_template/_config.scss +60 -0
  12. package/dist/assets/themes/_template/_theme.js +13 -4
  13. package/dist/assets/themes/_template/_theme.scss +16 -4
  14. package/dist/assets/themes/_template/css/base/_root.scss +19 -0
  15. package/dist/assets/themes/_template/css/components/_components.scss +23 -0
  16. package/dist/assets/themes/classy/README.md +18 -6
  17. package/dist/assets/themes/neobrutalism/README.md +98 -0
  18. package/dist/assets/themes/neobrutalism/_config.scss +139 -0
  19. package/dist/assets/themes/neobrutalism/_theme.js +27 -0
  20. package/dist/assets/themes/neobrutalism/_theme.scss +33 -0
  21. package/dist/assets/themes/neobrutalism/css/base/_mixins.scss +46 -0
  22. package/dist/assets/themes/neobrutalism/css/base/_root.scss +80 -0
  23. package/dist/assets/themes/neobrutalism/css/base/_typography.scss +77 -0
  24. package/dist/assets/themes/neobrutalism/css/base/_utilities.scss +25 -0
  25. package/dist/assets/themes/neobrutalism/css/components/_buttons.scss +148 -0
  26. package/dist/assets/themes/neobrutalism/css/components/_cards.scss +69 -0
  27. package/dist/assets/themes/neobrutalism/css/components/_forms.scss +88 -0
  28. package/dist/assets/themes/neobrutalism/css/components/_infinite-scroll.scss +94 -0
  29. package/dist/assets/themes/neobrutalism/css/layout/_general.scss +200 -0
  30. package/dist/assets/themes/neobrutalism/css/layout/_navigation.scss +153 -0
  31. package/dist/assets/themes/neobrutalism/js/initialize-tooltips.js +20 -0
  32. package/dist/assets/themes/neobrutalism/js/navbar-scroll.js +29 -0
  33. package/dist/assets/themes/neobrutalism/pages/index.scss +227 -0
  34. package/dist/assets/themes/neobrutalism/pages/pricing/index.scss +267 -0
  35. package/dist/assets/themes/neobrutalism/pages/test/libraries/layers/index.js +9 -0
  36. package/dist/assets/themes/neobrutalism/pages/test/libraries/layers/index.scss +7 -0
  37. package/dist/build.js +2 -5
  38. package/dist/commands/install.js +1 -1
  39. package/dist/commands/setup.js +41 -0
  40. package/dist/defaults/CLAUDE.md +5 -1
  41. package/dist/defaults/dist/_includes/core/head.html +17 -0
  42. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/footer.html +4 -4
  43. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/download.html +2 -0
  44. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/feedback.html +7 -3
  45. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/core/base.html +31 -0
  46. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/pages/index.html +345 -0
  47. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/pages/pricing.html +483 -0
  48. package/dist/defaults/dist/pages/test/libraries/layers.html +57 -0
  49. package/dist/defaults/src/_config.yml +2 -0
  50. package/dist/defaults/test/_init.js +10 -0
  51. package/dist/gulp/tasks/defaults.js +8 -0
  52. package/dist/gulp/tasks/sass.js +43 -2
  53. package/dist/gulp/tasks/translation.js +11 -0
  54. package/dist/gulp/tasks/utils/manage-test-layers.js +97 -0
  55. package/dist/index.js +30 -4
  56. package/dist/test/runner.js +62 -0
  57. package/dist/test/suites/build/manager.test.js +11 -4
  58. package/dist/test/suites/build/mode-helpers.test.js +54 -2
  59. package/dist/utils/mode-helpers.js +65 -40
  60. package/docs/assets.md +6 -1
  61. package/docs/environment-detection.md +85 -0
  62. package/docs/test-framework.md +48 -3
  63. package/docs/themes.md +451 -0
  64. package/package.json +2 -1
  65. package/docs/cross-context-helpers.md +0 -75
  66. package/package copy.json +0 -75
@@ -2,6 +2,28 @@
2
2
 
3
3
  UJM ships a built-in three-layer test harness. `npx mgr test` discovers framework suites from `<ujm>/dist/test/suites/**/*.js` and consumer suites from `<cwd>/test/**/*.js`, partitions by `layer`, and runs each layer in the right environment. Same shape as the sister harnesses in EM (electron-manager) and BXM (browser-extension-manager).
4
4
 
5
+ ## 🚫 NEVER mock — test against the real harness (HARD RULE)
6
+
7
+ **Do NOT hand-roll fake/stub/mock objects.** Every test runs against a real environment, and the harness hands the test the real thing — use it:
8
+
9
+ - **`build` layer** gets the **real** `Manager` from `require('ultimate-jekyll-manager/build')` — call its real API (`getConfig`, `getUJMConfig`, `getPackage`, `isTesting`, the gulp pure helpers). Never fake a `Manager` whose `getConfig()` returns canned data.
10
+ - **`page` layer** runs in a **real** headless Chromium tab with real `window`/`document` and the harness-provided `window.Configuration`. Drive the real frontend Manager surface; don't stub DOM globals in your test.
11
+ - **`boot` layer** runs against the **real** built `_site/` served over a **real** HTTP origin — exercise the actually-shipped site through Puppeteer.
12
+ - **Pure functions (zero I/O) are the ONLY thing you call directly** — e.g. `mergeJekyllConfigs`, `validateYAMLFrontMatter`, `createTemplateTransform`, `collectTextNodes`. `require()` them and pass plain inputs. That is NOT mocking — there is nothing to mock. The moment a function touches real I/O (config files, the DOM, the HTTP server, an external API), it MUST run against the real harness/build, not a stub.
13
+
14
+ If you find yourself writing `const mockX = {...}` to satisfy code under test, STOP — use the real context the layer already provides, or (if it's genuinely pure) call it with plain data.
15
+
16
+ ### The ONLY two exceptions where a narrow stub is allowed
17
+
18
+ Mock **nothing** by default. There are exactly two cases where the real dependency genuinely cannot run in the test environment — and even then, stub the *smallest possible seam* (one method / one object), restore it immediately, and comment *why*:
19
+
20
+ 1. **A side effect that would destroy the test run itself.** If the real call would kill or corrupt the harness — a process-exit, a destructive `_site/` wipe, a recursive re-invocation of the build/test command — stub *that one call* to a no-op, assert the surrounding logic, then restore. You are preventing the harness from terminating mid-assertion, not faking behavior.
21
+ 2. **A real dependency the test environment can't provide.** When the real thing only exists from infra you can't stand up in the current layer (an external service with no local equivalent, a second running instance), a unit test may hand minimal inputs to exercise the logic in isolation — but a real-harness test (`page`/`boot`) MUST still cover the wired path where one exists.
22
+
23
+ If you can run it for real, you must. These exceptions are not a license to unit-test in isolation when a real-harness layer would work.
24
+
25
+ **External APIs are skipped in-source, NOT mocked.** UJM build/gulp code that would hit the network (e.g. fetching Firebase auth files) short-circuits in its own source when `Manager.isTesting()` is true — it returns early, it does not return canned/mocked data. See [environment-detection.md](environment-detection.md). If a suite has slower live-integration tests, gate them behind the `--integration` flag (`UJ_TEST_INTEGRATION=1`) and run the real path; anything such a test creates externally MUST be cleaned up by the test (`cleanup`/`inspect` teardown) — the runner does not clean external systems.
26
+
5
27
  ## Quick start
6
28
 
7
29
  ```bash
@@ -135,7 +157,7 @@ The public surface exposed by `require('ultimate-jekyll-manager/build')` include
135
157
  - `Manager.logger(name)` — returns a `Logger` instance
136
158
  - `Manager.require(path)` — escape hatch when you really need a UJM transitive dep
137
159
 
138
- See [docs/cross-context-helpers.md](cross-context-helpers.md) for `isTesting`/`isDevelopment` semantics.
160
+ See [docs/environment-detection.md](environment-detection.md) for `isTesting`/`isDevelopment` semantics.
139
161
 
140
162
  ## Reporter contract — `__UJM_TEST__` JSON-line events
141
163
 
@@ -162,12 +184,35 @@ Same protocol as EM (`__EM_TEST__`) and BXM (`__BXM_TEST__`). One marker per fra
162
184
  - **Excluded**: any directory starting with `_` (handy for shared helpers).
163
185
  - **Framework boot suites** are excluded when the cwd's `package.json#name` is not `ultimate-jekyll-manager` — they target UJM's fixture site, not the consumer's. Consumers write their own boot tests in `<cwd>/test/boot/`.
164
186
 
187
+ ## `test/_init.js` — pre-test lifecycle hook
188
+
189
+ The runner loads an optional `test/_init.js` from **both** test roots — the framework (`<UJM>/test/_init.js`) and the consumer project (`<cwd>/test/_init.js`) — and runs it **once, before any suite** (it is NOT itself run as a test; the `_`-prefix keeps it out of discovery). Mirrors the same hook in BEM/EM/BXM so all four frameworks share one shape.
190
+
191
+ The module **must export a function** — `module.exports = (ctx) => ({ ... })` — called with `{ projectRoot }` and returning the hook object. It may declare:
192
+
193
+ - `async setup({ projectRoot })` — runs once before the suites, e.g. to scaffold a fixture file the boot layer needs.
194
+
195
+ There is **no `cleanup` hook** and **no `accounts` field** (unlike BEM — these frameworks have no auth/user system): tests clean up after themselves, so there is nothing project-level to tear down.
196
+
197
+ ```javascript
198
+ // <cwd>/test/_init.js
199
+ const fs = require('fs');
200
+ const path = require('path');
201
+
202
+ module.exports = ({ projectRoot }) => ({
203
+ async setup() {
204
+ // Seed any fixture a suite needs before it runs.
205
+ fs.mkdirSync(path.join(projectRoot, '.temp'), { recursive: true });
206
+ },
207
+ });
208
+ ```
209
+
165
210
  ## Env vars
166
211
 
167
212
  | Env | Set by | Purpose |
168
213
  |---|---|---|
169
214
  | `UJ_TEST_MODE=true` | `npx mgr test` always | Canonical test signal. `Manager.isTesting()` reads this. Use it to short-circuit network calls / prompts / long timers in code that runs during tests. |
170
- | `UJ_TEST_INTEGRATION=1` | `--integration` flag | Opt-in flag for slower integration tests if your suite has them |
215
+ | `UJ_TEST_INTEGRATION=1` | `--integration` flag | Opt-in flag for slower live-integration tests if your suite has them. These run the **real** external path (NOT mocked); anything they create externally MUST be cleaned up by the test. |
171
216
  | `UJ_TEST_BOOT_PROJECT` | Auto-set when UJM tests itself; else manual | Project root the boot runner uses (its `_site/` is the boot target) |
172
217
  | `UJ_TEST_BOOT_DIR` | Manual | Absolute override for the `_site/` directory. Wins over `UJ_TEST_BOOT_PROJECT/_site` and `<cwd>/_site` |
173
218
  | `UJ_TEST_DEBUG=1` | Manual | Verbose Puppeteer console output piped to the parent stdout |
@@ -179,5 +224,5 @@ Puppeteer is a `devDependency` of UJM itself. Consumers don't get it unless they
179
224
  ## See also
180
225
 
181
226
  - [test-boot-layer.md](test-boot-layer.md) — deep dive on boot layer (`_site/` discovery, HTTP server, fixture vs consumer)
182
- - [cross-context-helpers.md](cross-context-helpers.md) — `Manager.isTesting()` / `isDevelopment()` semantics
227
+ - [environment-detection.md](environment-detection.md) — `Manager.isTesting()` / `isDevelopment()` semantics
183
228
  - [cli.md](cli.md) — CLI surface, env-var conventions
package/docs/themes.md ADDED
@@ -0,0 +1,451 @@
1
+ # Themes
2
+
3
+ How UJM's theme system works, and how to author a theme — either **inside UJM**
4
+ (shipped to every consumer) or **in a consumer project** (that project only).
5
+
6
+ A theme controls the **visual language** (colors, type, borders, shadows,
7
+ component styling) and optionally the **markup** of specific pages. It does NOT
8
+ need to re-implement the framework's shared behavior — that is injected for every
9
+ theme automatically (see [Shared vs per-theme](#shared-vs-per-theme)).
10
+
11
+ Shipped themes live in [src/assets/themes/](../src/assets/themes/):
12
+
13
+ - **`bootstrap/`** — the base layer: Bootstrap 5 SCSS/JS + universal `overrides/`
14
+ every theme inherits. Not selected directly.
15
+ - **`classy/`** — the default, full-featured frontend theme. Also the **layout
16
+ fallback source** (see below).
17
+ - **`neobrutalism/`** — bold high-contrast theme (hard borders, offset shadows,
18
+ zero radius). A worked example of a second theme.
19
+ - **`_template/`** — a copy-paste starter for new themes (the `_` prefix excludes
20
+ it from selection).
21
+
22
+ ---
23
+
24
+ ## How a theme is selected and loaded
25
+
26
+ A consumer picks a theme with one field in `src/_config.yml`:
27
+
28
+ ```yaml
29
+ theme:
30
+ id: "neobrutalism" # folder name under assets/themes/
31
+ appearance: "light" # light | dark | system → sets <html data-bs-theme>
32
+ ```
33
+
34
+ Three resolution mechanisms turn that id into a built site:
35
+
36
+ ### 1. SCSS (`__theme__` via loadPaths)
37
+
38
+ The consumer's `src/assets/css/main.scss` does `@use 'ultimate-jekyll-manager' as *;`.
39
+ That entry point ([src/assets/css/ultimate-jekyll-manager.scss](../src/assets/css/ultimate-jekyll-manager.scss))
40
+ does `@forward 'theme'`. The SASS task resolves the bare `theme` import via
41
+ `loadPaths`, in priority order
42
+ ([src/gulp/tasks/sass.js](../src/gulp/tasks/sass.js)):
43
+
44
+ 1. `<project>/src/assets/themes/<id>/` ← **project shadows package**
45
+ 2. `<package>/dist/assets/themes/<id>/` ← UJM's built-in theme
46
+ 3. `<package>/dist/assets/themes/` ← lets a theme `@import '../bootstrap/...'`
47
+
48
+ So `_theme.scss` is found in the project's theme dir first, else UJM's. You never
49
+ import a theme by hard path — let loadPaths resolve it.
50
+
51
+ ### 2. JS (`__theme__` webpack alias)
52
+
53
+ `ultimate-jekyll-manager.js` does `import('__theme__/_theme.js')`. The webpack
54
+ alias ([src/gulp/tasks/webpack.js](../src/gulp/tasks/webpack.js)) resolves
55
+ `__theme__` to the project theme dir if it exists, else UJM's package theme dir —
56
+ same project-shadows-package rule as SCSS.
57
+
58
+ ### 3. Layouts & includes (the classy fallback)
59
+
60
+ This is the key to **not duplicating ~40 page layouts**. Theme HTML lives under:
61
+
62
+ - `src/defaults/dist/_layouts/themes/<id>/...`
63
+ - `src/defaults/dist/_includes/themes/<id>/...`
64
+
65
+ During build, `copyFallbackThemeFiles()`
66
+ ([src/gulp/tasks/distribute.js](../src/gulp/tasks/distribute.js)) copies every
67
+ layout/include from the **`classy`** theme that the selected theme hasn't defined,
68
+ rewriting `themes/classy/` → `themes/<id>/` in the content. Layouts also use the
69
+ `themes/[ site.theme.id ]/...` template variable, which distribute resolves to the
70
+ active theme.
71
+
72
+ **Result:** a new theme inherits all of classy's pages for free and overrides
73
+ **only** the files whose *markup* must differ. You saw this in the build log:
74
+
75
+ ```
76
+ Copied 48 fallback files from 'classy' to 'neobrutalism' theme
77
+ ```
78
+
79
+ > Because classy is the fallback source, **keep classy's layouts theme-agnostic**
80
+ > (semantic Bootstrap classes, data-driven includes). Every other theme inherits
81
+ > them.
82
+
83
+ #### When to override a layout vs just restyle with CSS
84
+
85
+ Most of the time you do NOT override layouts — you restyle Bootstrap's classes
86
+ (see [Shared vs per-theme](#shared-vs-per-theme)) and the inherited classy markup
87
+ adopts your look. Override a layout only when the **structure itself** must differ.
88
+
89
+ The `neobrutalism` theme demonstrates both. It restyles classy's markup everywhere
90
+ EXCEPT the homepage and pricing page, where it ships genuinely different structure
91
+ (asymmetric split hero, offset showcase rows, oversized color-block stats) at
92
+ `_layouts/themes/neobrutalism/frontend/pages/{index,pricing}.html`. **Critically,
93
+ those overrides reuse the SAME `page.resolved.*` frontmatter data contract as
94
+ classy's versions** — same `hero`, `pricing`, `stats`, `cta` keys, same
95
+ price-resolution Liquid — so a consumer's existing page frontmatter keeps working;
96
+ only the HTML that renders the data changes. When you override a page layout, copy
97
+ classy's frontmatter block and preserve any data-resolution Liquid; restructure
98
+ only the markup below the front matter.
99
+
100
+ #### No theme-prefixed classes — use universal class names
101
+
102
+ **Markup must never use theme-prefixed classes** (no `nb-*`, `classy-*`, etc.). A
103
+ theme-prefixed class hardcodes the markup to one theme and breaks swappability. When a
104
+ theme writes its own layout, it uses:
105
+
106
+ 1. **Standard Bootstrap classes** wherever one fits — every theme already styles these:
107
+ - `.card` (+ `.card-body`) — the canonical box for stat/step/plan/feature blocks
108
+ - `.btn` / `.btn-primary` / `.btn-warning` / `.btn-outline-*` — buttons (theme picks the semantic color; `.btn-warning` = the yellow accent in neobrutalism)
109
+ - `.text-bg-{primary,secondary,success,info,warning,danger}` — full color-block fills
110
+ - `.border`, `.shadow`, `.accordion`, `.badge`, grid/flex utilities
111
+ 2. **Universal semantic layout classes** for structures with no Bootstrap equivalent —
112
+ shared *names*, each theme supplies its own *styling*:
113
+
114
+ | Class | Represents |
115
+ |---|---|
116
+ | `.section-hero` / `.hero-title` / `.hero-actions` | a page hero block |
117
+ | `.action-block` (`--ink` / `--surface`) | large stacked call-to-action blocks |
118
+ | `.logo-strip` (`-box`, `-label`) | a "trusted by" logo marquee strip |
119
+ | `.showcase` / `.showcase-row` (`--flip`) / `.showcase-num` / `.showcase-body` | alternating feature showcase rows |
120
+ | `.steps` / `.step-card` (`-num`, `-icon`, `-title`, `-desc`) | numbered "how it works" steps |
121
+ | `.stats` / `.stats-grid` / `.stat-block` (`-num`, `-label`, `-sub`) | stat / social-proof cells |
122
+ | `.cta` / `.cta-panel` / `.cta-title` / `.cta-desc` / `.cta-actions` | closing CTA panel |
123
+ | `.pricing-hero` / `.pricing-title` / `.pricing-plans` / `.pricing-plan` (`--popular`) / `.pricing-plan-*` | pricing page structures |
124
+ | `.billing-toggle` / `.billing-option` / `.billing-save` | monthly/annual billing switch |
125
+ | `.enterprise-panel` (`-title`) / `.faq` | enterprise strip, FAQ section |
126
+ | `.section-head` / `.section-title` / `.kicker` (`--invert`) / `.highlight` / `.font-mono` | shared section heading + label/highlight helpers |
127
+
128
+ A new theme that overrides these pages styles **the same class names** its own way.
129
+ This is the contract that keeps custom layouts swappable.
130
+
131
+ > The `nb-`-style prefix survives ONLY on a theme's SCSS internals — its `$theme-*`
132
+ > config tokens, `--theme-*` CSS variables, and `@mixin` helpers. Those never appear in
133
+ > HTML, so they don't affect swappability. Keep prefixes out of markup, not out of SCSS.
134
+
135
+ ### 4. Page-specific CSS (theme-aware, additive)
136
+
137
+ Every page links a per-path CSS bundle (`/assets/css/pages/<path>/index.bundle.css`,
138
+ resolved in [_includes/core/head.html](../src/defaults/dist/_includes/core/head.html)).
139
+ That bundle composes UJM's base page CSS (`assets/css/pages/<path>/index.scss`) and
140
+ the consumer's same-path file. Themes add a **third, additive layer**:
141
+
142
+ - Put theme page CSS at **`themes/<id>/pages/<path>/index.scss`**.
143
+ - The SASS task ([src/gulp/tasks/sass.js](../src/gulp/tasks/sass.js)) compiles it to a
144
+ separate bundle **`pages/<path>/index.<id>.bundle.css`**.
145
+ - `head.html` links that bundle **in addition to** the base bundle, loaded *after* it
146
+ (so theme page CSS can override base), gated by `{% iffile %}`.
147
+
148
+ **The fallback is the absence of a file** — and that's the whole elegance:
149
+
150
+ - If the theme has **no** page CSS for a path (e.g. signin, which almost no theme
151
+ customizes), the `<id>` bundle simply doesn't exist, `{% iffile %}` skips the link,
152
+ and the page is styled entirely by the theme's component/general CSS in the main
153
+ bundle. **No fallback mechanism needed** — missing = nothing extra loads.
154
+ - If the theme **does** ship page CSS (e.g. neobrutalism's `pages/index.scss` for its
155
+ custom homepage structure), it loads and layers on top.
156
+
157
+ This asymmetry vs. HTML layouts is deliberate: a page must always have *some* layout
158
+ (hence the classy copy-fallback), but page CSS is purely additive, so a missing file
159
+ is the correct no-op. Theme page CSS compiles standalone, so it pulls in the theme's
160
+ tokens + mixins via loadPaths (`@use 'config' as *;` + `@import 'css/base/mixins';`).
161
+
162
+ **Path shape must match the base bundle.** The theme file's path mirrors the base
163
+ page-CSS path for that page — which is NOT always `pages/<path>/index.scss`:
164
+
165
+ | Page | Base page CSS | Theme page CSS |
166
+ |---|---|---|
167
+ | Homepage (`/`) | `pages/index.scss` (flat) | `pages/index.scss` (flat) |
168
+ | Other (`/pricing`) | `pages/pricing/index.scss` (nested) | `pages/pricing/index.scss` (nested) |
169
+
170
+ The homepage is the one special case — its bundle is the flat `pages/index.bundle.css`,
171
+ so the theme file is the flat `themes/<id>/pages/index.scss`, NOT `pages/index/index.scss`.
172
+ If the shapes don't match, `{% iffile %}` looks for a bundle that was compiled under a
173
+ different name and silently skips the link. (The same flat-vs-nested rule applies to
174
+ theme page JS below.)
175
+
176
+ ### 5. Page-specific JS (theme-aware, additive — mirrors page CSS)
177
+
178
+ Page JS works exactly like page CSS — three additive layers, same no-op-on-missing
179
+ rule. The frontend Manager ([src/index.js](../src/index.js)) dynamically imports a
180
+ page module from each layer and runs them **in order**:
181
+
182
+ 1. `#main` — `__main_assets__/js/pages/<path>/index.js` (framework default)
183
+ 2. `#theme` — `__theme__/pages/<path>/index.js` (active theme) ← the theme layer
184
+ 3. `#project` — `__project_assets__/js/pages/<path>/index.js` (consumer)
185
+
186
+ - Put theme page JS at **`themes/<id>/pages/<path>/index.js`** (same path shape as the
187
+ CSS — flat `pages/index.js` for the homepage, nested otherwise).
188
+ - A module exports `default ({ manager, options }) => { ... }`. Missing at any layer is
189
+ a graceful no-op (logged as `module missing: #<layer>/…`, execution continues).
190
+ - Execution order is **main → theme → project**, matching the CSS cascade: framework
191
+ default first, theme second, consumer last (consumer always wins).
192
+ - The theme import uses a `/* webpackInclude: /\.js$/ */` magic comment so webpack's
193
+ dynamic-import context only scans `.js` — the theme's `pages/` dir also holds page
194
+ CSS (`.scss`), which must NOT be pulled into the JS context.
195
+
196
+ So a theme can ship a page's structure (layout override), its styling (theme page CSS),
197
+ AND its behavior (theme page JS) — all three keyed off the same `pages/<path>` path,
198
+ all three no-ops when absent.
199
+
200
+ The three layers, named by **source** (the `#main`/`#theme`/`#project` tags in the
201
+ console logs map to these), always load in this order:
202
+
203
+ 1. **Global** — the framework's own page file (`#main`)
204
+ 2. **Theme** — the active theme's page file (`#theme`)
205
+ 3. **Consumer** — the consuming project's page file (`#project`)
206
+
207
+ Later layers win (Consumer overrides Theme overrides Global) — the same cascade for
208
+ both CSS and JS.
209
+
210
+ ### Asset-layer test panel
211
+
212
+ The built-in **`/test/libraries/layers`** page renders a live status panel for exactly
213
+ this cascade: six dots (CSS ×3, JS ×3), one per layer. Each dot starts **red** and a
214
+ layer turns **its own** dot green when it loads (CSS via a selector, JS by setting the
215
+ dot color). A **red** dot means that layer has no file for this page — the normal state
216
+ for a layer nobody customized. The panel reflects what *actually* loads.
217
+
218
+ - **Global** and **Theme** dots are populated by files shipped in the framework:
219
+ `assets/{css,js}/pages/test/libraries/layers/index.*` (global) and the active theme's
220
+ `pages/test/libraries/layers/index.*` (theme). These are green out of the box.
221
+ - **Consumer** dots require a real consumer file at
222
+ `src/assets/{css,js}/pages/test/libraries/layers/index.*`. By default a project has
223
+ none, so they're **red** — the honest "this layer is available but unused" signal.
224
+
225
+ **Proving the Consumer layer without committing files (`UJ_TEST_LAYERS`).** To light the
226
+ Consumer dots green on demand, run the dev server with the flag — there's a ready-made
227
+ script:
228
+
229
+ ```bash
230
+ npm run start:test-layers # ≡ UJ_TEST_LAYERS=true npm start
231
+ ```
232
+
233
+ When set, the `defaults` task ([utils/manage-test-layers.js](../src/gulp/tasks/utils/manage-test-layers.js))
234
+ generates a real consumer page file into the project's `src/` **at build start** (before
235
+ sass + jekyll), so it loads through the genuine `__project_assets__` / consumer-page-CSS
236
+ path — no shims. The generated CSS `@use`s the framework base so it *composes* (the same
237
+ contract every real consumer page file follows). The files carry a `GENERATED — UJ_TEST_LAYERS`
238
+ marker and are **auto-removed at the start of the next run** (and never on a normal run),
239
+ so they never persist or get committed. (`.temp`/`dist` are also cleaned by `npx mgr clean`.)
240
+
241
+ > **Build-order note (dev only).** Theme page bundles are produced by the SASS task,
242
+ > then Jekyll's `{% iffile %}` checks for them in its source tree (`dist/`). On the
243
+ > *very first* dev render, Jekyll may render a page before its theme bundle has been
244
+ > written, so the link is missing until that page is re-rendered (touch the page source
245
+ > or save it again). A production `npm run build` runs sass before jekyll, so there's no
246
+ > race. If a theme page's CSS/JS isn't applying in dev, re-trigger that page's render.
247
+
248
+ > Editing `sass.js` / `index.js` (or any gulp/build task)? The consumer's running
249
+ > `gulp serve` loaded the task at startup — **fully restart the consumer's `npm start`**
250
+ > for task-code changes to take effect (webpack picks up `src/index.js` on its own watch,
251
+ > but gulp task definitions like the sass globs only reload on a fresh process).
252
+ > Layout/SCSS/`head.html` *content* changes are picked up live.
253
+
254
+ ---
255
+
256
+ ## Shared vs per-theme
257
+
258
+ Get this distinction right or you'll either duplicate plumbing or fight overrides.
259
+
260
+ ### Shared — do NOT re-implement in a theme
261
+
262
+ | Layer | Where | What |
263
+ |---|---|---|
264
+ | Core behavior CSS | [src/assets/css/core/](../src/assets/css/core/) | animations, alerts, lazy-loading shimmer, cookie consent, bindings skeletons, social sharing. Injected for **every** theme by `ultimate-jekyll-manager.scss`. |
265
+ | Bootstrap extensions | [src/assets/themes/bootstrap/overrides/](../src/assets/themes/bootstrap/overrides/) | avatars, color-shades, soft-colors, adaptive buttons, spacing, link/typography utilities. Each theme pulls these in via `@import '../bootstrap/overrides'` at the end of its `_theme.scss`. |
266
+ | Page layouts/includes | classy theme + fallback copy | the ~40 frontend/backend/admin layouts and nav/footer/account includes. |
267
+ | Bootstrap class contract | `bootstrap/scss` | the markup + class names (`.btn`, `.card`, `.navbar`, `.form-control`). Themes **restyle** these classes; they don't invent new markup. |
268
+
269
+ ### Per-theme — this IS the theme's job (and SHOULD differ between themes)
270
+
271
+ - `_config.scss` — design tokens (`!default`), Bootstrap forward.
272
+ - `_root.scss` — SCSS → CSS-variable bridge for light/dark.
273
+ - Component SCSS — how `.btn`/`.card`/`.form-control`/`.navbar` actually look.
274
+ - `_theme.js` — expose Bootstrap + run theme behaviors on DOM ready.
275
+ - *(optional)* Page-layout overrides under `_layouts/themes/<id>/...` — for pages
276
+ whose structure must differ (reuse classy's frontmatter data contract).
277
+ - *(optional)* Theme page CSS at `pages/<path>/index.scss` — additive per-page
278
+ styles for those overridden layouts.
279
+ - *(optional)* Theme page JS at `pages/<path>/index.js` — additive per-page behavior
280
+ (the `#theme` layer between `#main` and `#project`).
281
+
282
+ A neobrutalist button and a classy button share only the `.btn` class — restyling
283
+ it is the theme's whole purpose. **Do not try to "share" component styling between
284
+ themes**; that produces an override-fighting mess. Share *plumbing*, not *looks*.
285
+
286
+ > Themes share the **class-name contract** (standard Bootstrap classes + the universal
287
+ > semantic layout classes — see [No theme-prefixed classes](#no-theme-prefixed-classes--use-universal-class-names)),
288
+ > not the styling. Same names, different looks → markup stays swappable.
289
+
290
+ ### Structural vs visual components (gotcha)
291
+
292
+ A few "components" in classy are **structural behavior**, not just styling — e.g.
293
+ `_infinite-scroll.scss` (the trusted-by logo marquee + testimonial scroll). The
294
+ shared layouts emit `.infinite-scroll-track` markup, so any theme using those
295
+ layouts needs the matching CSS or the content renders broken (giant unstyled
296
+ logos). Until these are promoted to the shared layer, **port structural components
297
+ you actually use** into your theme (neobrutalism ships its own
298
+ `css/components/_infinite-scroll.scss` — mostly the same flex/marquee rules with
299
+ theme-tuned cosmetics). If a page using shared markup looks broken, check whether
300
+ a structural component is missing.
301
+
302
+ ### Adaptive buttons need an explicit theme override (gotcha)
303
+
304
+ The shared `bootstrap/overrides/_buttons-adaptive.scss` defines `.btn-adaptive` /
305
+ `.btn-adaptive-inverse` (mode-flipping solids used by the nav CTA, auth buttons,
306
+ redirect, etc.) **only at single-class specificity** (`.btn-adaptive`, `0,1,0`) and
307
+ via the `--bs-btn-*` custom properties. A theme that restyles `.btn` will hit two
308
+ problems with these classes if it ignores them:
309
+
310
+ 1. **Transparent resting fill.** Bootstrap's base `.btn { --bs-btn-bg: transparent }`
311
+ can tie/beat the `.btn-adaptive` rule on source order, so the button renders
312
+ *transparent* instead of solid. Fix: restate the resting fill at **doubled
313
+ specificity** (`.btn.btn-adaptive`, `0,2,0`) in the theme's `_buttons.scss`, with a
314
+ `[data-bs-theme="dark"]` pair for the mode flip.
315
+ 2. **Hover color flash.** The adaptive defaults darken on hover (`--bs-btn-hover-bg:
316
+ var(--bs-dark-hover)`), so an adaptive button animates differently from the theme's
317
+ other solids. Fix: add `.btn-adaptive` / `.btn-adaptive-inverse` to the same
318
+ hover/active **freeze** list the theme uses for `.btn-primary` etc.
319
+ (`--bs-btn-hover-bg: var(--bs-btn-bg)`), so the press is pure transform+shadow.
320
+
321
+ See `themes/neobrutalism/css/components/_buttons.scss` for the reference treatment.
322
+ The `[class*="btn-outline-"]` selector already catches the *outline* adaptive
323
+ variants, so only the two **solid** adaptive classes need this.
324
+
325
+ ### One token for interactive hovers — and `$primary` ≠ `--bs-primary` (gotcha)
326
+
327
+ Give every interactive hover/active fill (nav links, footer links, dropdown items,
328
+ in-content links, mobile toggler) a **single** token so they retune from one line and
329
+ never drift. Neobrutalism defines `--nb-accent-interactive` (blue) in `_root.scss`,
330
+ distinct from the yellow signature accent (`--nb-accent-yellow`) reserved for static
331
+ blocks (kicker tags, highlight marker, hero, badges). Every interactive `:hover`/
332
+ `.active` reads `var(--nb-accent-interactive)`; nothing hardcodes the color.
333
+
334
+ **The trap:** point that token at the runtime **`var(--bs-primary)`**, NOT the SCSS
335
+ `$primary` token. A consumer can set them to *different* values (UJM consumers
336
+ frequently do — the SCSS `$primary` and the rendered `--bs-primary` diverged, so a
337
+ rule written as `background: $primary` compiled a purple that didn't match the blue
338
+ `.btn-primary` buttons on the page). Any rule that should match a rendered Bootstrap
339
+ color must use the **CSS variable** (`var(--bs-primary)`), because SCSS values are
340
+ frozen at compile time while the `--bs-*` vars reflect the live theme.
341
+
342
+ One more: a generic `a:hover { color: … }` also paints **button** labels (buttons are
343
+ `<a>`). Guard it with `a.btn:hover { color: var(--bs-btn-color); }` so buttons keep
344
+ their own (frozen) text color. Nav/dropdown/footer links already win on specificity.
345
+
346
+ ---
347
+
348
+ ## Authoring conventions (both paths)
349
+
350
+ 1. **Every token is `!default`** so consumers can override without forking.
351
+ 2. **Bridge to CSS variables** in `_root.scss`; components read `var(--*)`, not raw
352
+ SCSS. Dark mode then becomes one `[data-bs-theme="dark"]` override block.
353
+ 3. **Restyle Bootstrap's own classes** so inherited markup adopts your look with no
354
+ HTML edits. Add your own classes only for net-new patterns.
355
+ 4. **Namespace your own classes** (`.nb-*`, `.recipe-*`) to avoid collisions.
356
+ 5. **Match classy's `$avatar-sizes` map** in `_config.scss` — the shared includes
357
+ (nav/account) reference it.
358
+ 6. **Fonts** load via the base layout's `theme.head.content`, NOT a CSS `@import`
359
+ (avoids render-blocking duplicate loads). To use custom fonts, override
360
+ `frontend/core/base.html` (see below).
361
+ 7. **Validate live, then document.** UJM can't run a dev server — build in a
362
+ consumer and screenshot (see [Validating](#validating-a-theme)).
363
+
364
+ ---
365
+
366
+ ## Path A — author a theme INSIDE UJM (shipped to all consumers)
367
+
368
+ Use this for first-party themes like `neobrutalism`.
369
+
370
+ 1. **Copy the template** to your theme id:
371
+ ```
372
+ src/assets/themes/_template/ → src/assets/themes/my-theme/
373
+ ```
374
+ 2. **Edit the SCSS:**
375
+ - `_config.scss` — your tokens + the `@forward '../bootstrap/scss/bootstrap.scss' with (...)` block.
376
+ - `_root.scss` — CSS-variable bridge (light + dark).
377
+ - add `css/base/`, `css/layout/`, `css/components/` partials and `@import` them
378
+ in `_theme.scss`. End `_theme.scss` with `@import '../bootstrap/overrides';`.
379
+ - If you use shared mixins across partials, `@import` a `_mixins.scss` first so
380
+ they're in the global scope the other `@import`s share (neobrutalism does this).
381
+ 3. **Edit `_theme.js`** — `import bootstrap from '__main_assets__/themes/bootstrap/js/index.umd.js'`,
382
+ `window.bootstrap = bootstrap`, run behaviors inside `domReady()`. Keep multiple
383
+ behaviors in small `js/` files.
384
+ 4. **Override only the HTML that must differ** under
385
+ `src/defaults/dist/_layouts/themes/my-theme/...` (and `_includes/...`). The
386
+ classy fallback supplies the rest. The most common override is
387
+ `frontend/core/base.html` to load your fonts (neobrutalism overrides just this
388
+ one file).
389
+ 5. **Add a `README.md`** in the theme folder (customization quickstart).
390
+ 6. `npm run prepare` (copies `src/`→`dist/`) so consumers see it. Then test in a
391
+ consumer (Path: [Validating](#validating-a-theme)).
392
+
393
+ ## Path B — author a theme IN A CONSUMER PROJECT (that project only)
394
+
395
+ Use this when one site needs a bespoke look that shouldn't ship in UJM (e.g. Sweet
396
+ Saucy's `recipe` theme).
397
+
398
+ 1. **Copy the template** from UJM into your project:
399
+ ```
400
+ node_modules/ultimate-jekyll-manager/dist/assets/themes/_template/
401
+ → <project>/src/assets/themes/my-theme/
402
+ ```
403
+ 2. **Select it** in `src/_config.yml`: `theme: { id: "my-theme" }`.
404
+ 3. **Edit the SCSS/JS** exactly as Path A. The `../bootstrap/...` imports still
405
+ resolve — UJM's loadPaths include the package themes root.
406
+ 4. **Override HTML** (only if needed) under
407
+ `<project>/src/_layouts/themes/my-theme/...` and `src/_includes/themes/my-theme/...`.
408
+ The fallback still copies classy's defaults into your theme id at build time.
409
+ 5. **PurgeCSS safelist:** if you add custom classes used only in JS-injected DOM,
410
+ add them to `config/ultimate-jekyll-manager.json` → `sass.purgecss.safelist` so
411
+ production builds don't strip them.
412
+ 6. **`npm start`** and verify.
413
+
414
+ > To merely *recolor* an existing theme (not build a new one), don't create a
415
+ > theme — override its `!default` tokens in `main.scss` before
416
+ > `@use 'ultimate-jekyll-manager'`. See classy's README. To change a theme's
417
+ > component styles or a layout, **shadow it**: create
418
+ > `src/assets/themes/<id>/` (SCSS) or `src/_layouts/themes/<id>/<file>` (HTML) in
419
+ > your project — loadPaths/fallback resolve your copy first.
420
+
421
+ ---
422
+
423
+ ## Validating a theme
424
+
425
+ UJM cannot run a dev server itself (it runs inside a consumer). To verify a theme:
426
+
427
+ 1. In UJM: `npm run prepare` (or `npm start` for watch) to publish `src/`→`dist/`.
428
+ 2. In a consumer wired to local UJM (`"ultimate-jekyll-manager": "file:../ultimate-jekyll-manager"`),
429
+ set `theme.id` and run `npm start`. Capture the BrowserSync URL (e.g.
430
+ `https://localhost:4000`).
431
+ 3. Screenshot the key pages (home, pricing, signin, signup) in **both** light and
432
+ dark (`document.documentElement.setAttribute('data-bs-theme','dark')`) — e.g.
433
+ via the chrome-devtools MCP. Check the console for errors and that your theme's
434
+ "loaded" log appears.
435
+ 4. Iterate on SCSS — the consumer's gulp watcher recompiles when UJM's `dist/`
436
+ changes (if UJM is running `npm start`), or re-run `npm run prepare`.
437
+
438
+ **Do not declare a theme done without looking at rendered screenshots.** Verified
439
+ example: `neobrutalism` built into `ultimate-jekyll-website`, screenshotted across
440
+ all four pages in light + dark.
441
+
442
+ ---
443
+
444
+ ## Reference
445
+
446
+ - Theme tokens example: [src/assets/themes/neobrutalism/_config.scss](../src/assets/themes/neobrutalism/_config.scss)
447
+ - CSS-variable bridge example: [src/assets/themes/neobrutalism/css/base/_root.scss](../src/assets/themes/neobrutalism/css/base/_root.scss)
448
+ - Starter: [src/assets/themes/_template/](../src/assets/themes/_template/)
449
+ - Fallback mechanism: [src/gulp/tasks/distribute.js](../src/gulp/tasks/distribute.js) (`copyFallbackThemeFiles`)
450
+ - Resolution: [src/gulp/tasks/sass.js](../src/gulp/tasks/sass.js), [src/gulp/tasks/webpack.js](../src/gulp/tasks/webpack.js)
451
+ - Related: [docs/assets.md](assets.md) (file layout), [docs/css.md](css.md) (section/theme-adaptive classes), [docs/appearance.md](appearance.md) (dark/light switching)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.4.3",
3
+ "version": "1.6.0",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "projectScripts": {
33
33
  "start": "npx mgr clean && npx mgr setup && bundle exec npm run gulp --",
34
+ "start:test-layers": "UJ_TEST_LAYERS=true npm start",
34
35
  "gulp": "gulp --cwd ./ --gulpfile ./node_modules/ultimate-jekyll-manager/dist/gulp/main.js",
35
36
  "build": "npx mgr clean && npx mgr setup && UJ_BUILD_MODE=true bundle exec npm run gulp -- build",
36
37
  "deploy": "npx mgr deploy",
@@ -1,75 +0,0 @@
1
- # Cross-context Helpers
2
-
3
- `src/utils/mode-helpers.js` exposes four shared helpers mixed into every UJM Manager via `attachTo(Manager)`. Mirrors the same pattern in EM and BXM. Used when behavior should differ by *what kind of process* you're in — short-circuit network probes in tests, suppress dev-only banners in production, etc.
4
-
5
- ## API
6
-
7
- | Method | Returns | Purpose |
8
- |---|---|---|
9
- | `Manager.isTesting()` | `boolean` | True when UJM's test framework is running this process. Set by `npx mgr test` (`UJ_TEST_MODE=true`) and consumer test setups that want the same signal. |
10
- | `Manager.isDevelopment()` | `boolean` | True when running in dev mode (not a production build). Reads `UJ_BUILD_MODE` / `NODE_ENV` / `UJ_IS_SERVER` / `window.Configuration.uj.environment` depending on context. |
11
- | `Manager.isProduction()` | `boolean` | Inverse of `isDevelopment()`. |
12
- | `Manager.getVersion()` | `string \| null` | UJM's version from `<cwd>/package.json#version`. Null if no `package.json` (e.g. shipped browser bundle). |
13
-
14
- All four are available both **statically** on the Manager constructor and on **`Manager.prototype`**, so these all work:
15
-
16
- ```js
17
- const Manager = require('ultimate-jekyll-manager/build');
18
- Manager.isTesting(); // static
19
- new Manager().isTesting(); // instance
20
- ```
21
-
22
- ## When to use
23
-
24
- ```js
25
- // In a build helper that fetches Firebase auth files:
26
- async function fetchFirebaseAuthFiles() {
27
- if (Manager.isTesting()) {
28
- return; // short-circuit — tests provide their own stubs
29
- }
30
- // ...real fetch
31
- }
32
-
33
- // In a gulp task that opens the dev browser:
34
- function maybeOpenBrowser() {
35
- if (Manager.isBuildMode() || Manager.isTesting()) return;
36
- // ...exec `open` etc.
37
- }
38
-
39
- // In a frontend module that logs verbose debug info:
40
- if (manager.isDevelopment()) {
41
- console.log('[dev] webManager loaded with', cfg);
42
- }
43
- ```
44
-
45
- **Don't use these for "should I hit dev or prod backends"** — that's a config concern. Use `Manager.getEnvironment()` (returns `'development'` or `'production'` strings) for that distinction.
46
-
47
- ## How `isDevelopment` is detected
48
-
49
- Order of checks:
50
-
51
- 1. **Build-time Node**: `process.env.UJ_BUILD_MODE === 'true'` → production. `process.env.NODE_ENV === 'development'` → development. `process.env.UJ_IS_SERVER === 'true'` → production.
52
- 2. **Browser**: `window.Configuration.uj.environment` if present (`'development'` or `'production'`).
53
- 3. Default → `false`.
54
-
55
- ## How `isTesting` is detected
56
-
57
- Two checks (either is sufficient):
58
-
59
- 1. **Node**: `process.env.UJ_TEST_MODE === 'true'`. Set automatically by `npx mgr test`.
60
- 2. **Browser**: `globalThis.UJ_TEST_MODE === true`. Set automatically by UJM's `page`-layer harness HTML before any consumer code runs.
61
-
62
- This means consumer code that calls `Manager.isTesting()` from a tab context gets the right answer — Node-only check would always return false in a browser.
63
-
64
- ## Adding new helpers
65
-
66
- If you find yourself writing the same `if (process.env.UJ_FOO === 'true') ...` check more than twice, factor it into `mode-helpers.js`. Things to consider:
67
-
68
- - Does the helper need to work in **all** contexts (Node build-time + browser page + service worker)? If yes, gate every check with `typeof process !== 'undefined'` / `typeof window !== 'undefined'` so the same code runs everywhere.
69
- - Should it be mixed into static AND prototype? Almost always yes — the static form is for build-time CLI usage, the instance form for runtime use.
70
- - Add a test in `src/test/suites/build/mode-helpers.test.js` that toggles the underlying env var and asserts both states.
71
-
72
- ## See also
73
-
74
- - [test-framework.md](test-framework.md) — the test harness that sets `UJ_TEST_MODE`
75
- - [cli.md](cli.md) — full env-var matrix