ultimate-jekyll-manager 1.4.3 → 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 (65) hide show
  1. package/CHANGELOG.md +14 -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 +7 -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
@@ -995,6 +995,11 @@ function getIgnoredPages() {
995
995
  const socials = config?.socials || {};
996
996
  // const downloads = config?.downloads || {};
997
997
 
998
+ // User-configured excludes (translation.exclude). Each entry can be a folder
999
+ // (e.g. "blog" → /blog/**) or a single page path (e.g. "some-page"). We add
1000
+ // each to BOTH files and folders so it matches either way.
1001
+ const userExcludes = config?.translation?.exclude || [];
1002
+
998
1003
  const redirectsDir = path.join('dist', 'redirects');
999
1004
  const redirectFiles = glob(`${redirectsDir}/**/*.html`);
1000
1005
  const redirectPermalinks = [];
@@ -1045,6 +1050,9 @@ function getIgnoredPages() {
1045
1050
 
1046
1051
  // Redirects
1047
1052
  ...redirectPermalinks,
1053
+
1054
+ // User-configured excludes (treated as a page path)
1055
+ ...userExcludes,
1048
1056
  ],
1049
1057
  folders: [
1050
1058
  // Languages
@@ -1055,6 +1063,9 @@ function getIgnoredPages() {
1055
1063
 
1056
1064
  // Firestore auth pages
1057
1065
  '__/auth',
1066
+
1067
+ // User-configured excludes (treated as a folder)
1068
+ ...userExcludes,
1058
1069
  ],
1059
1070
  };
1060
1071
  }
@@ -0,0 +1,97 @@
1
+ // Test-layer manager (dev only)
2
+ // Powers the CONSUMER layer of the /test/libraries/layers asset-cascade panel.
3
+ //
4
+ // The Consumer dots only go green if the consuming project has its own page files
5
+ // at src/assets/{css,js}/pages/test/libraries/layers/index.*. To prove that layer
6
+ // live WITHOUT permanently adding files to the consumer, this is OPT-IN via the
7
+ // UJ_TEST_LAYERS=true env flag:
8
+ //
9
+ // - ALWAYS (every run): remove any previously-generated consumer test-layer files,
10
+ // so they never persist or get committed even if the flag is later unset.
11
+ // - WHEN UJ_TEST_LAYERS=true: (re)generate them into the consumer's src/ at build
12
+ // START — before sass + jekyll run — so the real __project_assets__ / consumer
13
+ // page-CSS path picks them up exactly like any other consumer page file. This is
14
+ // the honest mechanism (no aliases/shims); the files are just real, briefly.
15
+ //
16
+ // Generated files carry a GENERATED marker so the cleaner only ever deletes its own.
17
+ const path = require('path');
18
+ const jetpack = require('fs-jetpack');
19
+
20
+ // Page path the panel lives at → where its consumer-layer assets must sit.
21
+ const REL_CSS = 'src/assets/css/pages/test/libraries/layers/index.scss';
22
+ const REL_JS = 'src/assets/js/pages/test/libraries/layers/index.js';
23
+
24
+ const MARKER = 'GENERATED — UJ_TEST_LAYERS';
25
+
26
+ // NOTE: a consumer page-CSS file shares the SAME output bundle as the framework's
27
+ // base page CSS, so it must @use the base (like every real consumer page file) to
28
+ // COMPOSE with it rather than replace it. The base lives at the same page path under
29
+ // UJM's css/, importable via the SASS loadPaths (which include UJM's dist/assets/css).
30
+ const CSS_CONTENT = `// ${MARKER} (auto-removed on the next build; do not commit)
31
+ // Consumer layer of the /test/libraries/layers panel → turns the "css-consumer" dot green.
32
+ @use 'pages/test/libraries/layers/index' as *;
33
+
34
+ .layer-dot[data-layer="css-consumer"] {
35
+ background: #30a46c; // green
36
+ }
37
+ `;
38
+
39
+ const JS_CONTENT = `// ${MARKER} (auto-removed on the next build; do not commit)
40
+ // Consumer layer of the /test/libraries/layers panel → turns the "js-consumer" dot green.
41
+ export default ({ manager, options }) => {
42
+ const dot = document.querySelector('.layer-dot[data-layer="js-consumer"]');
43
+ if (dot) {
44
+ dot.style.background = '#30a46c';
45
+ }
46
+ console.log('[test-layer] consumer JS ran → js-consumer dot green');
47
+ };
48
+ `;
49
+
50
+ // Delete a generated file only if it still carries our marker (never clobber a real
51
+ // consumer file someone legitimately created at this path).
52
+ function removeIfGenerated(absPath) {
53
+ if (!jetpack.exists(absPath)) {
54
+ return false;
55
+ }
56
+ const contents = jetpack.read(absPath) || '';
57
+ if (contents.includes(MARKER)) {
58
+ jetpack.remove(absPath);
59
+ // Clean up now-empty generated dirs (best effort)
60
+ jetpack.remove(path.dirname(absPath) + '/.keep'); // no-op if absent
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /**
67
+ * Manage the consumer-layer test fixtures (dev only).
68
+ * Always cleans prior generated files; generates fresh ones when UJ_TEST_LAYERS=true.
69
+ * @param {object} Manager - UJM build Manager
70
+ * @param {object} logger - task logger (optional)
71
+ */
72
+ function manageTestLayers(Manager, logger) {
73
+ // Never touch anything in a production build
74
+ if (Manager.isBuildMode()) {
75
+ return;
76
+ }
77
+
78
+ const cssPath = path.resolve(process.cwd(), REL_CSS);
79
+ const jsPath = path.resolve(process.cwd(), REL_JS);
80
+
81
+ // 1) Always clean previously-generated files
82
+ const removed = [removeIfGenerated(cssPath), removeIfGenerated(jsPath)].filter(Boolean).length;
83
+
84
+ // 2) Generate when explicitly requested
85
+ const enabled = process.env.UJ_TEST_LAYERS === 'true';
86
+ if (enabled) {
87
+ jetpack.write(cssPath, CSS_CONTENT);
88
+ jetpack.write(jsPath, JS_CONTENT);
89
+ if (logger) {
90
+ logger.log('UJ_TEST_LAYERS: generated consumer test-layer files (auto-removed next run)');
91
+ }
92
+ } else if (removed > 0 && logger) {
93
+ logger.log(`UJ_TEST_LAYERS: cleaned ${removed} stale generated test-layer file(s)`);
94
+ }
95
+ }
96
+
97
+ module.exports = manageTestLayers;
package/dist/index.js CHANGED
@@ -66,10 +66,17 @@ class Manager {
66
66
  .catch(e => console.error('Failed to load ultimate-jekyll-manager.js:', e))
67
67
  );
68
68
 
69
+ // Theme page module path (resolved via the __theme__ webpack alias)
70
+ const themeModulePathFull = `__theme__/pages/${pageModulePath}`;
71
+
69
72
  console.log(`Page-specific module loading: #main/${pageModulePathFull}`);
73
+ console.log(`Page-specific module loading: #theme/${themeModulePathFull}`);
70
74
  console.log(`Page-specific module loading: #project/${pageModulePathFull}`);
71
75
 
72
- // Load page-specific scripts
76
+ // Load page-specific scripts.
77
+ // Three layers, executed in order: #main (framework default) → #theme
78
+ // (active theme) → #project (consumer). Mirrors the page-CSS cascade.
79
+ // A missing module at any layer is a no-op — no fallback needed.
73
80
  modulePromises.push(
74
81
  // Import the main page-specific script
75
82
  import(`__main_assets__/js/pages/${pageModulePath}`)
@@ -85,11 +92,29 @@ class Manager {
85
92
  })
86
93
  );
87
94
 
95
+ modulePromises.push(
96
+ // Import the active theme's page-specific script.
97
+ // webpackInclude restricts the dynamic-import context to .js files — the
98
+ // theme's pages/ dir also contains page CSS (.scss), which must NOT be
99
+ // pulled into the JS context (webpack would try to parse it as JS).
100
+ import(/* webpackInclude: /\.js$/ */ `__theme__/pages/${pageModulePath}`)
101
+ .then(mod => {
102
+ modules[1] = { tag: 'theme', default: mod?.default };
103
+ })
104
+ .catch(e => {
105
+ if (this.isNotFound(e, pageModulePath)) {
106
+ console.warn(`Page-specific module missing: #theme/${themeModulePathFull}`);
107
+ } else {
108
+ console.error(`Page-specific module error: #theme/${themeModulePathFull}`, e);
109
+ }
110
+ })
111
+ );
112
+
88
113
  modulePromises.push(
89
114
  // Import the project page-specific script
90
115
  import(`__project_assets__/js/pages/${pageModulePath}`)
91
116
  .then(mod => {
92
- modules[1] = { tag: 'project', default: mod?.default };
117
+ modules[2] = { tag: 'project', default: mod?.default };
93
118
  })
94
119
  .catch(e => {
95
120
  if (this.isNotFound(e, pageModulePath)) {
@@ -111,12 +136,13 @@ class Manager {
111
136
  }
112
137
 
113
138
  // Execute the module function
139
+ const modPathLabel = mod.tag === 'theme' ? themeModulePathFull : pageModulePathFull;
114
140
  try {
115
- console.log(`Page-specific module loaded: #${mod.tag}/${pageModulePathFull}`);
141
+ console.log(`Page-specific module loaded: #${mod.tag}/${modPathLabel}`);
116
142
 
117
143
  await mod.default({ manager: this, options });
118
144
  } catch (e) {
119
- console.error(`Page-specific module error: #${mod.tag}/${pageModulePathFull}`, e);
145
+ console.error(`Page-specific module error: #${mod.tag}/${modPathLabel}`, e);
120
146
  break; // Stop execution if any module fails
121
147
  }
122
148
  }
@@ -50,6 +50,10 @@ async function run(options = {}) {
50
50
  console.log('');
51
51
  console.log(chalk.bold(' Ultimate Jekyll Manager Tests'));
52
52
 
53
+ // Run the optional test/_init.js setup() hooks (framework + consumer) ONCE,
54
+ // before any suite. There is no cleanup hook — tests clean up after themselves.
55
+ await runInitSetups();
56
+
53
57
  const results = { passed: 0, failed: 0, skipped: 0, tests: [] };
54
58
 
55
59
  if (sources.framework.length > 0) {
@@ -399,4 +403,62 @@ function relativizePath(file, source) {
399
403
  return path.relative(path.join(process.cwd(), 'test'), file);
400
404
  }
401
405
 
406
+ // ---------------------------------------------------------------------------
407
+ // test/_init.js — pre-test lifecycle hook (setup only)
408
+ //
409
+ // Mirrors the backend framework's hook so all four frameworks share one shape.
410
+ // A project may add `<cwd>/test/_init.js` exporting a FUNCTION —
411
+ // `module.exports = (ctx) => ({ setup })` — called with `{ projectRoot }` and
412
+ // returning an object with an async `setup({ projectRoot })` that runs ONCE
413
+ // before any suite (e.g. to scaffold a fixture file the boot layer needs).
414
+ // There is no `cleanup` hook: tests clean up after themselves. Unlike the
415
+ // backend framework, there is no `accounts` field here — these frameworks have
416
+ // no auth/user system.
417
+ // ---------------------------------------------------------------------------
418
+
419
+ function loadInit(testDir, label) {
420
+ const initPath = path.join(testDir, '_init.js');
421
+
422
+ if (!jetpack.exists(initPath)) {
423
+ return {};
424
+ }
425
+
426
+ try {
427
+ const fn = require(initPath);
428
+
429
+ if (typeof fn !== 'function') {
430
+ console.log(chalk.red(` ✗ ${label} test/_init.js must export a function: module.exports = (ctx) => ({ ... })`));
431
+ return {};
432
+ }
433
+
434
+ const mod = fn({ projectRoot: process.cwd() });
435
+ return mod && typeof mod === 'object' ? mod : {};
436
+ } catch (e) {
437
+ console.log(chalk.red(` ✗ Failed to load ${label} test/_init.js: ${e.message}`));
438
+ return {};
439
+ }
440
+ }
441
+
442
+ async function runInitSetups() {
443
+ const frameworkTestsDir = path.resolve(__dirname, '../../test');
444
+ const projectTestsDir = path.join(process.cwd(), 'test');
445
+
446
+ const hooks = [
447
+ loadInit(frameworkTestsDir, 'framework'),
448
+ loadInit(projectTestsDir, 'project'),
449
+ ];
450
+
451
+ const setups = hooks.filter((h) => typeof h.setup === 'function').map((h) => h.setup);
452
+
453
+ for (const setup of setups) {
454
+ process.stdout.write(chalk.gray(' Running test/_init.js setup... '));
455
+ try {
456
+ await setup({ projectRoot: process.cwd() });
457
+ console.log(chalk.green('✓'));
458
+ } catch (e) {
459
+ console.log(chalk.red(`✗ (${e.message})`));
460
+ }
461
+ }
462
+ }
463
+
402
464
  module.exports = { run, SkipError };
@@ -86,18 +86,25 @@ module.exports = {
86
86
  },
87
87
  },
88
88
  {
89
- name: 'getEnvironment maps server flag to environment string',
89
+ name: 'getEnvironment maps to development/testing/production',
90
90
  run: async (ctx) => {
91
91
  const Manager = require('../../../build.js');
92
- const original = process.env.UJ_IS_SERVER;
92
+ const origServer = process.env.UJ_IS_SERVER;
93
+ const origTest = process.env.UJ_TEST_MODE;
93
94
  try {
95
+ // Testing wins over everything.
96
+ process.env.UJ_TEST_MODE = 'true';
97
+ process.env.UJ_IS_SERVER = 'true';
98
+ ctx.expect(Manager.getEnvironment()).toBe('testing');
99
+ // With testing cleared, server flag → production; absent → development.
100
+ delete process.env.UJ_TEST_MODE;
94
101
  process.env.UJ_IS_SERVER = 'true';
95
102
  ctx.expect(Manager.getEnvironment()).toBe('production');
96
103
  delete process.env.UJ_IS_SERVER;
97
104
  ctx.expect(Manager.getEnvironment()).toBe('development');
98
105
  } finally {
99
- if (original === undefined) delete process.env.UJ_IS_SERVER;
100
- else process.env.UJ_IS_SERVER = original;
106
+ if (origServer === undefined) delete process.env.UJ_IS_SERVER; else process.env.UJ_IS_SERVER = origServer;
107
+ if (origTest === undefined) delete process.env.UJ_TEST_MODE; else process.env.UJ_TEST_MODE = origTest;
101
108
  }
102
109
  },
103
110
  },
@@ -11,7 +11,7 @@ module.exports = {
11
11
  name: 'helpers attach to Manager statically AND on prototype',
12
12
  run: async (ctx) => {
13
13
  const Manager = require('../../../build.js');
14
- for (const name of ['isTesting', 'isDevelopment', 'isProduction', 'getVersion']) {
14
+ for (const name of ['getEnvironment', 'isTesting', 'isDevelopment', 'isProduction', 'getVersion']) {
15
15
  ctx.expect(typeof Manager[name]).toBe('function');
16
16
  ctx.expect(typeof Manager.prototype[name]).toBe('function');
17
17
  }
@@ -35,17 +35,69 @@ module.exports = {
35
35
  },
36
36
  },
37
37
  {
38
- name: 'isDevelopment false when UJ_BUILD_MODE=true',
38
+ name: 'isDevelopment false / isProduction true when UJ_BUILD_MODE=true (and not testing)',
39
39
  run: async (ctx) => {
40
40
  const Manager = require('../../../build.js');
41
41
  const original = process.env.UJ_BUILD_MODE;
42
+ const origTest = process.env.UJ_TEST_MODE;
42
43
  try {
44
+ delete process.env.UJ_TEST_MODE; // isolate the build-mode branch from testing precedence
43
45
  process.env.UJ_BUILD_MODE = 'true';
44
46
  ctx.expect(Manager.isDevelopment()).toBe(false);
45
47
  ctx.expect(Manager.isProduction()).toBe(true);
46
48
  } finally {
47
49
  if (original === undefined) delete process.env.UJ_BUILD_MODE;
48
50
  else process.env.UJ_BUILD_MODE = original;
51
+ if (origTest !== undefined) process.env.UJ_TEST_MODE = origTest;
52
+ }
53
+ },
54
+ },
55
+ {
56
+ name: 'environments are mutually exclusive — testing wins under UJ_TEST_MODE',
57
+ run: async (ctx) => {
58
+ const Manager = require('../../../build.js');
59
+ // These tests run under UJ_TEST_MODE=true → testing wins; dev and prod are false.
60
+ ctx.expect(Manager.isTesting()).toBe(true);
61
+ ctx.expect(Manager.isDevelopment()).toBe(false);
62
+ ctx.expect(Manager.isProduction()).toBe(false);
63
+ },
64
+ },
65
+ {
66
+ // The core invariant of the SSOT refactor: is*() DERIVE from getEnvironment(), so they
67
+ // can NEVER disagree with it, and exactly one is always true. (In build-time Node `window`
68
+ // is undefined, so getEnvironment() resolves via the env-var fallback.)
69
+ name: 'invariant: is*() exactly matches getEnvironment() + mutually exclusive (every scenario)',
70
+ run: async (ctx) => {
71
+ const Manager = require('../../../build.js');
72
+ const prevTest = process.env.UJ_TEST_MODE;
73
+ const prevBuild = process.env.UJ_BUILD_MODE;
74
+ const prevServer = process.env.UJ_IS_SERVER;
75
+ const prevNode = process.env.NODE_ENV;
76
+ const scenarios = [
77
+ { env: { UJ_TEST_MODE: 'true', UJ_BUILD_MODE: 'true' }, expect: 'testing' },
78
+ { env: { UJ_BUILD_MODE: 'true' }, expect: 'production' },
79
+ { env: { UJ_IS_SERVER: 'true' }, expect: 'production' },
80
+ { env: { NODE_ENV: 'development' }, expect: 'development' },
81
+ { env: {}, expect: 'development' }, // UJM defaults dev (signal always baked in)
82
+ ];
83
+ try {
84
+ for (const s of scenarios) {
85
+ delete process.env.UJ_TEST_MODE; delete process.env.UJ_BUILD_MODE;
86
+ delete process.env.UJ_IS_SERVER; delete process.env.NODE_ENV;
87
+ for (const k of Object.keys(s.env)) process.env[k] = s.env[k];
88
+ const e = Manager.getEnvironment();
89
+ ctx.expect(e).toBe(s.expect);
90
+ ctx.expect(Manager.isDevelopment()).toBe(e === 'development');
91
+ ctx.expect(Manager.isTesting()).toBe(e === 'testing');
92
+ ctx.expect(Manager.isProduction()).toBe(e === 'production');
93
+ const trueCount = [Manager.isDevelopment(), Manager.isTesting(), Manager.isProduction()].filter(Boolean).length;
94
+ ctx.expect(trueCount).toBe(1);
95
+ }
96
+ } finally {
97
+ if (prevTest === undefined) delete process.env.UJ_TEST_MODE; else process.env.UJ_TEST_MODE = prevTest;
98
+ if (prevBuild === undefined) delete process.env.UJ_BUILD_MODE; else process.env.UJ_BUILD_MODE = prevBuild;
99
+ if (prevServer === undefined) delete process.env.UJ_IS_SERVER; else process.env.UJ_IS_SERVER = prevServer;
100
+ if (prevNode === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = prevNode;
49
101
  }
50
102
  },
51
103
  },
@@ -2,51 +2,72 @@
2
2
  // (build-time `src/build.js`, frontend ES module `src/index.js`, service worker
3
3
  // `src/service-worker.js`).
4
4
  //
5
- // Three orthogonal concepts:
6
- // isDevelopment() true when running in dev mode (jekyll dev server, not a
7
- // production build). Detected via NODE_ENV / UJ_BUILD_MODE /
8
- // site config.
9
- // isProduction() — inverse. Running a production-built `_site/`.
10
- // isTesting() — true when UJM's test framework is running this process. Set
11
- // by UJM's test runners (UJ_TEST_MODE=true) and consumer test
12
- // setups that want the same signal.
5
+ // `getEnvironment()` is the SINGLE SOURCE OF TRUTH: it is the ONLY function that reads the
6
+ // raw signals (UJ_TEST_MODE / window.Configuration.uj.environment / UJ_BUILD_MODE /
7
+ // UJ_IS_SERVER / NODE_ENV) and resolves them to exactly ONE of three mutually-exclusive
8
+ // values. The three is*() checks DERIVE from it — they never read raw signals themselves,
9
+ // so they can never disagree with getEnvironment().
13
10
  //
14
- // Use these whenever behavior should differ by *what kind of process* you're in —
15
- // shorter timeouts in tests, prompts suppressed in tests, dev-only banners.
16
- // Don't use them for "should we hit dev or prod backends" — that's a config
17
- // concern; use `getEnvironment()` for that (in build.js).
11
+ // isDevelopment() `getEnvironment() === 'development'`: running in dev mode (jekyll dev
12
+ // server, not a production build), and NOT testing.
13
+ // isTesting() — `getEnvironment() === 'testing'`: UJM's test framework is running this
14
+ // process (UJ_TEST_MODE=true). TAKES PRECEDENCE a test run is not dev.
15
+ // isProduction() — `getEnvironment() === 'production'`: running a production-built `_site/`,
16
+ // and NOT testing. A real positive check — NOT `!isDevelopment()`.
18
17
  //
19
- // Context caveat: in build-time Node (gulp / CLI), `window` is undefined. We
20
- // detect via `typeof window` so the same code works in every context. In test
21
- // mode the browser-side check is short-circuited via UJ_TEST_MODE / global so
22
- // `isTesting()` returns a stable value regardless of which test layer is running.
18
+ // To gate "anything non-production" use `!isProduction()` or `isDevelopment() ||
19
+ // isTesting()` intentionally never assume two values.
20
+ //
21
+ // Context caveat: in build-time Node (gulp / CLI), `window` is undefined. getEnvironment()
22
+ // detects via `typeof window` so the same code works in every context. Browser detection
23
+ // reads `window.Configuration.uj.environment` (baked into the page at build time).
23
24
 
24
- function isDevelopment() {
25
- // Build-time Node fallback.
25
+ // getEnvironment() — the SINGLE SOURCE OF TRUTH. Reads every raw signal and resolves to
26
+ // exactly ONE of 'development' | 'testing' | 'production' (mutually exclusive; testing wins).
27
+ // Precedence: testing → production → development.
28
+ function getEnvironment() {
29
+ // 1. Testing wins — set by UJM's test runners / harness, or a testing-baked build.
30
+ // Works in Node (process.env), browser (globalThis set before consumer JS), and
31
+ // config-baked builds (window.Configuration.uj.environment === 'testing').
32
+ if (typeof process !== 'undefined' && process.env && process.env.UJ_TEST_MODE === 'true') return 'testing';
33
+ if (typeof globalThis !== 'undefined' && globalThis.UJ_TEST_MODE === true) return 'testing';
34
+ if (typeof window !== 'undefined' && window.Configuration && window.Configuration.uj
35
+ && window.Configuration.uj.environment === 'testing') return 'testing';
36
+
37
+ // 2. Build-time Node signals (a production build or the running dev server).
26
38
  if (typeof process !== 'undefined' && process.env) {
27
- if (process.env.UJ_BUILD_MODE === 'true') return false;
28
- if (process.env.NODE_ENV === 'development') return true;
29
- if (process.env.UJ_IS_SERVER === 'true') return false;
39
+ if (process.env.UJ_BUILD_MODE === 'true') return 'production';
40
+ if (process.env.UJ_IS_SERVER === 'true') return 'production';
41
+ if (process.env.NODE_ENV === 'development') return 'development';
30
42
  }
31
- // Browser-side: look at window.Configuration if present.
43
+
44
+ // 3. Browser-side: the environment baked into the page at build time.
32
45
  if (typeof window !== 'undefined' && window.Configuration && window.Configuration.uj) {
33
- if (window.Configuration.uj.environment === 'development') return true;
34
- if (window.Configuration.uj.environment === 'production') return false;
46
+ if (window.Configuration.uj.environment === 'development') return 'development';
47
+ if (window.Configuration.uj.environment === 'production') return 'production';
35
48
  }
36
- return false;
49
+
50
+ // 4. Default: development. UJM's deployed artifacts ALWAYS carry their signal — the
51
+ // browser has `window.Configuration.uj.environment` baked in, and build-time Node
52
+ // always sets UJ_BUILD_MODE / UJ_IS_SERVER. So reaching here means a bare tooling /
53
+ // local-script context, where development is the sensible answer. (Contrast BEM/EM,
54
+ // whose deployed RUNTIME can legitimately lack a signal, so they default to production.)
55
+ return 'development';
56
+ }
57
+
58
+ // The three checks DERIVE from getEnvironment() — they never read raw signals, so they can
59
+ // never disagree with it. isDevelopment() is NOT true in testing; isProduction() is a real
60
+ // positive check (never `!isDevelopment()`).
61
+ function isDevelopment() {
62
+ return getEnvironment() === 'development';
37
63
  }
38
64
 
39
65
  function isProduction() {
40
- return !this.isDevelopment();
66
+ return getEnvironment() === 'production';
41
67
  }
42
68
 
43
69
  function isTesting() {
44
- // Canonical signal — set by UJM's test runners and consumer test setups alike.
45
- // Works in Node (process.env) AND in browser contexts (the harness preload sets
46
- // globalThis.UJ_TEST_MODE before any consumer code runs).
47
- if (typeof process !== 'undefined' && process.env && process.env.UJ_TEST_MODE === 'true') return true;
48
- if (typeof globalThis !== 'undefined' && globalThis.UJ_TEST_MODE === true) return true;
49
- return false;
70
+ return getEnvironment() === 'testing';
50
71
  }
51
72
 
52
73
  // `getVersion()` — returns UJM's own version string.
@@ -64,19 +85,23 @@ function getVersion() {
64
85
 
65
86
  // Mix the helpers into a Manager constructor's prototype + the constructor itself
66
87
  // (so `Manager.isTesting()` works statically too, matching BEM/EM/BXM pattern).
88
+ // getEnvironment() is the SSOT and is attached here too — build.js no longer defines it.
67
89
  function attachTo(Manager) {
68
- Manager.prototype.isDevelopment = isDevelopment;
69
- Manager.prototype.isProduction = isProduction;
70
- Manager.prototype.isTesting = isTesting;
71
- Manager.prototype.getVersion = getVersion;
72
- Manager.isDevelopment = isDevelopment;
73
- Manager.isProduction = isProduction;
74
- Manager.isTesting = isTesting;
75
- Manager.getVersion = getVersion;
90
+ Manager.prototype.getEnvironment = getEnvironment;
91
+ Manager.prototype.isDevelopment = isDevelopment;
92
+ Manager.prototype.isProduction = isProduction;
93
+ Manager.prototype.isTesting = isTesting;
94
+ Manager.prototype.getVersion = getVersion;
95
+ Manager.getEnvironment = getEnvironment;
96
+ Manager.isDevelopment = isDevelopment;
97
+ Manager.isProduction = isProduction;
98
+ Manager.isTesting = isTesting;
99
+ Manager.getVersion = getVersion;
76
100
  }
77
101
 
78
102
  module.exports = {
79
103
  attachTo,
104
+ getEnvironment,
80
105
  isDevelopment,
81
106
  isProduction,
82
107
  isTesting,
package/docs/assets.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  This document covers UJM's asset layout, consuming-project overrides, the JSON-driven section configuration system (nav, footer, account dropdown), frontmatter-driven page customization, webpack aliases, and the page module pattern.
4
4
 
5
+ > **Authoring or selecting a theme?** The `themes/[theme-id]/` layout/include
6
+ > paths and the `__theme__` SCSS/JS resolution referenced throughout this doc are
7
+ > explained in full — including the classy layout fallback and how to build a new
8
+ > theme — in [docs/themes.md](themes.md).
9
+
5
10
  ## Ultimate Jekyll Manager Files (THIS project)
6
11
 
7
12
  **CSS:**
@@ -269,7 +274,7 @@ import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js
269
274
  import(`__project_assets__/js/pages/${pageModulePath}`)
270
275
  ```
271
276
 
272
- **How they work together:** `src/index.js` loads page modules from both aliases first from `__main_assets__` (UJM defaults), then from `__project_assets__` (project overrides/extensions). If a project module doesn't exist, it gracefully skips. This enables a layered system where UJM provides defaults and consuming projects can extend or override page behavior.
277
+ **How they work together:** `src/index.js` loads page modules from **three** layers, executed in order: `__main_assets__` (UJM default) `__theme__/pages/...` (active theme) → `__project_assets__` (consumer override/extension). A missing module at any layer gracefully skips. This layered system mirrors the page-CSS cascade — UJM provides defaults, the theme customizes, and the consuming project always gets the final say. See [docs/themes.md → Page-specific JS](themes.md#5-page-specific-js-theme-aware-additive--mirrors-page-css).
273
278
 
274
279
  **When to use which:**
275
280
  - **`__main_assets__`** — When importing UJM-provided libraries, core modules, or referencing UJM's built-in page scripts
@@ -0,0 +1,85 @@
1
+ # Environment Detection
2
+
3
+ `getEnvironment()` returns exactly ONE of three mutually-exclusive, exhaustive values:
4
+
5
+ ```javascript
6
+ Manager.getEnvironment() // 'development' | 'testing' | 'production'
7
+
8
+ Manager.isDevelopment() // true ONLY in development
9
+ Manager.isTesting() // true ONLY in testing
10
+ Manager.isProduction() // true ONLY in production
11
+ ```
12
+
13
+ **The Manager is the single source of truth.** `getEnvironment()` is the ONLY function that reads the raw signals (`UJ_TEST_MODE` / `window.Configuration.uj.environment` / `UJ_BUILD_MODE` / `UJ_IS_SERVER` / `NODE_ENV`). The three `is*()` checks **derive** from it live on every call — they never read raw signals themselves, so they can never disagree with `getEnvironment()`.
14
+
15
+ **One implementation, mixed into every Manager.** UJM mixes the helpers into the build-time Manager and the frontend Manager via `attachTo(Manager)` from [src/utils/mode-helpers.js](../src/utils/mode-helpers.js), available as both prototype methods (`manager.isTesting()`) and statics (`Manager.isTesting()`).
16
+
17
+ ```javascript
18
+ manager.getEnvironment() // same answer build-time and in the browser
19
+ Manager.isTesting() // static form, for build-time scripts
20
+ ```
21
+
22
+ **Resolution order:** testing wins first, then production, else development. The three checks are mutually exclusive — exactly one is true. `isDevelopment()` is **false** during testing, and `isProduction()` is a real positive check (it is NOT `!isDevelopment()`).
23
+
24
+ ## Available helpers
25
+
26
+ | Helper | Returns |
27
+ |---|---|
28
+ | `getEnvironment()` | `'development' \| 'testing' \| 'production'` — the SSOT resolver; the only reader of raw signals. |
29
+ | `isDevelopment()` | `true` ONLY in development (jekyll dev server, not a production build), and NOT testing. Derives from `getEnvironment()`. |
30
+ | `isTesting()` | `true` ONLY in testing (`UJ_TEST_MODE === 'true'`). **Takes precedence** — a test run is not development. |
31
+ | `isProduction()` | `true` ONLY in production (a production-built `_site/`). A **real positive check** — NOT `!isDevelopment()`. |
32
+
33
+ ## Gating side effects — use the INTENTIONAL check
34
+
35
+ Because there are three environments, never gate a side effect on a two-value assumption. State what you mean:
36
+
37
+ ```javascript
38
+ // Production-only (skip real telemetry / production behavior in dev AND testing):
39
+ if (isProduction()) { /* do the real thing */ }
40
+ if (!isProduction()) { /* skip / use the safe local behavior */ }
41
+
42
+ // Local-or-test (anything that should run in BOTH dev and testing):
43
+ if (isDevelopment() || isTesting()) { /* localhost URL, observable redirect delay, dev banners */ }
44
+ ```
45
+
46
+ **Avoid** `if (!isDevelopment())` or `if (env !== 'development')` to gate production behavior — those wrongly include `testing` as production and leak real side effects during test runs. This is the bug class that motivated the 3-value model.
47
+
48
+ ## URL helpers
49
+
50
+ UJM does **not** own backend URL helpers. Frontend page code resolves backend URLs through the `web-manager` runtime singleton:
51
+
52
+ ```javascript
53
+ webManager.getApiUrl() // the brand's API URL — from web-manager, not UJM
54
+ ```
55
+
56
+ `web-manager`'s `getApiUrl()` follows the same convention as the sister frameworks — local in development/testing, production otherwise — so the rule "call the getter, never hardcode" still applies; the implementation just lives in `web-manager`. UJM's own URL helper is build-time only: `Manager.getWorkingUrl()` returns the BrowserSync dev-server URL (or the configured project URL) for the running dev server.
57
+
58
+ ## Where they live
59
+
60
+ Source: [src/utils/mode-helpers.js](../src/utils/mode-helpers.js) for `getEnvironment()` + `is*()` + `getVersion()`. The module exposes the functions plus an `attachTo(Manager)` mixin. Attached at the bottom of the build-time Manager [src/build.js](../src/build.js) and the frontend Manager [src/index.js](../src/index.js) — so build-time scripts and browser code resolve the environment identically.
61
+
62
+ ## How detection works
63
+
64
+ `getEnvironment()` resolves in this precedence order:
65
+
66
+ 1. **Testing** — `process.env.UJ_TEST_MODE === 'true'`, `globalThis.UJ_TEST_MODE === true`, or a build baked with `window.Configuration.uj.environment === 'testing'` (set by the harness before any consumer JS runs). A test run is a test run regardless of any other signal.
67
+ 2. **Build-time signals** — `UJ_BUILD_MODE === 'true'` → production; `UJ_IS_SERVER === 'true'` → production; `NODE_ENV === 'development'` → development.
68
+ 3. **Browser signal** — `window.Configuration.uj.environment` (`'development'` / `'production'`), baked into the page at build time.
69
+ 4. **Default** — development. UJM's deployed artifacts always carry their signal (the browser has `window.Configuration.uj.environment` baked in; build-time Node always sets `UJ_BUILD_MODE`/`UJ_IS_SERVER`), so reaching here means a bare tooling / local-script context where development is correct. (Contrast BEM/EM, whose deployed *runtime* can legitimately lack a signal, so they default to **production**.)
70
+
71
+ ## Adding a new helper
72
+
73
+ Write the function in [src/utils/mode-helpers.js](../src/utils/mode-helpers.js) (or a new `src/utils/<topic>-helpers.js` module), expose it from `attachTo(Manager)`, and ensure both the build-time and frontend Managers call `attachTo`. For anything environment-derived, derive from `getEnvironment()` rather than reading `process.env` / `window.Configuration` directly, so there is one source of truth and no chance of drift.
74
+
75
+ ## Why this matters
76
+
77
+ **One signal, used everywhere.** The test runner sets `UJ_TEST_MODE=true`; every piece of code that calls `isTesting()` (framework or consumer) then sees `true` — no need to invent a per-module env var.
78
+
79
+ **Sub-modules check the same signal.** When framework code (a network probe, a prompt) needs to skip side effects in tests, it checks `isTesting()` — the same answer the consumer's own code gets. No drift.
80
+
81
+ **`is*()` can never disagree with `getEnvironment()`.** Because the checks derive from the single resolver instead of reading raw signals (`window.Configuration` vs `UJ_BUILD_MODE`), there is exactly one definition of "what environment is this," and a wrong-but-confident gate is structurally impossible.
82
+
83
+ ## See also
84
+
85
+ - [test-framework.md](test-framework.md) — `UJ_TEST_MODE` is set automatically by the test runners; `--integration` gates real external APIs.