From 88b3df5a456210e919ac302b152435018417874b Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Mon, 25 May 2026 16:55:25 +0300 Subject: [PATCH 1/9] feat: add test source files --- .../author/author.featured.src.js | 16 ++++++++++++++++ .../author/author.featured.src.scss | 18 ++++++++++++++++++ .../common/styles/_media-queries.scss | 19 +++++++++++++++++++ .../common/styles/_variables.scss | 2 ++ .../src-feature-segments/common/utils/math.js | 7 +++++++ .../common/utils/random.js | 3 +++ .../common/utils/randomInt.js | 4 ++++ .../component/component.featured.src.scss | 13 +++++++++++++ .../publish/publish.featured.src.js | 18 ++++++++++++++++++ .../publish/publish.featured.src.scss | 12 ++++++++++++ .../author/author.authorlibs.js | 16 ++++++++++++++++ .../author/author.authorlibs.scss | 18 ++++++++++++++++++ test/src-multi-sourcekey/author/author.src.js | 16 ++++++++++++++++ .../author/author.src.scss | 18 ++++++++++++++++++ .../common/styles/_media-queries.scss | 19 +++++++++++++++++++ .../common/styles/_variables.scss | 2 ++ test/src-multi-sourcekey/common/utils/math.js | 7 +++++++ .../common/utils/random.js | 3 +++ .../common/utils/randomInt.js | 4 ++++ .../component/component.publishlibs.scss | 13 +++++++++++++ .../publish/component/component.src.scss | 13 +++++++++++++ .../publish/publish.publishlibs.js | 18 ++++++++++++++++++ .../publish/publish.publishlibs.scss | 12 ++++++++++++ .../publish/publish.src.js | 18 ++++++++++++++++++ .../publish/publish.src.scss | 12 ++++++++++++ 25 files changed, 301 insertions(+) create mode 100644 test/src-feature-segments/author/author.featured.src.js create mode 100644 test/src-feature-segments/author/author.featured.src.scss create mode 100644 test/src-feature-segments/common/styles/_media-queries.scss create mode 100644 test/src-feature-segments/common/styles/_variables.scss create mode 100644 test/src-feature-segments/common/utils/math.js create mode 100644 test/src-feature-segments/common/utils/random.js create mode 100644 test/src-feature-segments/common/utils/randomInt.js create mode 100644 test/src-feature-segments/publish/component/component.featured.src.scss create mode 100644 test/src-feature-segments/publish/publish.featured.src.js create mode 100644 test/src-feature-segments/publish/publish.featured.src.scss create mode 100644 test/src-multi-sourcekey/author/author.authorlibs.js create mode 100644 test/src-multi-sourcekey/author/author.authorlibs.scss create mode 100644 test/src-multi-sourcekey/author/author.src.js create mode 100644 test/src-multi-sourcekey/author/author.src.scss create mode 100644 test/src-multi-sourcekey/common/styles/_media-queries.scss create mode 100644 test/src-multi-sourcekey/common/styles/_variables.scss create mode 100644 test/src-multi-sourcekey/common/utils/math.js create mode 100644 test/src-multi-sourcekey/common/utils/random.js create mode 100644 test/src-multi-sourcekey/common/utils/randomInt.js create mode 100644 test/src-multi-sourcekey/publish/component/component.publishlibs.scss create mode 100644 test/src-multi-sourcekey/publish/component/component.src.scss create mode 100644 test/src-multi-sourcekey/publish/publish.publishlibs.js create mode 100644 test/src-multi-sourcekey/publish/publish.publishlibs.scss create mode 100644 test/src-multi-sourcekey/publish/publish.src.js create mode 100644 test/src-multi-sourcekey/publish/publish.src.scss diff --git a/test/src-feature-segments/author/author.featured.src.js b/test/src-feature-segments/author/author.featured.src.js new file mode 100644 index 0000000..379aa6c --- /dev/null +++ b/test/src-feature-segments/author/author.featured.src.js @@ -0,0 +1,16 @@ +import { randomInt } from "common/utils/randomInt"; +import { random } from "common/utils/random"; + +class MainAuthor { + constructor() { + this.value = randomInt(1, 10); + this.value2 = random(1, 10); + this.init(); + } + + init() { + console.log(`Hello Author ${this.value} author ${this.value2}!`); + } +} + +export const instace = new MainAuthor(); diff --git a/test/src-feature-segments/author/author.featured.src.scss b/test/src-feature-segments/author/author.featured.src.scss new file mode 100644 index 0000000..bf39eb3 --- /dev/null +++ b/test/src-feature-segments/author/author.featured.src.scss @@ -0,0 +1,18 @@ +@use 'media-queries' as mq; + +body { + @include mq.for-size(s) { + font-size: 1.5em; + + + + } + + @include mq.for-size(m) { + font-size: 1em; + } +} + +::placeholder { + color: gray; +} diff --git a/test/src-feature-segments/common/styles/_media-queries.scss b/test/src-feature-segments/common/styles/_media-queries.scss new file mode 100644 index 0000000..ebaf464 --- /dev/null +++ b/test/src-feature-segments/common/styles/_media-queries.scss @@ -0,0 +1,19 @@ +@mixin for-size($size) { + @if $size == s { + @media (min-width: 600px) { + @content; + } + } @else if $size == m { + @media (min-width: 768px) { + @content; + } + } @else if $size == l { + @media (min-width: 960px) { + @content; + } + } @else if $size == xl { + @media (min-width: 1200px) { + @content; + } + } +} diff --git a/test/src-feature-segments/common/styles/_variables.scss b/test/src-feature-segments/common/styles/_variables.scss new file mode 100644 index 0000000..d43fe95 --- /dev/null +++ b/test/src-feature-segments/common/styles/_variables.scss @@ -0,0 +1,2 @@ +$primary-color: #5a3699; +$secondary-color: #fff; diff --git a/test/src-feature-segments/common/utils/math.js b/test/src-feature-segments/common/utils/math.js new file mode 100644 index 0000000..09e11b7 --- /dev/null +++ b/test/src-feature-segments/common/utils/math.js @@ -0,0 +1,7 @@ +export function randomInt(min = 0, max = 1) { + return Math.round(random(min, max)); +} + +export function random(min = 0, max = 1) { + return Math.random() * (max - min) + min; +} diff --git a/test/src-feature-segments/common/utils/random.js b/test/src-feature-segments/common/utils/random.js new file mode 100644 index 0000000..717ecf9 --- /dev/null +++ b/test/src-feature-segments/common/utils/random.js @@ -0,0 +1,3 @@ +export function random(min = 0, max = 1) { + return Math.random() * (max - min) + min; +} diff --git a/test/src-feature-segments/common/utils/randomInt.js b/test/src-feature-segments/common/utils/randomInt.js new file mode 100644 index 0000000..5ab945a --- /dev/null +++ b/test/src-feature-segments/common/utils/randomInt.js @@ -0,0 +1,4 @@ + +export function randomInt(min = 0, max = 1) { + return Math.round(Math.random() * (max - min) + min); +} diff --git a/test/src-feature-segments/publish/component/component.featured.src.scss b/test/src-feature-segments/publish/component/component.featured.src.scss new file mode 100644 index 0000000..ac88792 --- /dev/null +++ b/test/src-feature-segments/publish/component/component.featured.src.scss @@ -0,0 +1,13 @@ +::placeholder { + color: gray; +} + +.image { + background-image: url("image@1x.png"); +} + +@media (resolution >= 2dppx) { + .image { + background-image: url("image@2x.png"); + } +} diff --git a/test/src-feature-segments/publish/publish.featured.src.js b/test/src-feature-segments/publish/publish.featured.src.js new file mode 100644 index 0000000..650b7c9 --- /dev/null +++ b/test/src-feature-segments/publish/publish.featured.src.js @@ -0,0 +1,18 @@ +import { randomInt } from "common/utils/randomInt"; + +class MainPublish { + constructor() { + this.value = randomInt(1, 10); + this.arr = [...[0, 1, 2]]; + this.obj = { + name: 'test' + }; + this.init(); + } + + init() { + const { name } = this.obj; + console.log(`Hello World ${this.value}! ${name}`); + } +} +export const instace = new MainPublish(); diff --git a/test/src-feature-segments/publish/publish.featured.src.scss b/test/src-feature-segments/publish/publish.featured.src.scss new file mode 100644 index 0000000..3aebb7d --- /dev/null +++ b/test/src-feature-segments/publish/publish.featured.src.scss @@ -0,0 +1,12 @@ +@use 'variables'; + +body { + color: variables.$secondary-color; + background-color: variables.$primary-color; + + @media (min-resolution: 2dppx) { + .image { + background-image: url('image@2x.png'); + } + } +} diff --git a/test/src-multi-sourcekey/author/author.authorlibs.js b/test/src-multi-sourcekey/author/author.authorlibs.js new file mode 100644 index 0000000..379aa6c --- /dev/null +++ b/test/src-multi-sourcekey/author/author.authorlibs.js @@ -0,0 +1,16 @@ +import { randomInt } from "common/utils/randomInt"; +import { random } from "common/utils/random"; + +class MainAuthor { + constructor() { + this.value = randomInt(1, 10); + this.value2 = random(1, 10); + this.init(); + } + + init() { + console.log(`Hello Author ${this.value} author ${this.value2}!`); + } +} + +export const instace = new MainAuthor(); diff --git a/test/src-multi-sourcekey/author/author.authorlibs.scss b/test/src-multi-sourcekey/author/author.authorlibs.scss new file mode 100644 index 0000000..bf39eb3 --- /dev/null +++ b/test/src-multi-sourcekey/author/author.authorlibs.scss @@ -0,0 +1,18 @@ +@use 'media-queries' as mq; + +body { + @include mq.for-size(s) { + font-size: 1.5em; + + + + } + + @include mq.for-size(m) { + font-size: 1em; + } +} + +::placeholder { + color: gray; +} diff --git a/test/src-multi-sourcekey/author/author.src.js b/test/src-multi-sourcekey/author/author.src.js new file mode 100644 index 0000000..379aa6c --- /dev/null +++ b/test/src-multi-sourcekey/author/author.src.js @@ -0,0 +1,16 @@ +import { randomInt } from "common/utils/randomInt"; +import { random } from "common/utils/random"; + +class MainAuthor { + constructor() { + this.value = randomInt(1, 10); + this.value2 = random(1, 10); + this.init(); + } + + init() { + console.log(`Hello Author ${this.value} author ${this.value2}!`); + } +} + +export const instace = new MainAuthor(); diff --git a/test/src-multi-sourcekey/author/author.src.scss b/test/src-multi-sourcekey/author/author.src.scss new file mode 100644 index 0000000..bf39eb3 --- /dev/null +++ b/test/src-multi-sourcekey/author/author.src.scss @@ -0,0 +1,18 @@ +@use 'media-queries' as mq; + +body { + @include mq.for-size(s) { + font-size: 1.5em; + + + + } + + @include mq.for-size(m) { + font-size: 1em; + } +} + +::placeholder { + color: gray; +} diff --git a/test/src-multi-sourcekey/common/styles/_media-queries.scss b/test/src-multi-sourcekey/common/styles/_media-queries.scss new file mode 100644 index 0000000..ebaf464 --- /dev/null +++ b/test/src-multi-sourcekey/common/styles/_media-queries.scss @@ -0,0 +1,19 @@ +@mixin for-size($size) { + @if $size == s { + @media (min-width: 600px) { + @content; + } + } @else if $size == m { + @media (min-width: 768px) { + @content; + } + } @else if $size == l { + @media (min-width: 960px) { + @content; + } + } @else if $size == xl { + @media (min-width: 1200px) { + @content; + } + } +} diff --git a/test/src-multi-sourcekey/common/styles/_variables.scss b/test/src-multi-sourcekey/common/styles/_variables.scss new file mode 100644 index 0000000..d43fe95 --- /dev/null +++ b/test/src-multi-sourcekey/common/styles/_variables.scss @@ -0,0 +1,2 @@ +$primary-color: #5a3699; +$secondary-color: #fff; diff --git a/test/src-multi-sourcekey/common/utils/math.js b/test/src-multi-sourcekey/common/utils/math.js new file mode 100644 index 0000000..09e11b7 --- /dev/null +++ b/test/src-multi-sourcekey/common/utils/math.js @@ -0,0 +1,7 @@ +export function randomInt(min = 0, max = 1) { + return Math.round(random(min, max)); +} + +export function random(min = 0, max = 1) { + return Math.random() * (max - min) + min; +} diff --git a/test/src-multi-sourcekey/common/utils/random.js b/test/src-multi-sourcekey/common/utils/random.js new file mode 100644 index 0000000..717ecf9 --- /dev/null +++ b/test/src-multi-sourcekey/common/utils/random.js @@ -0,0 +1,3 @@ +export function random(min = 0, max = 1) { + return Math.random() * (max - min) + min; +} diff --git a/test/src-multi-sourcekey/common/utils/randomInt.js b/test/src-multi-sourcekey/common/utils/randomInt.js new file mode 100644 index 0000000..5ab945a --- /dev/null +++ b/test/src-multi-sourcekey/common/utils/randomInt.js @@ -0,0 +1,4 @@ + +export function randomInt(min = 0, max = 1) { + return Math.round(Math.random() * (max - min) + min); +} diff --git a/test/src-multi-sourcekey/publish/component/component.publishlibs.scss b/test/src-multi-sourcekey/publish/component/component.publishlibs.scss new file mode 100644 index 0000000..ac88792 --- /dev/null +++ b/test/src-multi-sourcekey/publish/component/component.publishlibs.scss @@ -0,0 +1,13 @@ +::placeholder { + color: gray; +} + +.image { + background-image: url("image@1x.png"); +} + +@media (resolution >= 2dppx) { + .image { + background-image: url("image@2x.png"); + } +} diff --git a/test/src-multi-sourcekey/publish/component/component.src.scss b/test/src-multi-sourcekey/publish/component/component.src.scss new file mode 100644 index 0000000..ac88792 --- /dev/null +++ b/test/src-multi-sourcekey/publish/component/component.src.scss @@ -0,0 +1,13 @@ +::placeholder { + color: gray; +} + +.image { + background-image: url("image@1x.png"); +} + +@media (resolution >= 2dppx) { + .image { + background-image: url("image@2x.png"); + } +} diff --git a/test/src-multi-sourcekey/publish/publish.publishlibs.js b/test/src-multi-sourcekey/publish/publish.publishlibs.js new file mode 100644 index 0000000..650b7c9 --- /dev/null +++ b/test/src-multi-sourcekey/publish/publish.publishlibs.js @@ -0,0 +1,18 @@ +import { randomInt } from "common/utils/randomInt"; + +class MainPublish { + constructor() { + this.value = randomInt(1, 10); + this.arr = [...[0, 1, 2]]; + this.obj = { + name: 'test' + }; + this.init(); + } + + init() { + const { name } = this.obj; + console.log(`Hello World ${this.value}! ${name}`); + } +} +export const instace = new MainPublish(); diff --git a/test/src-multi-sourcekey/publish/publish.publishlibs.scss b/test/src-multi-sourcekey/publish/publish.publishlibs.scss new file mode 100644 index 0000000..3aebb7d --- /dev/null +++ b/test/src-multi-sourcekey/publish/publish.publishlibs.scss @@ -0,0 +1,12 @@ +@use 'variables'; + +body { + color: variables.$secondary-color; + background-color: variables.$primary-color; + + @media (min-resolution: 2dppx) { + .image { + background-image: url('image@2x.png'); + } + } +} diff --git a/test/src-multi-sourcekey/publish/publish.src.js b/test/src-multi-sourcekey/publish/publish.src.js new file mode 100644 index 0000000..650b7c9 --- /dev/null +++ b/test/src-multi-sourcekey/publish/publish.src.js @@ -0,0 +1,18 @@ +import { randomInt } from "common/utils/randomInt"; + +class MainPublish { + constructor() { + this.value = randomInt(1, 10); + this.arr = [...[0, 1, 2]]; + this.obj = { + name: 'test' + }; + this.init(); + } + + init() { + const { name } = this.obj; + console.log(`Hello World ${this.value}! ${name}`); + } +} +export const instace = new MainPublish(); diff --git a/test/src-multi-sourcekey/publish/publish.src.scss b/test/src-multi-sourcekey/publish/publish.src.scss new file mode 100644 index 0000000..3aebb7d --- /dev/null +++ b/test/src-multi-sourcekey/publish/publish.src.scss @@ -0,0 +1,12 @@ +@use 'variables'; + +body { + color: variables.$secondary-color; + background-color: variables.$primary-color; + + @media (min-resolution: 2dppx) { + .image { + background-image: url('image@2x.png'); + } + } +} From f7573ea98342b5d65192178d7e44a5d44aa81438 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Mon, 25 May 2026 16:59:08 +0300 Subject: [PATCH 2/9] feat: Add file name to dist folders segments --- .gitignore | 4 + config/clientlibs.config.js | 6 + config/general.config.js | 22 ++ config/optimization.config.js | 7 +- docs/configuration.md | 170 +++++++++++++- package.json | 4 +- tasks/clientlibs.js | 46 ++++ tasks/clientlibs.test.js | 155 ++++++++++++ tasks/styles.js | 66 +++--- tasks/styles.test.js | 60 +++++ tasks/webpack.test.js | 65 ++++++ .../publish/publish.featured2.src.scss | 12 + .../publish/publish.featured3.src.scss | 13 ++ test/src/publish/publish.src.scss | 2 +- utils/checkChunk.js | 14 +- utils/checkChunk.test.js | 31 ++- utils/generateEntries.js | 45 +++- utils/generateEntries.test.js | 220 +++++++++++++++++- utils/renderClientLibs.js | 2 +- utils/renderSass.js | 2 +- 20 files changed, 886 insertions(+), 60 deletions(-) create mode 100644 test/src-feature-segments/publish/publish.featured2.src.scss create mode 100644 test/src-feature-segments/publish/publish.featured3.src.scss diff --git a/.gitignore b/.gitignore index f50d1b8..3614a94 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ symbols.svg .tmp/ .cache-loader dist/ +dist-feature-segments/ +dist-multi-sourcekey/ +test/dist-splitchunks/ +test/dist-splitchunks-collision/ # Svg Icons Temp folder tmp/ diff --git a/config/clientlibs.config.js b/config/clientlibs.config.js index 1604287..072da9a 100644 --- a/config/clientlibs.config.js +++ b/config/clientlibs.config.js @@ -16,8 +16,14 @@ const skipCategories = ['myproject.author']; // Object to be able to generate clientlibs for new CSS files (created in build process) in target folder const extraEntries = {}; +// When true, generates .content.xml and js.txt metadata for webpack split chunks +// (runtimeChunk + splitChunks cacheGroups). Chunks must each have a unique output +// subfolder; chunks sharing a folder are skipped with a warning. +const generateSplitChunksClientlibs = false; + module.exports = { override, skipCategories, extraEntries, + generateSplitChunksClientlibs, }; diff --git a/config/general.config.js b/config/general.config.js index befcfe4..8948e66 100644 --- a/config/general.config.js +++ b/config/general.config.js @@ -32,11 +32,30 @@ const nodeModules = path.join(rootPath, 'node_modules'); */ // what is the source files key suffix to compile +// can be a string (e.g. 'source') or an array (e.g. ['publishlibs', 'authorlibs', 'dialoglibs', 'editorlibs'] etc) const sourceKey = 'source'; // what is the compiled bundle key const bundleKey = 'bundle'; +// When true, the sourceKey segment becomes an additional folder level in the +// dist entry key and is dropped from the dist filename. +// e.g. component.main.publishlibs.js -> dist/.../main/publishlibs/component.main.bundle.js +const sourceKeyAsDistFolder = false; + +// When true, dot-separated segments between the component prefix and sourceKey +// in the source filename are expanded into folder levels in the dist entry key. +// e.g. component.main.publishlibs.js -> dist/.../main/component.main.bundle.js +const fileNameDotSuffixesAsDistFolder = false; + +// Values excluded from folder promotion when sourceKeyAsDistFolder or +// fileNameDotSuffixesAsDistFolder are true. Each entry may be: +// - a string: exact case-sensitive match (e.g. 'clientlibs') +// - a RegExp: pattern match (e.g. /^v\d+$/ to exclude v1, v2, v3 …) +// Applies to both sourceKey values and feature segment values. +// Empty array means no exclusions (default). +const excludeFileNameDotSuffixes = []; + // source file types ['js', 'scss'] const sourceTypes = ['js', 'scss']; @@ -97,6 +116,9 @@ module.exports = { projectKey, sourceKey, bundleKey, + fileNameDotSuffixesAsDistFolder, + excludeFileNameDotSuffixes, + sourceKeyAsDistFolder, sourceTypes, rootPath, sourcesPath, diff --git a/config/optimization.config.js b/config/optimization.config.js index 7c3ae0a..f8bc5b5 100644 --- a/config/optimization.config.js +++ b/config/optimization.config.js @@ -1,10 +1,9 @@ -const path = require('path') const { isProduction, excludedFromVendors, bundleKey } = require('./general.config'); const checkChunk = require('../utils/checkChunk'); const nodeModules = `node_modules`; -// curry to pass the module to check -const test = ( excludes, includes) => (mod) => checkChunk(mod.context, excludes, includes); +// curry to pass the webpack module object to checkChunk +const test = (excludes, includes) => (mod) => checkChunk(mod, excludes, includes); module.exports = { minimize: isProduction, @@ -16,7 +15,7 @@ module.exports = { chunks: 'initial', minChunks: 2, cacheGroups: { - // Treeshake vendors in node_modules (but keep unique vendors at the clientlibs it belongs) + // Treeshake vendors in node_modules (but keep unique vendors at the clientlibs it belongs) vendors: { test: test([nodeModules], excludedFromVendors), minChunks: 2, diff --git a/docs/configuration.md b/docs/configuration.md index 3fd911b..e0e083b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,10 +73,25 @@ module.exports = { general: { // Your project name with which ClientLibs category are prefixed projectKey: "myproj", - // Only the source files with this suffix will be compiled + // Only the source files with this suffix will be compiled. + // Accepts a string or an array of strings. sourceKey: "source", // The compiled bundle filename suffix bundleKey: "bundle", + // When true, the sourceKey segment becomes an additional dist folder level + // and is dropped from the dist filename. + // e.g. component.main.publishlibs.js -> dist/.../main/publishlibs/component.main.bundle.js + sourceKeyAsDistFolder: false, + + // When true, dot-separated segments between the component prefix and sourceKey + // in the source filename are expanded into folder levels in the dist entry key. + // e.g. component.main.publishlibs.js -> dist/.../main/component.main.bundle.js + fileNameDotSuffixesAsDistFolder: false, + + // Values excluded from folder promotion when sourceKeyAsDistFolder or + // fileNameDotSuffixesAsDistFolder are true. Empty array means no exclusions. + excludeFileNameDotSuffixes: [], + // The path to the directory with your source files sourcesPath: "src", // Path to the dir with the code shared among Scss and JS files @@ -105,6 +120,101 @@ module.exports = { } ``` +#### Filename-based clientlib convention + +By default, `sourceKey` is a single string (e.g. `"source"`). The build finds every file matching `**/*.source.js` and compiles it to a dist file in the same relative folder. For example: + +``` +src/components/video/video.source.js -> dist/components/video/video.bundle.js +``` + +This works well for simple setups, but AEM has a specific requirement: **each clientlib must live in its own dedicated folder** with a `.content.xml` file. A single component often needs multiple clientlibs - one for publish, one for author, and possibly additional ones for optional features loaded conditionally by AEM configuration. Producing that folder structure in dist would otherwise require mirroring it in the source tree, leading to deeply nested folders that are awkward to navigate. + +There are three options that make this easier: an array `sourceKey`, `sourceKeyAsDistFolder`, and `fileNameDotSuffixesAsDistFolder`. Used together, they let you keep the source tree flat while the build derives the required dist folder hierarchy from the filename. + +##### `sourceKey` as an array + `sourceKeyAsDistFolder` + +A component commonly needs separate clientlibs for publish and author mode. Without any flags, you would need separate source folders to keep those files apart. With an array `sourceKey` and `sourceKeyAsDistFolder: true`, you can keep them co-located in the same source folder - the sourceKey suffix in the filename (`publishlibs` vs `authorlibs`) tells the build which dist folder each file belongs to: + +``` +src/components/video/ + video.publishlibs.scss + video.authorlibs.scss +``` + +``` +dist/ + components/video/publishlibs/video.bundle.css # clientlib: myproj.video.publishlibs + components/video/authorlibs/video.bundle.css # clientlib: myproj.video.authorlibs +``` + +##### `fileNameDotSuffixesAsDistFolder` + +A component may need feature-specific clientlibs - for example, HD video support or caption styles - that AEM loads conditionally. Each must be a separate clientlib folder in dist. With `fileNameDotSuffixesAsDistFolder: true`, the dot-segments between the component name and the sourceKey suffix become folder levels in dist: + +``` +src/components/video/ + video.main.publishlibs.scss + video.hd.publishlibs.scss + video.captions.publishlibs.scss +``` + +``` +dist/components/video/ + main/publishlibs/video.bundle.css # base clientlib + hd/publishlibs/video.hd.bundle.css # loaded when HD feature is active + captions/publishlibs/video.captions.bundle.css +``` + +No `main`, `hd/` or `captions/` subfolders needed in source - the feature name lives in the filename. + +##### Enabling the convention + +```javascript +module.exports = { + general: { + sourceKey: ['publishlibs', 'authorlibs'], + fileNameDotSuffixesAsDistFolder: true, + sourceKeyAsDistFolder: true, + } +} +``` + +| Flag combination | `video.main.publishlibs.js` -> dist key | +|---|---| +| `false` / `false` | `video.main.bundle.js` (default) | +| `true` / `false` | `main/video.main.bundle.js` | +| `false` / `true` | `publishlibs/video.main.bundle.js` | +| `true` / `true` | `main/publishlibs/video.main.bundle.js` | + +Both flags default to `false` - existing projects are unaffected unless they opt in. + +##### `excludeFileNameDotSuffixes` + +When using `sourceKeyAsDistFolder` or `fileNameDotSuffixesAsDistFolder`, every matched value is promoted to a folder by default. `excludeFileNameDotSuffixes` lets you opt specific values out of that promotion while keeping them as valid source keys. + +**Motivating scenario:** A project migrating from `sourceKey: 'clientlibs'` to `sourceKey: ['clientlibs', 'publishlibs', 'authorlibs']` wants folder promotion only for the new values. The existing `*.clientlibs.*` files should continue producing flat dist entries without renaming: + +```javascript +module.exports = { + general: { + sourceKey: ['clientlibs', 'publishlibs', 'authorlibs'], + sourceKeyAsDistFolder: true, + excludeFileNameDotSuffixes: ['clientlibs'], + } +} +``` + +| Source file | Dist entry key | +|---|---| +| `author/author.clientlibs.js` | `author/author.bundle.js` - excluded, stays flat | +| `author/author.authorlibs.js` | `author/authorlibs/author.bundle.js` - promoted | +| `publish/publish.publishlibs.js` | `publish/publishlibs/publish.bundle.js` - promoted | + +The same exclusion applies to feature segments when `fileNameDotSuffixesAsDistFolder: true`. Any segment listed in `excludeFileNameDotSuffixes` is not promoted to a folder, but remains in the dist filename. + +`excludeFileNameDotSuffixes` defaults to `[]` - no exclusions. It is a no-op when both promotion flags are `false`. + ### Babel Babel webpack plugin, enabled by default in the option `general.modules`. @@ -300,3 +410,61 @@ Advantages of having a separated vendor: **treeshaking.bundle.js** This is intended to optimize the codebase of the project, by code splitting the modules that are the building blocks of it, like core-js, babel and @your modules. + +### Clientlibs + +Controls AEM clientlib metadata generation. Defaults: + +```javascript +{ + clientlibs: { + // When true, overwrites existing .content.xml and txt files + override: false, + // Categories to skip when override is true + skipCategories: ['myproject.author'], + // Extra entries to generate clientlibs for (CSS files produced in the build process) + extraEntries: {}, + // When true, also generates .content.xml and js.txt for webpack split chunks + // (runtimeChunk + splitChunks cacheGroups). Each chunk must have its own unique + // output subfolder; chunks sharing a folder are skipped with a warning. + generateSplitChunksClientlibs: false, + } +} +``` + +#### `generateSplitChunksClientlibs` + +Webpack split chunks (`treeshaking.bundle.js`, `vendors.bundle.js`) are emitted to dist but have no AEM clientlib metadata by default. Enabling this flag generates `.content.xml` and `js.txt` for each split chunk so AEM can discover them as clientlibs. + +**Requirement:** each chunk must live in its own dedicated subfolder. The default config places both chunks in `commons/` - this causes a collision and both are skipped with a warning. To use this flag, give each chunk a unique subfolder in `optimization`: + +```javascript +module.exports = { + clientlibs: { + generateSplitChunksClientlibs: true, + }, + optimization: { + runtimeChunk: { name: 'commons/treeshaking/treeshaking.bundle.js' }, + splitChunks: { + cacheGroups: { + vendors: { name: 'commons/vendors/vendors.bundle.js' }, + treeshaking: { name: 'commons/treeshaking/treeshaking.bundle.js' }, + } + } + } +}; +``` + +This produces: + +``` +dist/commons/treeshaking/ + .content.xml # category: myproj.commons.treeshaking + js.txt # treeshaking.bundle.js + +dist/commons/vendors/ + .content.xml # category: myproj.commons.vendors + js.txt # vendors.bundle.js +``` + +If two or more chunks share the same output folder, a warning is logged and no metadata is written for that folder. No error is thrown. diff --git a/package.json b/package.json index 428ad56..789f709 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/Netcentric/fe-build.git " }, "scripts": { - "clean": "rm -rf ./test/dist", + "clean": "rm -rf ./test/dist ./test/dist-feature-segments ./test/dist-multi-sourcekey ./test/dist-splitchunks ./test/dist-splitchunks-collision", "test": "npm run clean && npm run jest", "jest": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest -u --runInBand" }, @@ -43,7 +43,7 @@ "@babel/preset-env": "^7.26.7", "autoprefixer": "^10.4.20", "babel-loader": "9.1.2", - "chokidar": "^5.0.0", + "chokidar": "^4.0.3", "core-js": "^3.40.0", "eslint": "^8.57.1", "eslint-webpack-plugin": "^4.2.0", diff --git a/tasks/clientlibs.js b/tasks/clientlibs.js index 6d642cb..19231a3 100644 --- a/tasks/clientlibs.js +++ b/tasks/clientlibs.js @@ -1,3 +1,4 @@ +const path = require('path'); const { log } = require('../utils/log'); const generateEntries = require('../utils/generateEntries'); const getClientlib = require('../utils/getClientlib'); @@ -37,5 +38,50 @@ module.exports = (config) => { clientLibs[folder][extension] = fileName; }); + // generate clientlib metadata for webpack split chunks when opt-in flag is set + const { generateSplitChunksClientlibs = false } = config.clientlibs; + + if (generateSplitChunksClientlibs && config.optimization) { + const chunkNames = new Set(); + const { runtimeChunk, splitChunks } = config.optimization; + + if (runtimeChunk && runtimeChunk.name) { + chunkNames.add(runtimeChunk.name); + } + + if (splitChunks && splitChunks.cacheGroups) { + Object.values(splitChunks.cacheGroups).forEach(group => { + if (group.name) chunkNames.add(group.name); + }); + } + + // group by output folder to detect collisions + const byDir = {}; + chunkNames.forEach(chunkName => { + const dir = path.dirname(chunkName); + if (!byDir[dir]) { byDir[dir] = [] }; + + byDir[dir].push(chunkName); + }); + + Object.entries(byDir).forEach(([dir, chunks]) => { + if (chunks.length > 1) { + log( + __filename, + `Skipping split chunk clientlib for "${dir}/" - multiple chunks share this folder:\n ${chunks.join('\n ')}\n To generate clientlibs for these, give each chunk its own subfolder.`, + '', + 'warning' + ); + } else { + const [chunkName] = chunks; + const { name, folder, fileName, extension } = getClientlib(chunkName); + if (!clientLibs[folder]) { + clientLibs[folder] = { name, folder }; + } + clientLibs[folder][extension] = fileName; + } + }); + } + Object.keys(clientLibs).forEach(lib => renderClientLibs(clientLibs[lib], config)); }; diff --git a/tasks/clientlibs.test.js b/tasks/clientlibs.test.js index deba29e..fb2f623 100644 --- a/tasks/clientlibs.test.js +++ b/tasks/clientlibs.test.js @@ -42,6 +42,161 @@ describe('Test task/clientlibs.js', () => { }) }); +// -- fileNameDotSuffixesAsDistFolder [test/src-feature-segments -> test/dist-feature-segments] +const cfgFeatureSegments = extendConfig('./test/.febuild', defaults); +cfgFeatureSegments.general.sourcesPath = path.resolve('test/src-feature-segments'); +cfgFeatureSegments.general.destinationPath = path.resolve('test/dist-feature-segments'); +cfgFeatureSegments.general.fileNameDotSuffixesAsDistFolder = true; +const entriesFeatureSegments = { + ...generateEntries(cfgFeatureSegments), + ...generateEntries(cfgFeatureSegments, 'scss'), +}; + +describe('clientlibs with fileNameDotSuffixesAsDistFolder [test/src-feature-segments]', () => { + beforeAll(async () => { + await clientlibTask(cfgFeatureSegments); + }); + + Object.keys(entriesFeatureSegments).forEach((entry) => { + const file = path.join(cfgFeatureSegments.general.destinationPath, entry); + const type = path.extname(file); + const dir = path.dirname(file); + const { name, fileName } = getClientlib(entry); + const ext = type == '.js' ? 'js' : 'css'; + const txtContet = `${fileName.split('.').slice(0, -1).join('.')}.${ext}`; + const txtPath = path.join(dir, `${ext}.txt`); + + it(`TXT files should be created and point to ${entry} file`, () => { + const fileContent = fs.readFileSync(txtPath, { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(txtContet); + }); + + it(`Should create .content.xml files with its category "${name}" based on template`, () => { + const template = config.templates.clientlibTemplate(name, cfgFeatureSegments.general.projectKey); + const fileContent = fs.readFileSync(path.join(dir, '.content.xml'), { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(template); + }); + }); +}); + +// -- Array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey -> test/dist-multi-sourcekey] +const cfgMultiSourcekey = extendConfig('./test/.febuild', defaults); +cfgMultiSourcekey.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); +cfgMultiSourcekey.general.destinationPath = path.resolve('test/dist-multi-sourcekey'); +cfgMultiSourcekey.general.sourceKey = ['src', 'authorlibs', 'publishlibs']; +cfgMultiSourcekey.general.sourceKeyAsDistFolder = true; +const entriesMultiSourcekey = { + ...generateEntries(cfgMultiSourcekey), + ...generateEntries(cfgMultiSourcekey, 'scss'), +}; + +describe('clientlibs with array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey]', () => { + beforeAll(async () => { + await clientlibTask(cfgMultiSourcekey); + }); + + Object.keys(entriesMultiSourcekey).forEach((entry) => { + const file = path.join(cfgMultiSourcekey.general.destinationPath, entry); + const type = path.extname(file); + const dir = path.dirname(file); + const { name, fileName } = getClientlib(entry); + const ext = type == '.js' ? 'js' : 'css'; + const txtContet = `${fileName.split('.').slice(0, -1).join('.')}.${ext}`; + const txtPath = path.join(dir, `${ext}.txt`); + + it(`TXT files should be created and point to ${entry} file`, () => { + const fileContent = fs.readFileSync(txtPath, { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(txtContet); + }); + + it(`Should create .content.xml files with its category "${name}" based on template`, () => { + const template = config.templates.clientlibTemplate(name, cfgMultiSourcekey.general.projectKey); + const fileContent = fs.readFileSync(path.join(dir, '.content.xml'), { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(template); + }); + }); +}); + +// -- generateSplitChunksClientlibs - collision (default optimization) +const cfgSplitCollision = extendConfig('./test/.febuild', defaults); +cfgSplitCollision.general.destinationPath = path.resolve('test/dist-splitchunks-collision'); +cfgSplitCollision.clientlibs.generateSplitChunksClientlibs = true; +// default optimization has commons/treeshaking.bundle.js + commons/vendors.bundle.js in same dir + +describe('generateSplitChunksClientlibs - collision skips metadata (default optimization)', () => { + beforeAll(async () => { + await clientlibTask(cfgSplitCollision); + }); + + it('No .content.xml written to commons/ when both chunks share the same folder', () => { + const xmlPath = path.join(cfgSplitCollision.general.destinationPath, 'commons', '.content.xml'); + expect(fs.existsSync(xmlPath)).toBe(false); + }); + + it('No js.txt written to commons/ when collision detected', () => { + const txtPath = path.join(cfgSplitCollision.general.destinationPath, 'commons', 'js.txt'); + expect(fs.existsSync(txtPath)).toBe(false); + }); +}); + +// -- generateSplitChunksClientlibs - unique subfolders -> metadata generated +const cfgSplitUnique = extendConfig('./test/.febuild', defaults); +cfgSplitUnique.general.destinationPath = path.resolve('test/dist-splitchunks'); +cfgSplitUnique.clientlibs.generateSplitChunksClientlibs = true; +cfgSplitUnique.optimization = { + ...cfgSplitUnique.optimization, + runtimeChunk: { name: 'commons/treeshaking/treeshaking.bundle.js' }, + splitChunks: { + ...cfgSplitUnique.optimization.splitChunks, + cacheGroups: { + vendors: { name: 'commons/vendors/vendors.bundle.js' }, + treeshaking: { name: 'commons/treeshaking/treeshaking.bundle.js' }, + }, + }, +}; + +describe('generateSplitChunksClientlibs - unique subfolders generate metadata', () => { + beforeAll(async () => { + await clientlibTask(cfgSplitUnique); + }); + + it('.content.xml is written for treeshaking chunk', () => { + const xmlPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'treeshaking', '.content.xml'); + expect(fs.existsSync(xmlPath)).toBe(true); + }); + + it('js.txt is written for treeshaking chunk and points to the bundle file', () => { + const txtPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'treeshaking', 'js.txt'); + const content = fs.readFileSync(txtPath, { encoding: 'utf8', flag: 'r' }); + expect(content).toBe('treeshaking.bundle.js'); + }); + + it('.content.xml is written for vendors chunk', () => { + const xmlPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'vendors', '.content.xml'); + expect(fs.existsSync(xmlPath)).toBe(true); + }); + + it('js.txt is written for vendors chunk and points to the bundle file', () => { + const txtPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'vendors', 'js.txt'); + const content = fs.readFileSync(txtPath, { encoding: 'utf8', flag: 'r' }); + expect(content).toBe('vendors.bundle.js'); + }); + + it('.content.xml category for treeshaking is derived from its folder path', () => { + const xmlPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'treeshaking', '.content.xml'); + const template = config.templates.clientlibTemplate('commons.treeshaking', cfgSplitUnique.general.projectKey); + const fileContent = fs.readFileSync(xmlPath, { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(template); + }); + + it('.content.xml category for vendors is derived from its folder path', () => { + const xmlPath = path.join(cfgSplitUnique.general.destinationPath, 'commons', 'vendors', '.content.xml'); + const template = config.templates.clientlibTemplate('commons.vendors', cfgSplitUnique.general.projectKey); + const fileContent = fs.readFileSync(xmlPath, { encoding: 'utf8', flag: 'r' }); + expect(fileContent).toBe(template); + }); +}); + diff --git a/tasks/styles.js b/tasks/styles.js index 1d298ba..81e5101 100644 --- a/tasks/styles.js +++ b/tasks/styles.js @@ -4,49 +4,41 @@ const generateEntries = require('../utils/generateEntries'); const renderStyles = require('../utils/renderStyles'); // extend log to proper say what file is running -module.exports = (config) => { - return new Promise((resolve) => { - if (config && config.general && config.general.watch) { - try { - log(__filename, 'Watcher Sass / autoprefixer running...', '', 'info'); +module.exports = async (config) => { + if (config && config.general && config.general.watch) { + try { + log(__filename, 'Watcher Sass / autoprefixer running...', '', 'info'); - const chokidar = require('chokidar') - const sassPattern = path.join(config.general.sourcesPath, `**/*.${config.general.sourceKey}.scss`); - - const watcher = chokidar.watch(sassPattern, { - ignoreInitial: true - }) - - watcher.on('all', (event, file) => { - const relativePath = path.relative( - config.general.sourcesPath, - path.dirname(file) - ) - - const fileName = path - .basename(file) - .replace(config.general.sourceKey, config.general.bundleKey) + const chokidar = require('chokidar'); + const entries = generateEntries(config, 'scss'); - const destFile = path.join(relativePath, fileName) + // Build reverse map: absolute src path -> dest key + const srcToDest = Object.fromEntries( + Object.entries(entries).map(([dest, src]) => [src, dest]) + ); - config.stylelint.failOnError = false + const watcher = chokidar.watch(Object.values(entries), { + ignoreInitial: true + }); - renderStyles(file, destFile, config) - }); + watcher.on('all', (event, file) => { + const destFile = srcToDest[file]; + if (!destFile) return; - } catch (e) { - log(__filename, 'Something is missing, you need install dev dependencies for this.', e.message, 'error'); - } - } else { - log(__filename, 'Sass / autoprefixer running...', '', 'info'); + config.stylelint.failOnError = false; - // checking all entries at this configuration - const entries = generateEntries(config, 'scss'); - const promises = Object.keys(entries).map(file => renderStyles(entries[file], file, config)); - Promise.allSettled(promises).then((results) => { - log(__filename, 'Styles done', '', 'info'); - resolve(); + renderStyles(file, destFile, config); }); + + } catch (e) { + log(__filename, 'Something is missing, you need install dev dependencies for this.', e.message, 'error'); } - }); + } else { + log(__filename, 'Sass / autoprefixer running...', '', 'info'); + + const entries = generateEntries(config, 'scss'); + const promises = Object.keys(entries).map(file => renderStyles(entries[file], file, config)); + await Promise.allSettled(promises); + log(__filename, 'Styles done', '', 'info'); + } }; diff --git a/tasks/styles.test.js b/tasks/styles.test.js index 86c62e1..36c246f 100644 --- a/tasks/styles.test.js +++ b/tasks/styles.test.js @@ -6,6 +6,7 @@ const extendConfig = require('../utils/extendConfig'); const generateEntries = require('../utils/generateEntries'); let config = extendConfig('./test/.febuild', defaults); +config.general.disableStyleLint = true; let entries = { ...generateEntries(config, 'scss') }; @@ -32,5 +33,64 @@ describe('Test task/styles.js', () => { }) }); +// ── fileNameDotSuffixesAsDistFolder [test/src-feature-segments → test/dist-feature-segments] ──── +const cfgFeatureSegments = extendConfig('./test/.febuild', defaults); +cfgFeatureSegments.general.sourcesPath = path.resolve('test/src-feature-segments'); +cfgFeatureSegments.general.destinationPath = path.resolve('test/dist-feature-segments'); +cfgFeatureSegments.general.fileNameDotSuffixesAsDistFolder = true; +cfgFeatureSegments.general.disableStyleLint = true; +const entriesFeatureSegments = generateEntries(cfgFeatureSegments, 'scss'); + +describe('styles with fileNameDotSuffixesAsDistFolder [test/src-feature-segments]', () => { + beforeAll(async () => + await new Promise(async (r) => { + await styles(cfgFeatureSegments); + r(); + }) + ); + + Object.keys(entriesFeatureSegments).forEach((entry) => { + const file = path.join(cfgFeatureSegments.general.destinationPath, entry); + const source = entriesFeatureSegments[entry]; + const ext = path.extname(file) === '.js' ? 'js' : 'css'; + const fileName = `${file.split('.').slice(0, -1).join('.')}.${ext}`; + it(`Compile ${source} file and save ${entry} at destination folder`, async () => { + const bundleContent = fs.readFileSync(fileName, { encoding: 'utf8', flag: 'r' }); + const sourceContent = fs.readFileSync(source, { encoding: 'utf8', flag: 'r' }); + expect(bundleContent).not.toBe(sourceContent); + }); + }); +}); + +// ── Array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey → test/dist-multi-sourcekey] ── +const cfgMultiSourcekey = extendConfig('./test/.febuild', defaults); +cfgMultiSourcekey.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); +cfgMultiSourcekey.general.destinationPath = path.resolve('test/dist-multi-sourcekey'); +cfgMultiSourcekey.general.sourceKey = ['src', 'authorlibs', 'publishlibs']; +cfgMultiSourcekey.general.sourceKeyAsDistFolder = true; +cfgMultiSourcekey.general.disableStyleLint = true; +const entriesMultiSourcekey = generateEntries(cfgMultiSourcekey, 'scss'); + +describe('styles with array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey]', () => { + beforeAll(async () => + await new Promise(async (r) => { + await styles(cfgMultiSourcekey); + r(); + }) + ); + + Object.keys(entriesMultiSourcekey).forEach((entry) => { + const file = path.join(cfgMultiSourcekey.general.destinationPath, entry); + const source = entriesMultiSourcekey[entry]; + const ext = path.extname(file) === '.js' ? 'js' : 'css'; + const fileName = `${file.split('.').slice(0, -1).join('.')}.${ext}`; + it(`Compile ${source} file and save ${entry} at destination folder`, async () => { + const bundleContent = fs.readFileSync(fileName, { encoding: 'utf8', flag: 'r' }); + const sourceContent = fs.readFileSync(source, { encoding: 'utf8', flag: 'r' }); + expect(bundleContent).not.toBe(sourceContent); + }); + }); +}); + diff --git a/tasks/webpack.test.js b/tasks/webpack.test.js index f93c2c1..245dd00 100644 --- a/tasks/webpack.test.js +++ b/tasks/webpack.test.js @@ -36,5 +36,70 @@ describe('Test task/webpack.js', () => { }) }); +// ── fileNameDotSuffixesAsDistFolder [test/src-feature-segments → test/dist-feature-segments] ──── +const cfgFeatureSegments = extendConfig('./test/.febuild', defaults); +cfgFeatureSegments.general.sourcesPath = path.resolve('test/src-feature-segments'); +cfgFeatureSegments.general.destinationPath = path.resolve('test/dist-feature-segments'); +cfgFeatureSegments.general.fileNameDotSuffixesAsDistFolder = true; +const entriesFeatureSegments = generateEntries(cfgFeatureSegments, 'js'); + +describe('webpack with fileNameDotSuffixesAsDistFolder [test/src-feature-segments]', () => { + beforeAll(async () => { + return await new Promise(async (resolve, reject) => { + try { + await webpackTask(cfgFeatureSegments); + resolve(); + } catch (e) { + reject(e); + } + }); + }, 20000); + + Object.keys(entriesFeatureSegments).forEach((entry) => { + const file = path.join(cfgFeatureSegments.general.destinationPath, entry); + const source = entriesFeatureSegments[entry]; + const ext = path.extname(file) === '.js' ? 'js' : 'css'; + const fileName = `${file.split('.').slice(0, -1).join('.')}.${ext}`; + it(`Compile ${source} file and save ${entry} at destination folder`, async () => { + const bundleContent = fs.readFileSync(fileName, { encoding: 'utf8', flag: 'r' }); + const sourceContent = fs.readFileSync(source, { encoding: 'utf8', flag: 'r' }); + expect(bundleContent).not.toBe(sourceContent); + }); + }); +}); + +// ── Array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey → test/dist-multi-sourcekey] ── +const cfgMultiSourcekey = extendConfig('./test/.febuild', defaults); +cfgMultiSourcekey.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); +cfgMultiSourcekey.general.destinationPath = path.resolve('test/dist-multi-sourcekey'); +cfgMultiSourcekey.general.sourceKey = ['src', 'authorlibs', 'publishlibs']; +cfgMultiSourcekey.general.sourceKeyAsDistFolder = true; +const entriesMultiSourcekey = generateEntries(cfgMultiSourcekey, 'js'); + +describe('webpack with array sourceKey + sourceKeyAsDistFolder [test/src-multi-sourcekey]', () => { + beforeAll(async () => { + return await new Promise(async (resolve, reject) => { + try { + await webpackTask(cfgMultiSourcekey); + resolve(); + } catch (e) { + reject(e); + } + }); + }, 20000); + + Object.keys(entriesMultiSourcekey).forEach((entry) => { + const file = path.join(cfgMultiSourcekey.general.destinationPath, entry); + const source = entriesMultiSourcekey[entry]; + const ext = path.extname(file) === '.js' ? 'js' : 'css'; + const fileName = `${file.split('.').slice(0, -1).join('.')}.${ext}`; + it(`Compile ${source} file and save ${entry} at destination folder`, async () => { + const bundleContent = fs.readFileSync(fileName, { encoding: 'utf8', flag: 'r' }); + const sourceContent = fs.readFileSync(source, { encoding: 'utf8', flag: 'r' }); + expect(bundleContent).not.toBe(sourceContent); + }); + }); +}); + diff --git a/test/src-feature-segments/publish/publish.featured2.src.scss b/test/src-feature-segments/publish/publish.featured2.src.scss new file mode 100644 index 0000000..3aebb7d --- /dev/null +++ b/test/src-feature-segments/publish/publish.featured2.src.scss @@ -0,0 +1,12 @@ +@use 'variables'; + +body { + color: variables.$secondary-color; + background-color: variables.$primary-color; + + @media (min-resolution: 2dppx) { + .image { + background-image: url('image@2x.png'); + } + } +} diff --git a/test/src-feature-segments/publish/publish.featured3.src.scss b/test/src-feature-segments/publish/publish.featured3.src.scss new file mode 100644 index 0000000..3e23f3f --- /dev/null +++ b/test/src-feature-segments/publish/publish.featured3.src.scss @@ -0,0 +1,13 @@ +@use 'variables'; + +body { + color: variables.$secondary-color; + background-color: variables.$primary-color; + + @media (min-resolution: 2dppx) { + .image { + background-image: url('image@2x.png'); + background-image: url('image@2x.png'); + } + } +} diff --git a/test/src/publish/publish.src.scss b/test/src/publish/publish.src.scss index 3aebb7d..d63cd63 100644 --- a/test/src/publish/publish.src.scss +++ b/test/src/publish/publish.src.scss @@ -6,7 +6,7 @@ body { @media (min-resolution: 2dppx) { .image { - background-image: url('image@2x.png'); + background-image: url('imagsdsse@2x.png'); } } } diff --git a/utils/checkChunk.js b/utils/checkChunk.js index 8c51143..e1a8031 100644 --- a/utils/checkChunk.js +++ b/utils/checkChunk.js @@ -1,12 +1,18 @@ const includesInModules = (names = []) => (module) => names.filter((name) => module.includes(name)).length > 0; -// To check if a context is from a vendor -module.exports = function checkChunk(module, excludes = [], includes = []) { - // is not a external module so there is no context +// To check if a context is from a vendor. +// Accepts either a string path or a webpack module object +// (uses mod.resource first, falls back to mod.context). +module.exports = function checkChunk(moduleOrPath, excludes = [], includes = []) { + const module = (moduleOrPath && typeof moduleOrPath === 'object') + ? (moduleOrPath.resource || moduleOrPath.context || null) + : moduleOrPath; + + // external/virtual modules (e.g. webpack externals) have no resolvable path if (!module) { return false; } - + if (includesInModules(excludes)(module)) { return false; } diff --git a/utils/checkChunk.test.js b/utils/checkChunk.test.js index e7304e6..bf6f4df 100644 --- a/utils/checkChunk.test.js +++ b/utils/checkChunk.test.js @@ -6,6 +6,11 @@ const vendor2 = './mkFullPathSync/node_modules/core-js'; const regular = './regular/test' const excluded = ['babel', 'core-js'] +// Two files that share a directory — only distinguishable by filename +const sharedDir = './src/components/video'; +const publishFile = `${sharedDir}/video.main.publishlibs.js`; +const authorFile = `${sharedDir}/video.main.authorlibs.js`; + describe('Test utils/checkChunk.js', () => { @@ -26,4 +31,28 @@ describe('Test utils/checkChunk.js', () => { it(`Should return false if is not on included `, () => { expect(checkChunk(regular, [excluded], ['node_modules'])).toBe(false); }); -}); \ No newline at end of file + + it(`Should accept a webpack module object and use resource`, () => { + expect(checkChunk({ resource: vendor1 }, [], ['node_modules'])).toBe(true); + expect(checkChunk({ resource: vendor1 }, excluded, ['node_modules'])).toBe(false); + }); + + it(`Should fall back to context when resource is absent`, () => { + expect(checkChunk({ context: vendor2 }, [], ['node_modules'])).toBe(true); + }); + + it(`Should return false for an empty module object`, () => { + expect(checkChunk({}, [], ['node_modules'])).toBe(false); + }); + + it(`Should match by filename when two files share a directory`, () => { + // context alone would match both — resource lets us target only publishlibs + expect(checkChunk({ resource: publishFile, context: sharedDir }, [], ['.publishlibs.'])).toBe(true); + expect(checkChunk({ resource: authorFile, context: sharedDir }, [], ['.publishlibs.'])).toBe(false); + }); + + it(`Should match by filename using a string path (resource equivalent)`, () => { + expect(checkChunk(publishFile, [], ['.publishlibs.'])).toBe(true); + expect(checkChunk(authorFile, [], ['.publishlibs.'])).toBe(false); + }); +}); diff --git a/utils/generateEntries.js b/utils/generateEntries.js index 21c7828..9cc41fa 100644 --- a/utils/generateEntries.js +++ b/utils/generateEntries.js @@ -1,19 +1,54 @@ const glob = require('fast-glob'); const path = require('path'); +function buildGlobPattern(filenamePattern, extension) { + if (Array.isArray(filenamePattern)) { + return `**/*.{${filenamePattern.join(',')}}.${extension}`; + } + return `**/*.${filenamePattern}.${extension}`; +} + +function isExcluded(segment, exclusions) { + return exclusions.some(rule => + rule instanceof RegExp ? rule.test(segment) : rule === segment + ); +} + +function buildDestFile(file, bundleKey, fileNameDotSuffixesAsDistFolder, sourceKeyAsDistFolder, excludeFileNameDotSuffixes = []) { + const dir = path.dirname(file); + const basename = path.basename(file); + const parts = basename.split('.'); + const ext = parts[parts.length - 1]; + const matchedSourceKey = parts[parts.length - 2]; + const prefix = parts[0]; + const featureSegments = parts.slice(1, parts.length - 2); + + const distFolders = [ + ...(fileNameDotSuffixesAsDistFolder + ? featureSegments.filter(s => !isExcluded(s, excludeFileNameDotSuffixes)) + : []), + ...(sourceKeyAsDistFolder && !isExcluded(matchedSourceKey, excludeFileNameDotSuffixes) + ? [matchedSourceKey] + : []), + ]; + + const distFileNameParts = [prefix, ...featureSegments, bundleKey, ext]; + const distFileName = distFileNameParts.join('.'); + + return path.join(dir, ...distFolders, distFileName); +} + module.exports = function generateEntries(config, extension = 'js', filenamePattern = config.general.sourceKey, cwd = config.general.sourcesPath) { - const sourcePattern = `**/*.${filenamePattern}.${extension}`; + const sourcePattern = buildGlobPattern(filenamePattern, extension); const sourcesFiles = glob.sync(sourcePattern, { cwd: cwd }); // if is multiple entries if (config && config.general && config.general.multiple) { const sources = {}; + const { bundleKey, fileNameDotSuffixesAsDistFolder = false, sourceKeyAsDistFolder = false, excludeFileNameDotSuffixes = [] } = config.general; sourcesFiles.forEach((file) => { - const dir = path.dirname(file); - const fileName = path.basename(file); - const destFileName = fileName.replace(filenamePattern, config.general.bundleKey); - const destFile = path.join(dir, destFileName); + const destFile = buildDestFile(file, bundleKey, fileNameDotSuffixesAsDistFolder, sourceKeyAsDistFolder, excludeFileNameDotSuffixes); sources[destFile] = path.join(cwd, file); }); diff --git a/utils/generateEntries.test.js b/utils/generateEntries.test.js index 44a2e4f..81e7f6e 100644 --- a/utils/generateEntries.test.js +++ b/utils/generateEntries.test.js @@ -1,11 +1,12 @@ process.argv.push('--quiet'); +const path = require('path'); const defaults = require('../config'); const extendConfig = require('./extendConfig'); const generateEntries = require('./generateEntries'); const config = extendConfig('./test/.febuild', defaults); - + describe('Test utils/generateEntries.js', () => { it('Should throw an error if there is no config', () => { expect(generateEntries).toThrowError(); @@ -20,7 +21,7 @@ describe('Test utils/generateEntries.js', () => { const entries = generateEntries(config); expect(Object.keys(entries).length).toBe(2); }); - + it('Should find 3 SCSS entries at ./test', () => { const entries = generateEntries(config,'scss'); expect(Object.keys(entries).length).toBe(3); @@ -43,4 +44,217 @@ describe('Test utils/generateEntries.js', () => { const entries = generateEntries(config); expect(Array.isArray(entries)).toBe(true); }); -}) \ No newline at end of file +}) + +describe('sourceKeyAsDistFolder flag [test/src]', () => { + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourceKeyAsDistFolder = true; + }); + + it('Dist entry keys include the sourceKey as a folder level', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.length).toBe(2); + expect(keys.every(k => k.includes(path.sep + 'src' + path.sep))).toBe(true); + }); + + it('Dist filename does not contain the sourceKey segment', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.every(k => !path.basename(k).includes('.src.'))).toBe(true); + }); +}); + +describe('fileNameDotSuffixesAsDistFolder flag [test/src-feature-segments]', () => { + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourcesPath = path.resolve('test/src-feature-segments'); + }); + + it('When false (default), feature segments stay in the filename only, not expanded to folders', () => { + cfg.general.fileNameDotSuffixesAsDistFolder = false; + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.length).toBe(2); + expect(keys.every(k => !k.includes(path.sep + 'featured' + path.sep))).toBe(true); + expect(keys.every(k => path.basename(k).includes('featured'))).toBe(true); + }); + + it('When true, feature segments are expanded to folder levels in the dist entry key', () => { + cfg.general.fileNameDotSuffixesAsDistFolder = true; + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.length).toBe(2); + expect(keys.every(k => k.includes(path.sep + 'featured' + path.sep))).toBe(true); + }); +}); + +describe('Array sourceKey [test/src-multi-sourcekey]', () => { + const multiSourceKey = ['src', 'authorlibs', 'publishlibs']; + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourceKey = multiSourceKey; + cfg.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); + }); + + it('Glob matches files for all sourceKey values in the array', () => { + cfg.general.multiple = false; + const entries = generateEntries(cfg); + expect(Array.isArray(entries)).toBe(true); + // author.src.js, author.authorlibs.js, publish.src.js, publish.publishlibs.js + expect(entries.length).toBe(4); + }); + + it('Without sourceKeyAsDistFolder, files with different sourceKeys collide on the same dist key', () => { + const entries = generateEntries(cfg); + // 4 source files but only 2 unique dist keys: author/author.dist.js, publish/publish.dist.js + expect(Object.keys(entries).length).toBe(2); + }); + + it('With sourceKeyAsDistFolder true, each sourceKey gets its own folder, resolving key collisions', () => { + cfg.general.sourceKeyAsDistFolder = true; + const entries = generateEntries(cfg); + // author/src/author.dist.js, author/authorlibs/author.dist.js, + // publish/src/publish.dist.js, publish/publishlibs/publish.dist.js + expect(Object.keys(entries).length).toBe(4); + }); +}); + +describe('excludeFileNameDotSuffixes - sourceKey exclusion [test/src-multi-sourcekey]', () => { + const multiSourceKey = ['src', 'authorlibs', 'publishlibs']; + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourceKey = multiSourceKey; + cfg.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); + cfg.general.sourceKeyAsDistFolder = true; + cfg.general.excludeFileNameDotSuffixes = ['src']; + }); + + it('Excluded sourceKey value produces flat entry (no subfolder)', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + // author.src.js and publish.src.js should NOT have a src/ subfolder + const srcKeys = keys.filter(k => path.basename(k).startsWith('author') || path.basename(path.dirname(k)) === 'author'); + expect(keys.some(k => k.includes(path.sep + 'src' + path.sep))).toBe(false); + }); + + it('Non-excluded sourceKey values still produce subfolders', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.some(k => k.includes(path.sep + 'authorlibs' + path.sep))).toBe(true); + expect(keys.some(k => k.includes(path.sep + 'publishlibs' + path.sep))).toBe(true); + }); + + it('All 4 source files produce 4 unique dist keys (no collision despite mixed exclusion)', () => { + const entries = generateEntries(cfg); + expect(Object.keys(entries).length).toBe(4); + }); +}); + +describe('excludeFileNameDotSuffixes - feature segment exclusion [test/src-feature-segments]', () => { + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourcesPath = path.resolve('test/src-feature-segments'); + cfg.general.fileNameDotSuffixesAsDistFolder = true; + cfg.general.excludeFileNameDotSuffixes = ['featured']; + }); + + it('Excluded feature segment is not promoted to a folder', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.every(k => !k.includes(path.sep + 'featured' + path.sep))).toBe(true); + }); + + it('Excluded feature segment remains in the dist filename', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.every(k => path.basename(k).includes('featured'))).toBe(true); + }); + + it('Entry count is the same as without the flag (2 JS entries)', () => { + const entries = generateEntries(cfg); + expect(Object.keys(entries).length).toBe(2); + }); +}); + +describe('excludeFileNameDotSuffixes - RegExp pattern exclusion [test/src-feature-segments]', () => { + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourcesPath = path.resolve('test/src-feature-segments'); + cfg.general.fileNameDotSuffixesAsDistFolder = true; + }); + + it('RegExp rule excludes all matching variants from folder promotion (featured, featured2, featured3)', () => { + cfg.general.excludeFileNameDotSuffixes = [/^featured\d*$/]; + // src-feature-segments has featured, featured2, featured3 SCSS segments + const entries = generateEntries(cfg, 'scss'); + const keys = Object.keys(entries); + expect(keys.length).toBe(5); + expect(keys.every(k => !k.includes(path.sep + 'featured' + path.sep))).toBe(true); + expect(keys.every(k => !k.includes(path.sep + 'featured2' + path.sep))).toBe(true); + expect(keys.every(k => !k.includes(path.sep + 'featured3' + path.sep))).toBe(true); + }); + + it('RegExp-excluded segments remain in the dist filename', () => { + cfg.general.excludeFileNameDotSuffixes = [/^featured\d*$/]; + const entries = generateEntries(cfg, 'scss'); + const keys = Object.keys(entries); + expect(keys.every(k => path.basename(k).includes('featured'))).toBe(true); + }); + + it('A non-matching RegExp does not exclude anything', () => { + cfg.general.excludeFileNameDotSuffixes = [/^v\d+$/]; + const entries = generateEntries(cfg, 'scss'); + const keys = Object.keys(entries); + // featured/featured2/featured3 not excluded — they get promoted to folders + expect(keys.some(k => k.includes(path.sep + 'featured' + path.sep))).toBe(true); + }); + + it('Mixed string and RegExp entries both apply', () => { + // string covers 'featured'; regex covers 'featured2' and 'featured3' + cfg.general.excludeFileNameDotSuffixes = ['featured', /^featured\d+$/]; + const entries = generateEntries(cfg, 'scss'); + const keys = Object.keys(entries); + expect(keys.length).toBe(5); + expect(keys.every(k => !k.includes(path.sep + 'featured' + path.sep))).toBe(true); + expect(keys.every(k => !k.includes(path.sep + 'featured2' + path.sep))).toBe(true); + expect(keys.every(k => !k.includes(path.sep + 'featured3' + path.sep))).toBe(true); + }); +}); + +describe('excludeFileNameDotSuffixes - RegExp sourceKey exclusion [test/src-multi-sourcekey]', () => { + const multiSourceKey = ['src', 'authorlibs', 'publishlibs']; + let cfg; + beforeEach(() => { + cfg = extendConfig('./test/.febuild', defaults); + cfg.general.sourceKey = multiSourceKey; + cfg.general.sourcesPath = path.resolve('test/src-multi-sourcekey'); + cfg.general.sourceKeyAsDistFolder = true; + cfg.general.excludeFileNameDotSuffixes = [/^src$/]; + }); + + it('RegExp-excluded sourceKey value produces flat entry (no subfolder)', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.some(k => k.includes(path.sep + 'src' + path.sep))).toBe(false); + }); + + it('Non-excluded sourceKey values still produce subfolders', () => { + const entries = generateEntries(cfg); + const keys = Object.keys(entries); + expect(keys.some(k => k.includes(path.sep + 'authorlibs' + path.sep))).toBe(true); + expect(keys.some(k => k.includes(path.sep + 'publishlibs' + path.sep))).toBe(true); + }); + + it('All 4 source files produce 4 unique dist keys', () => { + const entries = generateEntries(cfg); + expect(Object.keys(entries).length).toBe(4); + }); +}); diff --git a/utils/renderClientLibs.js b/utils/renderClientLibs.js index d28a6ca..009fa42 100644 --- a/utils/renderClientLibs.js +++ b/utils/renderClientLibs.js @@ -44,4 +44,4 @@ module.exports = function renderClientLibs(clientLibObject, config) { // write .content.xml const content = clientlibTemplate(name, projectKey); writeFile(path.join(absolutePath, '.content.xml'), content, override); -}; \ No newline at end of file +}; diff --git a/utils/renderSass.js b/utils/renderSass.js index 7eeb920..17edad6 100644 --- a/utils/renderSass.js +++ b/utils/renderSass.js @@ -18,7 +18,7 @@ module.exports = function renderSass(dest, file, config, cb, write = false) { // extract from config const compiled = sass.compileAsync(file, { outputStyle, - loadPaths:includePaths, + loadPaths: includePaths, sourceMap: !config.general.isProduction, // adicional config from https://sass-lang.com/documentation/js-api/interfaces/options/ ...adicionalOptions From 68ad0c348c44ac449e679e7e35c25b244ebc21d6 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Fri, 29 May 2026 16:05:19 +0300 Subject: [PATCH 3/9] docs: update docs for excludeFileNameDotSuffixes option --- docs/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e0e083b..d006228 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -191,7 +191,7 @@ Both flags default to `false` - existing projects are unaffected unless they opt ##### `excludeFileNameDotSuffixes` -When using `sourceKeyAsDistFolder` or `fileNameDotSuffixesAsDistFolder`, every matched value is promoted to a folder by default. `excludeFileNameDotSuffixes` lets you opt specific values out of that promotion while keeping them as valid source keys. +When using `sourceKeyAsDistFolder` or `fileNameDotSuffixesAsDistFolder`, every matched value is promoted to a folder by default. `excludeFileNameDotSuffixes` lets you opt specific values out of that promotion while keeping them as valid source keys. Each entry can be a plain string (exact match) or a `RegExp` (pattern match). **Motivating scenario:** A project migrating from `sourceKey: 'clientlibs'` to `sourceKey: ['clientlibs', 'publishlibs', 'authorlibs']` wants folder promotion only for the new values. The existing `*.clientlibs.*` files should continue producing flat dist entries without renaming: @@ -200,7 +200,7 @@ module.exports = { general: { sourceKey: ['clientlibs', 'publishlibs', 'authorlibs'], sourceKeyAsDistFolder: true, - excludeFileNameDotSuffixes: ['clientlibs'], + excludeFileNameDotSuffixes: ['clientlibs', /^v\d+$/], // exact string or RegExp - e.g. /^v\d+$/ matches v1, v2, v10 ... } } ``` From bfb2196c0142bc65aaf0a05e2a7ceea520b6673d Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Fri, 29 May 2026 16:34:14 +0300 Subject: [PATCH 4/9] build: update package-lock file --- package-lock.json | 50 ++++++++++------------------------------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b1693e..7ce76d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@babel/preset-env": "^7.26.7", "autoprefixer": "^10.4.20", "babel-loader": "9.1.2", - "chokidar": "^5.0.0", + "chokidar": "^4.0.3", "core-js": "^3.40.0", "eslint": "^8.57.1", "eslint-webpack-plugin": "^4.2.0", @@ -3329,15 +3329,14 @@ } }, "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dependencies": { - "readdirp": "^5.0.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 20.19.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5973,12 +5972,11 @@ "license": "MIT" }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "engines": { - "node": ">= 20.19.0" + "node": ">= 14.18.0" }, "funding": { "type": "individual", @@ -6208,34 +6206,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", From 245e7d842bbfa0bee5b8ec700481b3367a31bf41 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Tue, 2 Jun 2026 17:30:05 +0300 Subject: [PATCH 5/9] fix(styles): prevent watch from triggering on unlink and non-file events --- tasks/styles.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tasks/styles.js b/tasks/styles.js index 81e5101..db097dd 100644 --- a/tasks/styles.js +++ b/tasks/styles.js @@ -21,14 +21,17 @@ module.exports = async (config) => { ignoreInitial: true }); - watcher.on('all', (event, file) => { + config.stylelint.failOnError = false; + + const handleChange = (file) => { const destFile = srcToDest[file]; if (!destFile) return; - config.stylelint.failOnError = false; - renderStyles(file, destFile, config); - }); + }; + + watcher.on('add', handleChange); + watcher.on('change', handleChange); } catch (e) { log(__filename, 'Something is missing, you need install dev dependencies for this.', e.message, 'error'); From 78bcf7052797ff2055b746a8423be5d6d7905e76 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Tue, 2 Jun 2026 17:34:44 +0300 Subject: [PATCH 6/9] fix: guard against array return in generateEntries scss and malformed split chunk clientlibs --- tasks/clientlibs.js | 26 ++++++++++++++++++++++---- utils/generateEntries.js | 14 ++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/tasks/clientlibs.js b/tasks/clientlibs.js index 19231a3..f4695cd 100644 --- a/tasks/clientlibs.js +++ b/tasks/clientlibs.js @@ -74,11 +74,29 @@ module.exports = (config) => { ); } else { const [chunkName] = chunks; - const { name, folder, fileName, extension } = getClientlib(chunkName); - if (!clientLibs[folder]) { - clientLibs[folder] = { name, folder }; + const validExtensions = ['js', 'css']; + const chunkExt = chunkName.split('.').pop(); + if (path.dirname(chunkName) === '.' || !validExtensions.includes(chunkExt)) { + log( + __filename, + `Skipping split chunk clientlib for "${chunkName}" - chunk name must include a subfolder and a .js/.css extension (e.g. "commons/runtime.js").`, + '', + 'warning' + ); + } else { + const { name, folder, fileName, extension } = getClientlib(chunkName); + if (clientLibs[folder]) { + log( + __filename, + `Skipping split chunk clientlib for "${chunkName}" - folder "${folder}" is already registered by a source entry.`, + '', + 'warning' + ); + } else { + clientLibs[folder] = { name, folder }; + clientLibs[folder][extension] = fileName; + } } - clientLibs[folder][extension] = fileName; } }); } diff --git a/utils/generateEntries.js b/utils/generateEntries.js index 9cc41fa..b08f36a 100644 --- a/utils/generateEntries.js +++ b/utils/generateEntries.js @@ -42,13 +42,23 @@ module.exports = function generateEntries(config, extension = 'js', filenamePatt const sourcePattern = buildGlobPattern(filenamePattern, extension); const sourcesFiles = glob.sync(sourcePattern, { cwd: cwd }); - // if is multiple entries - if (config && config.general && config.general.multiple) { + const isMultiple = config && config.general && config.general.multiple; + + // The multiple: false - option doesn't have a clear implemented use case + if (isMultiple || extension === 'scss') { const sources = {}; const { bundleKey, fileNameDotSuffixesAsDistFolder = false, sourceKeyAsDistFolder = false, excludeFileNameDotSuffixes = [] } = config.general; sourcesFiles.forEach((file) => { const destFile = buildDestFile(file, bundleKey, fileNameDotSuffixesAsDistFolder, sourceKeyAsDistFolder, excludeFileNameDotSuffixes); + if (sources[destFile]) { + throw new Error( + `generateEntries: two source files resolve to the same destination "${destFile}":\n` + + ` - ${sources[destFile]}\n` + + ` - ${path.join(cwd, file)}\n` + + `Enable "sourceKeyAsDistFolder: true" or check "excludeFileNameDotSuffixes" to prevent the collision.` + ); + } sources[destFile] = path.join(cwd, file); }); From ac63d4f1dfb557d30fa34dbac7c2b6c15da05c58 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Tue, 2 Jun 2026 17:35:25 +0300 Subject: [PATCH 7/9] test(clientlibs): add coverage for dirless chunk and folder collision guards --- tasks/clientlibs.test.js | 43 +++++++++++++++++++++++++++++++++++ utils/generateEntries.test.js | 8 +++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tasks/clientlibs.test.js b/tasks/clientlibs.test.js index fb2f623..ff8c910 100644 --- a/tasks/clientlibs.test.js +++ b/tasks/clientlibs.test.js @@ -197,6 +197,49 @@ describe('generateSplitChunksClientlibs - unique subfolders generate metadata', }); }); +// -- generateSplitChunksClientlibs - dirless chunk (no subfolder) is skipped (Issue 4) +const cfgDirlessChunk = extendConfig('./test/.febuild', defaults); +cfgDirlessChunk.general.destinationPath = path.resolve('test/dist-splitchunks-dirless'); +cfgDirlessChunk.clientlibs.generateSplitChunksClientlibs = true; +cfgDirlessChunk.optimization = { + ...cfgDirlessChunk.optimization, + runtimeChunk: { name: 'runtime' }, // no subfolder, no extension +}; + +describe('generateSplitChunksClientlibs - dirless chunk is skipped', () => { + beforeAll(async () => { + await clientlibTask(cfgDirlessChunk); + }); + + it('No .content.xml written to dist root for a dirless chunk', () => { + const xmlPath = path.join(cfgDirlessChunk.general.destinationPath, '.content.xml'); + expect(fs.existsSync(xmlPath)).toBe(false); + }); +}); + +// -- generateSplitChunksClientlibs - chunk folder collides with source entry is skipped (Issue 5) +// source entries produce author/author.dist.js -> folder "author"; chunk author/runtime.bundle.js +// resolves to the same folder and must be skipped, leaving js.txt untouched. +const cfgChunkSrcCollision = extendConfig('./test/.febuild', defaults); +cfgChunkSrcCollision.general.destinationPath = path.resolve('test/dist-splitchunks-srccollision'); +cfgChunkSrcCollision.clientlibs.generateSplitChunksClientlibs = true; +cfgChunkSrcCollision.optimization = { + ...cfgChunkSrcCollision.optimization, + runtimeChunk: { name: 'author/runtime.bundle.js' }, // folder "author" already owned by source entry +}; + +describe('generateSplitChunksClientlibs - chunk folder colliding with source entry is skipped', () => { + beforeAll(async () => { + await clientlibTask(cfgChunkSrcCollision); + }); + + it('js.txt in author/ still points to the source-derived file, not the chunk', () => { + const txtPath = path.join(cfgChunkSrcCollision.general.destinationPath, 'author', 'js.txt'); + const content = fs.readFileSync(txtPath, { encoding: 'utf8', flag: 'r' }); + expect(content).toBe('author.dist.js'); + }); +}); + diff --git a/utils/generateEntries.test.js b/utils/generateEntries.test.js index 81e7f6e..59f8248 100644 --- a/utils/generateEntries.test.js +++ b/utils/generateEntries.test.js @@ -109,10 +109,10 @@ describe('Array sourceKey [test/src-multi-sourcekey]', () => { expect(entries.length).toBe(4); }); - it('Without sourceKeyAsDistFolder, files with different sourceKeys collide on the same dist key', () => { - const entries = generateEntries(cfg); - // 4 source files but only 2 unique dist keys: author/author.dist.js, publish/publish.dist.js - expect(Object.keys(entries).length).toBe(2); + it('Without sourceKeyAsDistFolder, files with different sourceKeys collide on the same dist key and throw', () => { + expect(() => generateEntries(cfg)).toThrow( + /two source files resolve to the same destination/ + ); }); it('With sourceKeyAsDistFolder true, each sourceKey gets its own folder, resolving key collisions', () => { From d041febbe9f9bd88063ca0ffa3c7a631e209d5a3 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Tue, 2 Jun 2026 17:36:18 +0300 Subject: [PATCH 8/9] build: use glob pattern in clean script to cover all test dist folders --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 789f709..1b9c7a9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/Netcentric/fe-build.git " }, "scripts": { - "clean": "rm -rf ./test/dist ./test/dist-feature-segments ./test/dist-multi-sourcekey ./test/dist-splitchunks ./test/dist-splitchunks-collision", + "clean": "rm -rf ./test/dist*", "test": "npm run clean && npm run jest", "jest": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest -u --runInBand" }, From 41a15a489f97a1fbdc7fcc6f8b81232683cd7eae Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Tue, 2 Jun 2026 17:36:32 +0300 Subject: [PATCH 9/9] docs(checkChunk): clarify why mod.resource is preferred over mod.context --- utils/checkChunk.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/checkChunk.js b/utils/checkChunk.js index e1a8031..c6716e8 100644 --- a/utils/checkChunk.js +++ b/utils/checkChunk.js @@ -1,8 +1,10 @@ const includesInModules = (names = []) => (module) => names.filter((name) => module.includes(name)).length > 0; // To check if a context is from a vendor. -// Accepts either a string path or a webpack module object -// (uses mod.resource first, falls back to mod.context). +// Accepts either a string path or a webpack module object. +// mod.resource (full file path incl. filename) is preferred over mod.context (directory only) +// so that users can optionally target files by source key or file dot suffix (which are part +// of the filename) when configuring chunk grouping. Falls back to mod.context for directory-only modules. module.exports = function checkChunk(moduleOrPath, excludes = [], includes = []) { const module = (moduleOrPath && typeof moduleOrPath === 'object') ? (moduleOrPath.resource || moduleOrPath.context || null)