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
@@ -0,0 +1,57 @@
1
+ ---
2
+ ### ALL PAGES ###
3
+ layout: themes/[ site.theme.id ]/frontend/core/minimal
4
+ permalink: /test/libraries/layers
5
+
6
+ ### REGULAR PAGES ###
7
+ sitemap:
8
+ include: false
9
+ meta:
10
+ title: "Asset layers"
11
+ description: "Live status of the Global → Theme → Consumer page-asset cascade (CSS + JS)."
12
+ breadcrumb: "Asset layers"
13
+ index: false
14
+ ---
15
+
16
+ <!--
17
+ ASSET-LAYER TEST PANEL
18
+ Shows the three-layer page-asset cascade (CSS and JS), loaded in this order:
19
+ 1. Global — the framework's own page-specific file
20
+ 2. Theme — the active theme's page-specific file
21
+ 3. Consumer — the consuming project's page-specific file
22
+ Each dot starts RED. A layer turns ITS OWN dot green when it loads (CSS via a
23
+ selector, JS by setting the dot's color). A RED dot = that layer has no file
24
+ for this page (the normal state for layers nobody customized).
25
+
26
+ The Consumer layer only loads if the consuming project has its own files at
27
+ src/assets/{css,js}/pages/test/libraries/layers/index.*. To prove it live
28
+ without committing anything, run with the UJ_TEST_LAYERS flag (see docs/themes.md
29
+ → "Asset-layer test panel"), which generates those files at build start and
30
+ auto-removes them on the next run.
31
+ -->
32
+ <section class="py-5">
33
+ <div class="container" style="max-width: 720px;">
34
+ <h1 class="mb-2">Asset layers</h1>
35
+ <p class="text-muted mb-4">
36
+ Each <strong>page-specific</strong> asset loads in three layers, in order:
37
+ <strong>Global → Theme → Consumer</strong> — all three are the same
38
+ <code>pages/&lt;path&gt;</code> file, just from different sources. Each layer turns its
39
+ own dot <strong>green</strong> when it loads; a <strong>red</strong> dot means that
40
+ layer has no file for this page (the normal state for layers nobody customized).
41
+ </p>
42
+
43
+ <h2 class="h5 mb-3">Page-specific CSS</h2>
44
+ <ul class="layer-list list-unstyled mb-4">
45
+ <li class="layer-row"><span class="layer-dot" data-layer="css-global"></span> Global <span class="text-muted">(framework default)</span></li>
46
+ <li class="layer-row"><span class="layer-dot" data-layer="css-theme"></span> Theme <span class="text-muted">(active theme)</span></li>
47
+ <li class="layer-row"><span class="layer-dot" data-layer="css-consumer"></span> Consumer <span class="text-muted">(your project)</span></li>
48
+ </ul>
49
+
50
+ <h2 class="h5 mb-3">Page-specific JS</h2>
51
+ <ul class="layer-list list-unstyled mb-0">
52
+ <li class="layer-row"><span class="layer-dot" data-layer="js-global"></span> Global <span class="text-muted">(framework default)</span></li>
53
+ <li class="layer-row"><span class="layer-dot" data-layer="js-theme"></span> Theme <span class="text-muted">(active theme)</span></li>
54
+ <li class="layer-row"><span class="layer-dot" data-layer="js-consumer"></span> Consumer <span class="text-muted">(your project)</span></li>
55
+ </ul>
56
+ </div>
57
+ </section>
@@ -33,7 +33,7 @@ meta:
33
33
 
34
34
  <!-- First visible image (should load immediately) -->
35
35
  <section>
36
- <h2 class="h3 mb-3">1. Above the Fold Image (Lazy Loading Starts Immediately)</h2>
36
+ <h2 class="h3 mb-3">1. Above the fold image (lazy loading starts immediately)</h2>
37
37
  <p>This image is visible on page load, so it should start loading immediately:</p>
38
38
  <img data-lazy="@src https://placehold.co/800x400?text=Immediate+Load"
39
39
  class="card-img-top lazy"
@@ -50,7 +50,7 @@ meta:
50
50
 
51
51
  <!-- Lazy loaded images with data-src -->
52
52
  <section>
53
- <h2 class="h3 mb-3">2. Standard Lazy Loading (data-src)</h2>
53
+ <h2 class="h3 mb-3">2. Standard lazy loading (data-src)</h2>
54
54
  <p>These images use <code>data-src</code> attribute and will load when scrolled into view:</p>
55
55
 
56
56
  <div class="row g-4 mb-4">
@@ -153,7 +153,7 @@ meta:
153
153
 
154
154
  <!-- Lazy Classes -->
155
155
  <section>
156
- <h2 class="h3 mb-3">6. Lazy Loaded Classes</h2>
156
+ <h2 class="h3 mb-3">6. Lazy loaded classes</h2>
157
157
  <p>These elements get classes added when they come into view:</p>
158
158
 
159
159
  <div class="row g-4">
@@ -194,7 +194,7 @@ meta:
194
194
 
195
195
  <!-- Iframes -->
196
196
  <section>
197
- <h2 class="h3 mb-3">7. Lazy Loaded Iframes</h2>
197
+ <h2 class="h3 mb-3">7. Lazy loaded iframes</h2>
198
198
  <p>YouTube videos and other iframes that load on scroll:</p>
199
199
 
200
200
  <div class="ratio ratio-16x9 mb-4">
@@ -242,7 +242,7 @@ meta:
242
242
 
243
243
  <!-- Video -->
244
244
  <section>
245
- <h2 class="h3 mb-3">9. Lazy Loaded Video</h2>
245
+ <h2 class="h3 mb-3">9. Lazy loaded video</h2>
246
246
  <p>HTML5 video that loads when scrolled into view:</p>
247
247
 
248
248
  <video data-lazy="@src https://www.w3schools.com/html/mov_bbb.mp4"
@@ -257,7 +257,7 @@ meta:
257
257
 
258
258
  <!-- Slow Loading Test with Picsum -->
259
259
  <section>
260
- <h2 class="h3 mb-3">10. Slow Loading Test (Loading Animation Demo)</h2>
260
+ <h2 class="h3 mb-3">10. Slow loading test (loading animation demo)</h2>
261
261
  <p>These images from picsum.photos load slowly, so you can see the loading animation:</p>
262
262
 
263
263
  <div class="row g-4">
@@ -325,7 +325,7 @@ meta:
325
325
 
326
326
  <!-- Error handling test -->
327
327
  <section>
328
- <h2 class="h3 mb-3">12. Error Handling Test</h2>
328
+ <h2 class="h3 mb-3">12. Error handling test</h2>
329
329
  <p>These images have invalid URLs to test error handling:</p>
330
330
 
331
331
  <div class="row g-4">
@@ -373,7 +373,7 @@ meta:
373
373
 
374
374
  <!-- Dynamic content test -->
375
375
  <section>
376
- <h2 class="h3 mb-3">14. Dynamic Content Test</h2>
376
+ <h2 class="h3 mb-3">14. Dynamic content test</h2>
377
377
  <p>Click the button below to dynamically add new lazy-loaded images to test MutationObserver:</p>
378
378
 
379
379
  <button id="add-dynamic-images" class="btn btn-primary mb-4">Add dynamic images</button>
@@ -54,7 +54,7 @@ web_manager:
54
54
  {%- endif -%}
55
55
 
56
56
  {% assign url_parts = page.url | split: '/' %}
57
- {% assign section_name = "Root Pages" %}
57
+ {% assign section_name = "Root pages" %}
58
58
 
59
59
  {% if url_parts.size > 2 %}
60
60
  {% assign section_name = url_parts[1] | replace: '-', ' ' | replace: '_', ' ' | capitalize %}
@@ -104,7 +104,7 @@ web_manager:
104
104
  {%- endif -%}
105
105
 
106
106
  {% assign url_parts = page.url | split: '/' %}
107
- {% assign page_section = "Root Pages" %}
107
+ {% assign page_section = "Root pages" %}
108
108
 
109
109
  {% if url_parts.size > 2 %}
110
110
  {% assign page_section = url_parts[1] | replace: '-', ' ' | replace: '_', ' ' | capitalize %}
@@ -277,6 +277,8 @@ manifest:
277
277
  translation:
278
278
  enabled: false
279
279
  default: "en"
280
+ exclude:
281
+ - "blog"
280
282
  languages:
281
283
  - "zh"
282
284
  - "es"
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Test lifecycle hook for this project. Runs once before any suite (not a test itself).
3
+ * See ultimate-jekyll-manager/docs/test-framework.md → "test/_init.js".
4
+ */
5
+
6
+ module.exports = ({ projectRoot }) => ({
7
+ // Seed any fixture a suite needs before it runs.
8
+ async setup() {
9
+ },
10
+ });
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const { minimatch } = require('minimatch');
9
9
  const { template } = require('node-powertools');
10
10
  const createTemplateTransform = require('./utils/template-transform');
11
+ const manageTestLayers = require('./utils/manage-test-layers');
11
12
  const argv = require('yargs')(process.argv.slice(2)).parseSync();
12
13
  const JSON5 = require('json5');
13
14
 
@@ -391,6 +392,13 @@ function defaults(complete, changedFile) {
391
392
  logger.log('Starting...');
392
393
  Manager.logMemory(logger, 'Start');
393
394
 
395
+ // Test-layer fixtures (dev only): clean any prior generated files, and generate
396
+ // fresh ones into the consumer src when UJ_TEST_LAYERS=true — at build START so
397
+ // sass + jekyll pick them up. Only on a full run, not per-changed-file in watch.
398
+ if (!changedFile) {
399
+ manageTestLayers(Manager, logger);
400
+ }
401
+
394
402
  // Use changedFile if provided, otherwise use all inputs
395
403
  const filesToProcess = changedFile ? [changedFile] : input;
396
404
  logger.log('input', filesToProcess)
@@ -6,6 +6,7 @@ const glob = require('glob').globSync;
6
6
  const responsive = require('gulp-responsive-modern');
7
7
  const sharp = require('sharp');
8
8
  const path = require('path');
9
+ const { Transform } = require('stream');
9
10
  const jetpack = require('fs-jetpack');
10
11
  const GitHubCache = require('./utils/github-cache');
11
12
 
@@ -188,6 +189,7 @@ async function imagemin(complete) {
188
189
  // above (so `npm start` never blocks on this), letting BrowserSync reload as images land later.
189
190
  await new Promise((resolve, reject) => {
190
191
  src(filesToProcess, { base: 'src/assets/images' })
192
+ .pipe(lowercaseExtTransform())
191
193
  .pipe(responsive({
192
194
  [`**/${RESPONSIVE_GLOB}`]: responsiveConfigs
193
195
  }, {
@@ -358,6 +360,24 @@ async function rewriteOversizedSources(files) {
358
360
  }
359
361
  }
360
362
 
363
+ // Lowercase the extension on each Vinyl file's path before piping into gulp-responsive-modern.
364
+ // gulp-responsive-modern's lib/format.js uses a case-sensitive switch on path.extname() and returns
365
+ // the string 'unsupported' for anything else, which then crashes sharp.toFormat(). Files saved
366
+ // straight off a camera (IMG_3119.JPG) hit this. Rewriting the Vinyl path in-stream keeps the
367
+ // on-disk file untouched while letting the plugin recognize the format.
368
+ function lowercaseExtTransform() {
369
+ return new Transform({
370
+ objectMode: true,
371
+ transform(file, _enc, cb) {
372
+ const ext = path.extname(file.path);
373
+ if (ext && ext !== ext.toLowerCase()) {
374
+ file.path = file.path.slice(0, -ext.length) + ext.toLowerCase();
375
+ }
376
+ cb(null, file);
377
+ },
378
+ });
379
+ }
380
+
361
381
  // Build responsive configurations from PICTURE_SIZES
362
382
  function getResponsiveConfigs() {
363
383
  const configs = [];
@@ -552,21 +572,26 @@ function logImageStatistics(stats, startTime, endTime) {
552
572
  // Size reduction stats
553
573
  if (stats.sizeBefore > 0 && stats.sizeAfter > 0) {
554
574
  const savedPercent = ((stats.savedBytes / stats.sizeBefore) * 100).toFixed(1);
575
+ const label = stats.savedBytes < 0 ? 'Total added' : 'Total saved';
555
576
  logger.log('\n💾 Size Reduction:');
556
577
  logger.log(` Original size: ${formatBytes(stats.sizeBefore)}`);
557
578
  logger.log(` Optimized size: ${formatBytes(stats.sizeAfter)}`);
558
- logger.log(` Total saved: ${formatBytes(stats.savedBytes)} (${savedPercent}%)`);
579
+ logger.log(` ${label}: ${formatBytes(Math.abs(stats.savedBytes))} (${savedPercent}%)`);
559
580
  }
560
581
 
561
582
  logger.log('═══════════════════════════════════════\n');
562
583
  }
563
584
 
564
- // Helper to format bytes
585
+ // Helper to format bytes. Handles negative inputs — when responsive variants (8 per source)
586
+ // sum to more than the cached original, savedBytes goes negative; without the absolute-value
587
+ // guard, Math.log(negative) is NaN and the suffix index becomes NaN -> "NaN undefined".
565
588
  function formatBytes(bytes, decimals = 2) {
566
589
  if (bytes === 0) return '0 Bytes';
590
+ const sign = bytes < 0 ? '-' : '';
591
+ const abs = Math.abs(bytes);
567
592
  const k = 1024;
568
593
  const dm = decimals < 0 ? 0 : decimals;
569
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
570
- const i = Math.floor(Math.log(bytes) / Math.log(k));
571
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
594
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
595
+ const i = Math.min(Math.floor(Math.log(abs) / Math.log(k)), sizes.length - 1);
596
+ return sign + parseFloat((abs / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
572
597
  }
@@ -44,6 +44,24 @@ const bundleFiles = [
44
44
  'src/assets/css/bundles/*.scss',
45
45
  ];
46
46
 
47
+ // Build the active theme's page-CSS globs, but ONLY for `pages` dirs that exist —
48
+ // gulp's src() throws ENOENT when it scandirs a missing directory. Project theme
49
+ // takes priority over the package theme (matches the __theme__ resolution rule).
50
+ function themePageGlobs() {
51
+ if (!config.theme.id) {
52
+ return [];
53
+ }
54
+
55
+ const candidates = [
56
+ path.resolve(rootPathProject, 'src/assets/themes', config.theme.id, 'pages'),
57
+ path.resolve(rootPathPackage, 'dist/assets/themes', config.theme.id, 'pages'),
58
+ ];
59
+
60
+ return candidates
61
+ .filter((dir) => jetpack.exists(dir))
62
+ .map((dir) => `${dir}/**/*.scss`);
63
+ }
64
+
47
65
  // Glob
48
66
  const input = [
49
67
  // Bundle files (admin, and any future bundles)
@@ -56,10 +74,23 @@ const input = [
56
74
  `${rootPathPackage}/dist/assets/css/pages/**/*.scss`,
57
75
  'src/assets/css/pages/**/*.scss',
58
76
 
77
+ // Theme page-specific CSS (theme-aware page styles).
78
+ // Compiles to a SEPARATE bundle (pages/<path>/index.<themeId>.bundle.css)
79
+ // that head.html links IN ADDITION to the base + consumer page bundles.
80
+ // Missing = nothing loads (component styles handle it) — no fallback needed.
81
+ //
82
+ // Only include a glob whose base `pages` dir exists — gulp's src() throws ENOENT
83
+ // if it scandirs a non-existent directory. Most consumers don't shadow the theme,
84
+ // so the project-side path usually won't exist.
85
+ ...themePageGlobs(),
86
+
59
87
  // Files to exclude
60
88
  // '!dist/**',
61
89
  ];
62
90
 
91
+ // Marker appended to theme page bundles so head.html can find them by theme id.
92
+ const THEME_PAGE_SUFFIX = config.theme.id ? `.${config.theme.id}` : '';
93
+
63
94
  // Additional files to watch (but not compile as entry points)
64
95
  const watchInput = [
65
96
  // Watch the paths we're compiling
@@ -345,7 +376,17 @@ function sass(complete) {
345
376
  .pipe(cleanCSS({
346
377
  format: Manager.actLikeProduction() ? 'compressed' : 'beautify',
347
378
  }))
348
- .pipe(rename((file) => {
379
+ .pipe(rename((file, vinyl) => {
380
+ // Theme page CSS originates from .../themes/<id>/pages/... — tag the bundle
381
+ // with the theme id (e.g. index.neobrutalism.bundle.css) so it compiles to
382
+ // its own file that head.html links alongside the base + consumer bundles.
383
+ const sourcePath = (vinyl && vinyl.history[0]) || '';
384
+ const isThemePage = THEME_PAGE_SUFFIX
385
+ && sourcePath.replace(/\\/g, '/').includes(`/themes/${config.theme.id}/pages/`);
386
+ if (isThemePage) {
387
+ file.basename += THEME_PAGE_SUFFIX;
388
+ }
389
+
349
390
  // Add bundle to the name
350
391
  file.basename += '.bundle';
351
392
 
@@ -355,7 +396,7 @@ function sass(complete) {
355
396
  bundleNames.push('main'); // main.scss is always a root bundle
356
397
 
357
398
  // Check if this is a root-level bundle
358
- const baseName = file.basename.replace('.bundle', '');
399
+ const baseName = file.basename.replace('.bundle', '').replace(THEME_PAGE_SUFFIX, '');
359
400
  const isBundle = bundleNames.includes(baseName);
360
401
 
361
402
  // Check
@@ -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
  },