Skip to content

feat: Add white-label build pipeline for plugin forks#245

Draft
jnealey-godaddy wants to merge 4 commits intomainfrom
claude/white-label-support
Draft

feat: Add white-label build pipeline for plugin forks#245
jnealey-godaddy wants to merge 4 commits intomainfrom
claude/white-label-support

Conversation

@jnealey-godaddy
Copy link
Owner

Summary

  • Adds a config-driven build pipeline that transforms all branded identifiers at build time, so forkers edit one file (white-label.json) and run npm run white-label:build to get a fully rebranded plugin in dist/
  • Source files stay untouched — forkers can pull upstream updates cleanly
  • Includes wp-env toggle for local testing of the white-labeled build

What gets rebranded

Plugin name, text domain, block namespace (designsetgo/accordionmyblocks/accordion), CSS prefix (dsgo-mb-), short prefix (dsgFadeInmbFadeIn), PHP namespace, constants, function prefixes, REST routes, pattern categories, post type slug, meta keys, option names, file names, and plugin header metadata.

How it works

  1. Webpack phasestring-replace-loader transforms JS/JSX at compile time; custom loader transforms block.json files
  2. Post-build phase — Node script copies PHP, patterns, CSS, and remaining JS/JSON to dist/, applying transformations and renaming branded filenames
  3. No white-label.json = no-op — default builds are completely unaffected

New npm scripts

Script Purpose
white-label:build Validate config + webpack build + post-build transform to dist/
white-label:zip Build + package as installable .zip
white-label:validate Validate config only
white-label:clean Remove dist/
wp-env:white-label Build + enable wp-env with dist/
wp-env:white-label:reset Revert wp-env to source

Verified

  • Zero remaining branded references in dist/ across PHP, JS, CSS, and JSON (excluding vendor/ and languages/)
  • Default build (no white-label.json) produces identical output to current behavior
  • File renaming works (e.g., class-dsgo-handlers.phpclass-mb-handlers.php)

Test plan

  • Delete white-label.json, run npm run build — confirm output matches current behavior
  • Copy white-label.json.example to white-label.json, run npm run white-label:build
  • Verify grep -r "designsetgo\|dsgo-\|_dsg_\|DesignSetGo\|DESIGNSETGO" dist/ returns zero results (excluding vendor/languages)
  • Run npm run wp-env:white-label then npm run wp-env:start — plugin should activate without errors
  • Verify blocks register and render in the editor
  • Run npm run wp-env:white-label:reset to revert
  • Run npm run white-label:zip — verify installable zip is produced

🤖 Generated with Claude Code

jnealey-godaddy and others added 2 commits February 13, 2026 10:15
Forkers can now rebrand the entire plugin by creating a white-label.json
config file and running `npm run white-label:build`. All branded identifiers
(plugin name, text domain, block namespace, CSS prefix, PHP namespace,
constants, REST routes, pattern categories) are transformed at build time.
Source files stay untouched, so forks can pull upstream updates cleanly.

New files:
- white-label.json.example: Documented config template
- white-label.schema.json: JSON Schema for IDE autocomplete/validation
- scripts/white-label-replacements.js: Shared replacement rules
- scripts/white-label-validate.js: Config validation
- scripts/white-label-json-loader.js: Webpack block.json loader
- scripts/white-label-php.js: Post-build PHP/pattern/CSS transformer
- scripts/white-label-zip.js: Zip packaging utility
- scripts/wp-env-white-label.js: wp-env toggle for testing

New npm scripts:
- white-label:build — Full rebranded build to dist/
- white-label:zip — Build + package as installable zip
- white-label:validate — Validate config
- wp-env:white-label — Build + enable wp-env with dist/
- wp-env:white-label:reset — Revert wp-env to source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Auto-fix prettier formatting and add eslint-disable no-console
for CLI scripts that legitimately need console output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 13, 2026 17:18
@claude
Copy link

claude bot commented Feb 13, 2026

Claude finished @jnealey88's task in 3m 8s —— View job


Code Review Complete

I've completed a comprehensive security and code quality review of the white-label build pipeline. The implementation is well-structured overall, but there are several security issues and code quality improvements needed.


Summary

Issues Found: 12 total

  • Critical: 0
  • High: 2 (Path traversal, Command injection risk)
  • Medium: 3 (JSON parsing, Race condition, Error recovery)
  • Low: 7 (Validation, error handling, code quality)

Critical Issues Requiring Immediate Attention

🔴 High Severity Issues

  1. Path Traversal Vulnerability (scripts/white-label-php.js:279)

    • User-controlled config.logoPath is used directly in file operations without validation
    • Could allow copying sensitive files (.env, credentials) into the dist build
    • Fix: Validate that the resolved path stays within the project directory
  2. Command Injection Risk (scripts/white-label-zip.js:58)

    • While execFileSync is safer than exec, defense-in-depth requires validating pluginSlug before use
    • Scripts can be run independently, bypassing the validation script
    • Fix: Add validation at point of use

🟡 Medium Severity Issues

  1. JSON Parsing Vulnerability (scripts/white-label-replacements.js:65)

    • No error handling or type validation for JSON.parse()
    • Could crash on invalid JSON or cause errors with non-object values
    • Fix: Add try-catch and validate parsed value is an object
  2. Race Condition (scripts/white-label-php.js:157)

    • Check-then-delete pattern creates potential race condition
    • Fix: Remove redundant existsSync check (force:true already handles it)
  3. Error Recovery Issue (scripts/white-label-zip.js:55)

    • If zip command fails after renaming dist/, cleanup might fail
    • Could leave directory in wrong state
    • Fix: Use symlink instead of rename, or validate zip exists first

Additional Issues (Lower Priority)

Code Quality & Maintainability

  1. Missing Error Handling (scripts/white-label-php.js:103)

    • File operations lack try-catch blocks
    • Build will crash with unhelpful errors if file operations fail
  2. Inefficient Array Operations (webpack.config.js:73)

    • Multiple filter/map operations on same array
    • Empty rules might be added to webpack config
  3. Missing Validation Logic (scripts/white-label-validate.js:197)

    • Comment mentions prefix collision check but code is missing
    • Could lead to replacement conflicts
  4. Validation Inconsistency (scripts/white-label-validate.js:28, 107, 162)

    • JSON parsing lacks type checking
    • Meta key prefix pattern doesn't allow underscores throughout
    • CSS prefix "recommendation" is treated as hard error

Documentation

  1. Misleading Comment (webpack.config.js:67)

    • Comment about regex escaping could be clearer
  2. Filename Replacement Behavior (scripts/white-label-php.js:71)

    • Broad string replacement in filenames not documented
    • Could have unintended effects (likely acceptable but should be documented)

Positive Observations

Good architectural decisions:

  • Clear separation between webpack transformations and post-build transformations
  • Config validation runs before build
  • No-op when config matches defaults
  • Comprehensive replacement coverage (PHP, JS, CSS, block.json)
  • Good use of path.resolve() to prevent most path issues

Security considerations already in place:

  • Uses execFileSync instead of exec (prevents most command injection)
  • Validation script has strong regex patterns
  • Source files remain untouched (dist/ only)

Code quality:

  • Well-documented with clear comments
  • Logical file organization
  • Handles edge cases like missing vendor/ directory

Recommendations

Before Merging:

  1. Fix the High severity issues (path traversal and command injection defense)
  2. Add JSON parsing error handling (Medium severity)
  3. Add error handling to file operations (prevents cryptic build failures)

Nice to Have:

  1. Consider adding a comprehensive test suite for the transformation logic
  2. Add integration test that validates zero branded references in dist/
  3. Document the filename replacement behavior explicitly
  4. Add validation that zip command exists before attempting to create archive

Overall Assessment

This is a well-designed white-label system with good separation of concerns and comprehensive coverage of branded identifiers. The main concerns are:

  1. Security: Path traversal vulnerability needs immediate fix
  2. Robustness: Add error handling to prevent cryptic failures
  3. Defense in depth: Validate user input at point of use, not just at validation time

With the security fixes applied, this would be a solid, production-ready white-label build pipeline.


return null;
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Medium - JSON Parsing Vulnerability

Problem: JSON.parse() is called without error handling, and there's no validation that the parsed content is actually an object. If a malicious actor provides invalid JSON or a non-object value (like "string", null, 123, []), this will cause unhandled errors or unexpected behavior.

Security Impact:

  • Invalid JSON could crash the build process
  • Non-object JSON values (arrays, strings, primitives) would cause Object.keys() on line 70 to throw
  • No sanitization before using config values in file operations and string replacements

Fix:

let config;
try {
    config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
    if (!config || typeof config !== 'object' || Array.isArray(config)) {
        throw new Error('Config must be an object');
    }
} catch (err) {
    console.error(`Error loading white-label.json: ${err.message}`);
    return null;
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added try-catch with error message and type validation (must be a JSON object, not array/primitive). Returns null on failure so callers degrade gracefully.


// 10. Handle custom logo if specified.
if (config.logoPath && config.logoPath !== DEFAULTS.logoPath) {
const logoSrc = path.join(ROOT, config.logoPath);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: High - Path Traversal Vulnerability

Problem: User-controlled config.logoPath is concatenated directly into a file path without any validation or sanitization. An attacker could provide a path like ../../../../etc/passwd or .env to copy sensitive files into the dist build.

Security Impact:

  • Could expose sensitive files (.env, credentials, private keys)
  • Could copy arbitrary files from the system into the distributed plugin
  • The copied files would be included in the zip and potentially deployed to production

Fix:

if (config.logoPath && config.logoPath !== DEFAULTS.logoPath) {
    // Validate that logoPath doesn't escape the project directory
    const logoSrc = path.resolve(ROOT, config.logoPath);
    if (!logoSrc.startsWith(ROOT + path.sep) && logoSrc !== ROOT) {
        console.error(`Error: Logo path must be within project directory: ${config.logoPath}`);
        process.exit(1);
    }
    if (fs.existsSync(logoSrc)) {
        const logoDest = path.join(DIST, 'build', 'admin', 'assets');
        fs.mkdirSync(logoDest, { recursive: true });
        fs.copyFileSync(logoSrc, path.join(logoDest, path.basename(logoSrc)));
        console.log(`  Custom logo: ${config.logoPath}`);
    } else {
        console.warn(`  Warning: Logo file not found: ${config.logoPath}`);
    }
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Resolved path is validated against ROOT + path.sep before any file operation. Also renamed the copied logo to match the default filename (logo.png) so plugin code references stay valid.

fs.renameSync(DIST, tempDir);

try {
execFileSync('zip', ['-r', zipName, config.pluginSlug], {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: High - Command Injection Vulnerability

Problem: User-controlled config.pluginSlug is passed directly to execFileSync as an argument without validation. While execFileSync is safer than exec, if the validation in white-label-validate.js is bypassed or the config is manually edited after validation, a malicious plugin slug could potentially cause issues.

Security Impact:

  • Plugin slug could contain special characters that might be interpreted by the zip command
  • While the regex in validation prevents most issues, defense in depth requires validation here too

Fix:

// Validate plugin slug before using in command
if (!/^[a-z][a-z0-9-]*$/.test(config.pluginSlug)) {
    console.error(`Error: Invalid plugin slug format: ${config.pluginSlug}`);
    process.exit(1);
}

try {
    execFileSync('zip', ['-r', zipName, config.pluginSlug], {
        cwd: ROOT,
        stdio: 'pipe',
    });

Note: This is defense in depth - the validation script should catch this, but scripts can be run independently.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: execFileSync doesn't use a shell, so this isn't actually command injection — arguments are passed as an array, not interpolated into a shell string. That said, added defense-in-depth: pluginSlug is validated at point-of-use with the same regex pattern, in case the script is run standalone.


// Clean dist/ directory.
if (fs.existsSync(DIST)) {
fs.rmSync(DIST, { recursive: true, force: true });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Medium - Race Condition

Problem: There's a potential race condition between checking if DIST exists and removing it. If another process creates files in DIST between the existsSync check and rmSync, unexpected behavior could occur.

Fix: Use the force: true option (which you already have) but remove the existence check:

// Clean dist/ directory (force: true makes it safe even if it doesn't exist)
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });

The force: true option already handles the case where the directory doesn't exist, so the check is redundant.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed the existsSync check — rmSync with force:true handles non-existence. Also added a safety check that DIST is within ROOT before the rmSync call.


let config;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Error Handling Inconsistency

Problem: The JSON parsing error handling here is better than in white-label-replacements.js, but it still doesn't validate that the parsed value is an object.

Fix:

let config;
try {
    config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
    if (!config || typeof config !== 'object' || Array.isArray(config)) {
        throw new Error('Configuration must be a JSON object, not an array or primitive');
    }
} catch (err) {
    console.error(`Error: Invalid JSON in white-label.json:\n${err.message}`);
    process.exit(1);
}

config.cssPrefix &&
(config.cssPrefix.length < 2 || config.cssPrefix.length > 6)
) {
errors.push(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Validation Logic Flaw

Problem: The CSS prefix length validation (2-6 characters) only adds a warning to the errors array but doesn't actually enforce it as a requirement. However, very short prefixes (1 character) or very long ones could cause namespace collisions or readability issues.

Consider: Whether this should be a hard error instead of just adding to the errors array. Currently this is only a warning (based on "Recommended" in the message), but it gets added to the errors array which causes the script to exit with an error code.

Recommendation: Either make this a hard validation (change the message to "Must be 2-6 characters") or separate warnings from errors.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Separated into a warnings array that prints before errors but doesn't block the build.

* @param {string} name Original filename.
* @return {string} Renamed filename (unchanged if no branded prefixes found).
*/
function renameEntry(name) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Potential Filename Collision

Problem: The renameEntry function uses simple string replacement which could cause unintended replacements. For example, if a file is named my-designsetgo-test.php and the new textDomain is foo, it becomes my-foo-test.php. While this might be intentional, it could cause issues if there are legitimately named files that happen to contain the old prefix.

Consider:

  • Whether filename replacement should only apply to files that start with the branded prefix
  • Adding a comment explaining that any occurrence of the branded prefixes in filenames will be replaced

This is likely acceptable behavior for a white-label system, but worth documenting.

if (entry.isDirectory()) {
copyDirWithTransform(srcPath, destPath, transform, pattern);
} else if (pattern && pattern.test(entry.name)) {
const content = fs.readFileSync(srcPath, 'utf8');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Missing Error Handling

Problem: File operations (readFileSync, writeFileSync, copyFileSync) are performed without try-catch blocks. If any file operation fails (permissions, disk full, file locked), the entire build will crash with an unhandled exception.

Fix: Wrap the main execution in a try-catch:

try {
    const content = fs.readFileSync(srcPath, 'utf8');
    const transformed = transform(content, srcPath);
    fs.writeFileSync(destPath, transformed, 'utf8');
} catch (err) {
    console.error(`Error transforming ${srcPath}: ${err.message}`);
    throw err; // Re-throw to stop the build
}

Or at minimum, wrap the entire main execution block in a try-catch with a helpful error message.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Wrapped the entire main execution block (steps 1-10) in a try-catch that prints the error and exits with code 1.

fs.rmSync(tempDir, { recursive: true, force: true });
}

fs.renameSync(DIST, tempDir);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Medium - Error Recovery Issue

Problem: If execFileSync throws an exception after renaming dist/ to tempDir, the finally block will attempt to rename it back. However, if the error occurred because the zip command doesn't exist or another system-level issue, the finally block could fail too, leaving the directory in the wrong state.

Additional Issue: If the script is interrupted (SIGINT/SIGTERM) between the rename operations, the dist/ directory could be left with the wrong name.

Fix: Consider using a more robust approach:

const tempDir = path.resolve(ROOT, config.pluginSlug);

// Check if temp dir already exists (shouldn't happen), clean it.
if (fs.existsSync(tempDir)) {
    fs.rmSync(tempDir, { recursive: true, force: true });
}

// Create symlink instead of rename for safer cleanup
try {
    fs.symlinkSync(DIST, tempDir, 'junction');
    
    execFileSync('zip', ['-r', zipName, config.pluginSlug], {
        cwd: ROOT,
        stdio: 'pipe',
    });
    console.log(`\nCreated: ${zipName}`);
} catch (err) {
    console.error(`Error creating zip: ${err.message}`);
    process.exit(1);
} finally {
    // Always cleanup symlink
    if (fs.existsSync(tempDir)) {
        fs.unlinkSync(tempDir);
    }
}

Or validate that zip command exists before any file operations.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added recovery logic at script start: if temp dir exists without dist/, it's treated as a leftover from a previous crash and restored. Also added existsSync guard in the finally block.

// White-label: build additional webpack module rules for string replacement.
const whiteLabelModuleRules = [];
if (whiteLabelRules) {
// Escape regex-special characters so literal strings work correctly with flags: 'g'.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Documentation Issue

Problem: The comment says "literal strings work correctly with flags: 'g'" which is misleading. The reason for escaping is that string-replace-loader converts the string to a RegExp when you provide the flags option.

Recommendation: Clarify the comment:

// Escape regex-special characters for string-replace-loader.
// The 'flags: g' option causes string-replace-loader to convert the search string 
// into a RegExp, so we must escape special characters to match literally.
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');


// Transform JS/JSX files.
// flags: 'g' converts search string to a RegExp, so we must escape special chars.
whiteLabelModuleRules.push({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Code Quality

Problem: The webpack rule is always added even if there are no literal rules (after filtering). This means an empty multiple: [] array would be passed to string-replace-loader.

Recommendation: Only add the rule if there are matches:

const literalRules = whiteLabelRules
    .filter((rule) => !(rule.search instanceof RegExp))
    .map((rule) => ({
        search: escapeRegExp(rule.search),
        replace: rule.replace,
        flags: 'g',
    }));

if (literalRules.length > 0) {
    whiteLabelModuleRules.push({
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        loader: 'string-replace-loader',
        options: {
            multiple: literalRules,
        },
    });
}

maxLength('postTypeSlug', 'Post type slug', 20);

// Meta key prefix.
if (required('metaKeyPrefix', 'Meta key prefix')) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Incomplete Validation

Problem: The metaKeyPrefix validation allows an optional leading underscore with the pattern /^_?[a-z][a-z0-9]*$/, but this pattern doesn't allow underscores in the middle of the prefix. For example, _my_prefix would fail validation even though it's a valid meta key format.

Issue: The pattern requires lowercase letter after the optional underscore, then only lowercase letters and numbers - no additional underscores allowed.

Fix: If underscores should be allowed throughout (like in option prefixes), change to:

if (required('metaKeyPrefix', 'Meta key prefix')) {
    if (!/^_?[a-z][a-z0-9_]*$/.test(config.metaKeyPrefix)) {
        errors.push(
            `Meta key prefix ("${config.metaKeyPrefix}") is invalid. Must be lowercase with optional leading underscore and underscores allowed throughout.`
        );
    }
}

Note: This matches the pattern used for optionPrefix and phpFunctionPrefix.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Pattern updated to /^_?[a-z][a-z0-9_]*$/ to allow underscores mid-prefix.

Block attributes like dsgoAnimationEnabled, dsgoTextRevealColor, and
130+ other dsgo[A-Z] camelCase identifiers were not being replaced.
The existing dsg[A-Z] regex only matched the short prefix, not the
full CSS prefix in camelCase form.

Adds dsgo[A-Z] → ${cssPrefix}[A-Z] regex rule to all four replacement
builders (JS, PHP, block.json, CSS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a comprehensive white-label build system that enables forkers to rebrand the DesignSetGo plugin by editing a single configuration file (white-label.json) and running a build command. The transformation happens at build time via webpack loaders and post-build Node.js scripts, leaving source files unchanged so forks can cleanly pull upstream updates.

Changes:

  • Adds config-driven transformation system that replaces all branded identifiers (plugin name, text domain, block namespace, CSS prefixes, PHP namespace, constants, function names, REST routes, and metadata)
  • Implements two-phase transformation: webpack string-replace-loader for JS/JSX during compilation, and post-build Node.js scripts for PHP, CSS, patterns, and other assets
  • Provides npm scripts for validation, building, packaging as installable zip, and local wp-env testing of white-labeled builds

Reviewed changes

Copilot reviewed 10 out of 12 changed files in this pull request and generated 20 comments.

Show a summary per file
File Description
white-label.schema.json JSON Schema defining all configuration fields with validation patterns for each identifier type
white-label.json.example Example configuration file showing how to rebrand to "MyBlocks"
webpack.config.js Adds string-replace-loader rules for JS/JSX transformation and custom loader for block.json files; includes cache invalidation for white-label config changes
scripts/white-label-replacements.js Core module defining default values and building replacement rules for different file types (JS, PHP, CSS, JSON); provides applyReplacements utility
scripts/white-label-validate.js Validates white-label.json against patterns and cross-field requirements before build
scripts/white-label-php.js Post-build script that copies and transforms PHP, CSS, patterns, and other assets from source to dist/ with filename renaming
scripts/white-label-json-loader.js Custom webpack loader that transforms block.json files during webpack compilation
scripts/white-label-zip.js Creates installable WordPress plugin zip from dist/ directory
scripts/wp-env-white-label.js Toggles wp-env to use white-labeled dist/ build for local testing
package.json Adds white-label npm scripts and string-replace-loader dependency
package-lock.json Lockfile update for string-replace-loader dependency
.gitignore Ignores dist/ directory and white-label.json configuration file

Comment on lines +43 to +52
/**
* Convert a hyphenated slug to camelCase.
* E.g., "my-blocks" → "myBlocks", "myblocks" → "myblocks"
*
* @param {string} slug Hyphenated slug.
* @return {string} camelCase version.
*/
function toCamelCase(slug) {
return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The toCamelCase function has a JSDoc comment but the description could be more precise. The function doesn't convert all strings to camelCase - it specifically handles hyphen-delimited strings. For example, "my_blocks" would not be converted to "myBlocks", and "MyBlocks" would remain "MyBlocks".

Consider updating the JSDoc to be more specific: "Convert a hyphen-delimited slug to camelCase by capitalizing the first character after each hyphen and removing the hyphens."

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 498
/**
* White Label Replacement Rules
*
* Centralized replacement definitions shared by webpack loaders
* and the post-build PHP/pattern transformer.
*
* @package
*/

'use strict';

const path = require('path');
const fs = require('fs');

/**
* Default values matching the current DesignSetGo branding.
* When config matches these, no transformation occurs.
*/
const DEFAULTS = {
pluginName: 'DesignSetGo',
pluginSlug: 'designsetgo',
pluginUri: 'https://designsetgoblocks.com',
pluginDescription:
'Professional Gutenberg block library with 52 blocks and 16 powerful extensions - complete Form Builder, container system, interactive elements, maps, modals, breadcrumbs, timelines, scroll effects, and animations. Built with WordPress standards for guaranteed editor/frontend parity.',
pluginAuthor: 'DesignSetGo',
pluginAuthorUri: 'https://designsetgoblocks.com/nealey',
textDomain: 'designsetgo',
blockNamespace: 'designsetgo',
cssPrefix: 'dsgo',
shortPrefix: 'dsg',
phpNamespace: 'DesignSetGo',
phpFunctionPrefix: 'designsetgo',
phpConstantPrefix: 'DESIGNSETGO',
restNamespace: 'designsetgo/v1',
patternCategoryPrefix: 'dsgo',
postTypeSlug: 'dsgo_form_submission',
metaKeyPrefix: '_dsg',
transientPrefix: 'dsgo',
optionPrefix: 'designsetgo',
logoPath: 'src/admin/assets/logo.png',
};

/**
* Convert a hyphenated slug to camelCase.
* E.g., "my-blocks" → "myBlocks", "myblocks" → "myblocks"
*
* @param {string} slug Hyphenated slug.
* @return {string} camelCase version.
*/
function toCamelCase(slug) {
return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}

/**
* Load white-label config, returning null if not found or matching defaults.
*
* @return {Object|null} Config object or null if no transformation needed.
*/
function loadConfig() {
const configPath = path.resolve(__dirname, '..', 'white-label.json');
if (!fs.existsSync(configPath)) {
return null;
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// Strip JSON Schema reference and comment fields.
const cleaned = { ...config };
delete cleaned.$schema;
Object.keys(cleaned).forEach((key) => {
if (key.startsWith('_comment')) {
delete cleaned[key];
}
});

// Check if all values match defaults (no transformation needed).
const isDefault = Object.keys(DEFAULTS).every(
(key) => cleaned[key] === DEFAULTS[key]
);
if (isDefault) {
return null;
}

return { ...DEFAULTS, ...cleaned };
}

/**
* Build replacement pairs for JS and SCSS files (webpack string-replace-loader).
*
* Returns an array of { search: RegExp, replace: string } objects,
* ordered longest-match-first.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for string-replace-loader.
*/
function buildJsScssReplacements(config) {
const rules = [];

// Helper: add a literal string replacement.
function literal(from, to) {
if (from !== to) {
rules.push({
search: from,
replace: to,
from, // Keep original for sorting.
});
}
}

// --- Longest patterns first ---

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings (block names, context keys).
literal('designsetgo/', `${config.blockNamespace}/`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, for classnames() calls and string concatenation).
literal('dsgo-', `${config.cssPrefix}-`);

// JS global names passed via wp_localize_script.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// WP script handles (hyphenated) in JS references.
literal('designsetgo-', `${config.pluginSlug}-`);

// Display name in JS strings.
literal('DesignSetGo', config.pluginName);

// Text domain in i18n calls.
literal('designsetgo', config.textDomain);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
// Must come before short prefix rule since dsgo starts with dsg.
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers (animation keyframes, attribute names, globals).
// Match dsg followed by uppercase letter — e.g., dsgFadeIn, dsgLinkUrl, dsgStickyHeaderSettings.
// This must NOT match dsgo- (safe: dsgo is never followed by uppercase).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort literal rules: longest 'from' first to prevent partial matches.
// Regex rules go last since they handle what literals miss.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for PHP files.
*
* Returns an array of { search: string|RegExp, replace: string } objects.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildPhpReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// --- PHP-specific replacements (longest first) ---

// PHP namespace (backslash-prefixed references).
literal('\\DesignSetGo\\', `\\${config.phpNamespace}\\`);

// PHP namespace declarations.
literal('namespace DesignSetGo', `namespace ${config.phpNamespace}`);

// @package docblock tag.
literal('@package DesignSetGo', `@package ${config.phpNamespace}`);

// PHP constants (order: longest constant names first).
literal('DESIGNSETGO_BASENAME', `${config.phpConstantPrefix}_BASENAME`);
literal('DESIGNSETGO_VERSION', `${config.phpConstantPrefix}_VERSION`);
literal('DESIGNSETGO_FILE', `${config.phpConstantPrefix}_FILE`);
literal('DESIGNSETGO_PATH', `${config.phpConstantPrefix}_PATH`);
literal('DESIGNSETGO_URL', `${config.phpConstantPrefix}_URL`);

// REST namespace.
literal('designsetgo/v1', config.restNamespace);

// Post type slug.
literal('dsgo_form_submission', config.postTypeSlug);

// Option names.
literal(
'designsetgo_global_styles',
`${config.optionPrefix}_global_styles`
);
literal('designsetgo_settings', `${config.optionPrefix}_settings`);

// Transient prefixes.
literal('dsgo_has_blocks_', `${config.transientPrefix}_has_blocks_`);
literal(
'dsgo_form_submissions_count',
`${config.transientPrefix}_form_submissions_count`
);

// JS global names passed via wp_localize_script (must match JS side).
// These use camelCase variants of the plugin name.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// Cache group.
literal("'designsetgo'", `'${config.textDomain}'`);

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings.
literal('designsetgo/', `${config.blockNamespace}/`);

// Function prefix (hook names, function names).
literal('designsetgo_', `${config.phpFunctionPrefix}_`);

// WP script/style handles and admin page slugs (hyphenated).
literal('designsetgo-', `${config.pluginSlug}-`);

// Data attributes in PHP HTML output.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties in PHP inline styles.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (with dot for selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (without dot for class attributes).
literal('dsgo-', `${config.cssPrefix}-`);

// Pattern category prefix.
literal('dsgo-hero', `${config.patternCategoryPrefix}-hero`);
literal('dsgo-contact', `${config.patternCategoryPrefix}-contact`);
literal('dsgo-features', `${config.patternCategoryPrefix}-features`);
literal('dsgo-cta', `${config.patternCategoryPrefix}-cta`);
literal('dsgo-faq', `${config.patternCategoryPrefix}-faq`);
literal('dsgo-gallery', `${config.patternCategoryPrefix}-gallery`);
literal('dsgo-homepage', `${config.patternCategoryPrefix}-homepage`);
literal('dsgo-modal', `${config.patternCategoryPrefix}-modal`);
literal('dsgo-pricing', `${config.patternCategoryPrefix}-pricing`);
literal('dsgo-team', `${config.patternCategoryPrefix}-team`);
literal(
'dsgo-testimonials',
`${config.patternCategoryPrefix}-testimonials`
);
literal('dsgo-content', `${config.patternCategoryPrefix}-content`);

// Meta key prefix.
literal('_dsg_', `${config.metaKeyPrefix}_`);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers.
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Display name.
literal('DesignSetGo', config.pluginName);

// Catch-all: bare "designsetgo" (admin menu slug, page params, remaining occurrences).
// Must come last since it's the shortest and most general match.
literal('designsetgo', config.textDomain);

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for block.json files.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildBlockJsonReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// Block name namespace.
literal('"designsetgo/', `"${config.blockNamespace}/`);

// Context provider keys.
literal('designsetgo/', `${config.blockNamespace}/`);

// Text domain.
literal('"designsetgo"', `"${config.textDomain}"`);

// CSS class references in block.json metadata.
literal('dsgo-', `${config.cssPrefix}-`);

// CSS custom property references.
literal('--dsgo-', `--${config.cssPrefix}-`);

// WP auto-generated class prefix.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// CSS prefix in camelCase form for block attribute names (dsgoAnimationEnabled, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for compiled CSS files (post-build).
*
* Applied to the CSS output after sass compilation, so SCSS variables
* are already resolved and only CSS class names, custom properties,
* data attributes, and keyframe names remain.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildCssReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, e.g., in attribute selectors or animations).
literal('dsgo-', `${config.cssPrefix}-`);

// Admin dashboard CSS classes and WP theme.json custom properties using full name.
// E.g., .designsetgo-info-box, --wp--custom--designsetgo--border-radius
literal('designsetgo-', `${config.pluginSlug}-`);
literal('designsetgo', config.textDomain);

// Display name in CSS comments (e.g., @package DesignSetGo).
literal('DesignSetGo', config.pluginName);

// CSS prefix in camelCase form (dsgoAnimationEnabled in data attributes, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for CSS @keyframes names (dsgFadeIn, dsgSlideInUp, etc.).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build plugin file header replacements.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for the main plugin PHP file header.
*/
function buildPluginHeaderReplacements(config) {
const rules = [];

function headerField(field, defaultVal, newVal) {
if (newVal !== defaultVal) {
rules.push({
search: `${field}${defaultVal}`,
replace: `${field}${newVal}`,
from: `${field}${defaultVal}`,
});
}
}

headerField(
' * Plugin Name: ',
DEFAULTS.pluginName,
config.pluginName
);
headerField(' * Plugin URI: ', DEFAULTS.pluginUri, config.pluginUri);
headerField(
' * Description: ',
DEFAULTS.pluginDescription,
config.pluginDescription
);
headerField(
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The white-label build system introduces several critical transformation functions (applyReplacements, loadConfig, buildJsScssReplacements, etc.) but lacks automated test coverage. Given that this codebase has comprehensive unit tests for other features (as seen in tests/unit/), the white-label functionality should also have tests.

Key functions that should be tested:

  • loadConfig: handling missing files, invalid JSON, default-matching configs
  • applyReplacements: literal and regex replacements, edge cases
  • buildXxxReplacements: ensure all expected transformations are generated
  • Filename renaming: ensure branded filenames are correctly transformed
  • Integration: end-to-end test of a simple white-label build

This is especially important since incorrect transformations could break the built plugin in subtle ways that might not be caught until deployment.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +65
try {
execFileSync('zip', ['-r', zipName, config.pluginSlug], {
cwd: ROOT,
stdio: 'pipe',
});
console.log(`\nCreated: ${zipName}`);
console.log(
'Install by uploading to WordPress > Plugins > Add New > Upload Plugin.'
);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zip creation uses execFileSync with stdio: 'pipe', which means output is buffered in memory. For large plugins, this could cause memory issues. Additionally, if the 'zip' command is not available on the system (e.g., on Windows without Unix tools), this will fail without a clear error message.

Consider: 1) Checking if 'zip' command exists before attempting to execute, 2) Providing fallback instructions for Windows users, or 3) Using a Node.js-based zip library for cross-platform compatibility.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 498
/**
* White Label Replacement Rules
*
* Centralized replacement definitions shared by webpack loaders
* and the post-build PHP/pattern transformer.
*
* @package
*/

'use strict';

const path = require('path');
const fs = require('fs');

/**
* Default values matching the current DesignSetGo branding.
* When config matches these, no transformation occurs.
*/
const DEFAULTS = {
pluginName: 'DesignSetGo',
pluginSlug: 'designsetgo',
pluginUri: 'https://designsetgoblocks.com',
pluginDescription:
'Professional Gutenberg block library with 52 blocks and 16 powerful extensions - complete Form Builder, container system, interactive elements, maps, modals, breadcrumbs, timelines, scroll effects, and animations. Built with WordPress standards for guaranteed editor/frontend parity.',
pluginAuthor: 'DesignSetGo',
pluginAuthorUri: 'https://designsetgoblocks.com/nealey',
textDomain: 'designsetgo',
blockNamespace: 'designsetgo',
cssPrefix: 'dsgo',
shortPrefix: 'dsg',
phpNamespace: 'DesignSetGo',
phpFunctionPrefix: 'designsetgo',
phpConstantPrefix: 'DESIGNSETGO',
restNamespace: 'designsetgo/v1',
patternCategoryPrefix: 'dsgo',
postTypeSlug: 'dsgo_form_submission',
metaKeyPrefix: '_dsg',
transientPrefix: 'dsgo',
optionPrefix: 'designsetgo',
logoPath: 'src/admin/assets/logo.png',
};

/**
* Convert a hyphenated slug to camelCase.
* E.g., "my-blocks" → "myBlocks", "myblocks" → "myblocks"
*
* @param {string} slug Hyphenated slug.
* @return {string} camelCase version.
*/
function toCamelCase(slug) {
return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}

/**
* Load white-label config, returning null if not found or matching defaults.
*
* @return {Object|null} Config object or null if no transformation needed.
*/
function loadConfig() {
const configPath = path.resolve(__dirname, '..', 'white-label.json');
if (!fs.existsSync(configPath)) {
return null;
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// Strip JSON Schema reference and comment fields.
const cleaned = { ...config };
delete cleaned.$schema;
Object.keys(cleaned).forEach((key) => {
if (key.startsWith('_comment')) {
delete cleaned[key];
}
});

// Check if all values match defaults (no transformation needed).
const isDefault = Object.keys(DEFAULTS).every(
(key) => cleaned[key] === DEFAULTS[key]
);
if (isDefault) {
return null;
}

return { ...DEFAULTS, ...cleaned };
}

/**
* Build replacement pairs for JS and SCSS files (webpack string-replace-loader).
*
* Returns an array of { search: RegExp, replace: string } objects,
* ordered longest-match-first.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for string-replace-loader.
*/
function buildJsScssReplacements(config) {
const rules = [];

// Helper: add a literal string replacement.
function literal(from, to) {
if (from !== to) {
rules.push({
search: from,
replace: to,
from, // Keep original for sorting.
});
}
}

// --- Longest patterns first ---

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings (block names, context keys).
literal('designsetgo/', `${config.blockNamespace}/`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, for classnames() calls and string concatenation).
literal('dsgo-', `${config.cssPrefix}-`);

// JS global names passed via wp_localize_script.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// WP script handles (hyphenated) in JS references.
literal('designsetgo-', `${config.pluginSlug}-`);

// Display name in JS strings.
literal('DesignSetGo', config.pluginName);

// Text domain in i18n calls.
literal('designsetgo', config.textDomain);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
// Must come before short prefix rule since dsgo starts with dsg.
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers (animation keyframes, attribute names, globals).
// Match dsg followed by uppercase letter — e.g., dsgFadeIn, dsgLinkUrl, dsgStickyHeaderSettings.
// This must NOT match dsgo- (safe: dsgo is never followed by uppercase).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort literal rules: longest 'from' first to prevent partial matches.
// Regex rules go last since they handle what literals miss.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for PHP files.
*
* Returns an array of { search: string|RegExp, replace: string } objects.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildPhpReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// --- PHP-specific replacements (longest first) ---

// PHP namespace (backslash-prefixed references).
literal('\\DesignSetGo\\', `\\${config.phpNamespace}\\`);

// PHP namespace declarations.
literal('namespace DesignSetGo', `namespace ${config.phpNamespace}`);

// @package docblock tag.
literal('@package DesignSetGo', `@package ${config.phpNamespace}`);

// PHP constants (order: longest constant names first).
literal('DESIGNSETGO_BASENAME', `${config.phpConstantPrefix}_BASENAME`);
literal('DESIGNSETGO_VERSION', `${config.phpConstantPrefix}_VERSION`);
literal('DESIGNSETGO_FILE', `${config.phpConstantPrefix}_FILE`);
literal('DESIGNSETGO_PATH', `${config.phpConstantPrefix}_PATH`);
literal('DESIGNSETGO_URL', `${config.phpConstantPrefix}_URL`);

// REST namespace.
literal('designsetgo/v1', config.restNamespace);

// Post type slug.
literal('dsgo_form_submission', config.postTypeSlug);

// Option names.
literal(
'designsetgo_global_styles',
`${config.optionPrefix}_global_styles`
);
literal('designsetgo_settings', `${config.optionPrefix}_settings`);

// Transient prefixes.
literal('dsgo_has_blocks_', `${config.transientPrefix}_has_blocks_`);
literal(
'dsgo_form_submissions_count',
`${config.transientPrefix}_form_submissions_count`
);

// JS global names passed via wp_localize_script (must match JS side).
// These use camelCase variants of the plugin name.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// Cache group.
literal("'designsetgo'", `'${config.textDomain}'`);

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings.
literal('designsetgo/', `${config.blockNamespace}/`);

// Function prefix (hook names, function names).
literal('designsetgo_', `${config.phpFunctionPrefix}_`);

// WP script/style handles and admin page slugs (hyphenated).
literal('designsetgo-', `${config.pluginSlug}-`);

// Data attributes in PHP HTML output.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties in PHP inline styles.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (with dot for selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (without dot for class attributes).
literal('dsgo-', `${config.cssPrefix}-`);

// Pattern category prefix.
literal('dsgo-hero', `${config.patternCategoryPrefix}-hero`);
literal('dsgo-contact', `${config.patternCategoryPrefix}-contact`);
literal('dsgo-features', `${config.patternCategoryPrefix}-features`);
literal('dsgo-cta', `${config.patternCategoryPrefix}-cta`);
literal('dsgo-faq', `${config.patternCategoryPrefix}-faq`);
literal('dsgo-gallery', `${config.patternCategoryPrefix}-gallery`);
literal('dsgo-homepage', `${config.patternCategoryPrefix}-homepage`);
literal('dsgo-modal', `${config.patternCategoryPrefix}-modal`);
literal('dsgo-pricing', `${config.patternCategoryPrefix}-pricing`);
literal('dsgo-team', `${config.patternCategoryPrefix}-team`);
literal(
'dsgo-testimonials',
`${config.patternCategoryPrefix}-testimonials`
);
literal('dsgo-content', `${config.patternCategoryPrefix}-content`);

// Meta key prefix.
literal('_dsg_', `${config.metaKeyPrefix}_`);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers.
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Display name.
literal('DesignSetGo', config.pluginName);

// Catch-all: bare "designsetgo" (admin menu slug, page params, remaining occurrences).
// Must come last since it's the shortest and most general match.
literal('designsetgo', config.textDomain);

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for block.json files.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildBlockJsonReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// Block name namespace.
literal('"designsetgo/', `"${config.blockNamespace}/`);

// Context provider keys.
literal('designsetgo/', `${config.blockNamespace}/`);

// Text domain.
literal('"designsetgo"', `"${config.textDomain}"`);

// CSS class references in block.json metadata.
literal('dsgo-', `${config.cssPrefix}-`);

// CSS custom property references.
literal('--dsgo-', `--${config.cssPrefix}-`);

// WP auto-generated class prefix.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// CSS prefix in camelCase form for block attribute names (dsgoAnimationEnabled, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for compiled CSS files (post-build).
*
* Applied to the CSS output after sass compilation, so SCSS variables
* are already resolved and only CSS class names, custom properties,
* data attributes, and keyframe names remain.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildCssReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, e.g., in attribute selectors or animations).
literal('dsgo-', `${config.cssPrefix}-`);

// Admin dashboard CSS classes and WP theme.json custom properties using full name.
// E.g., .designsetgo-info-box, --wp--custom--designsetgo--border-radius
literal('designsetgo-', `${config.pluginSlug}-`);
literal('designsetgo', config.textDomain);

// Display name in CSS comments (e.g., @package DesignSetGo).
literal('DesignSetGo', config.pluginName);

// CSS prefix in camelCase form (dsgoAnimationEnabled in data attributes, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for CSS @keyframes names (dsgFadeIn, dsgSlideInUp, etc.).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build plugin file header replacements.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for the main plugin PHP file header.
*/
function buildPluginHeaderReplacements(config) {
const rules = [];

function headerField(field, defaultVal, newVal) {
if (newVal !== defaultVal) {
rules.push({
search: `${field}${defaultVal}`,
replace: `${field}${newVal}`,
from: `${field}${defaultVal}`,
});
}
}

headerField(
' * Plugin Name: ',
DEFAULTS.pluginName,
config.pluginName
);
headerField(' * Plugin URI: ', DEFAULTS.pluginUri, config.pluginUri);
headerField(
' * Description: ',
DEFAULTS.pluginDescription,
config.pluginDescription
);
headerField(
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The white-label configuration values (like pluginName, textDomain, etc.) are directly interpolated into file content, including PHP code and JSON. While the validation script checks format patterns, it doesn't sanitize for code injection. A malicious config could potentially inject code.

For example, if someone sets pluginName: 'MyPlugin"; malicious_code(); //', this could be interpolated into PHP strings or JavaScript.

Consider: 1) Adding explicit checks that config values don't contain quotes, semicolons, or other code-injection characters, or 2) Document that white-label.json should only be created by trusted developers (since it's a build-time tool, not runtime user input), or 3) Add escaping functions for different contexts (PHP strings, JS strings, JSON values).

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added validation in white-label-validate.js: pluginName, pluginDescription, and pluginAuthor now reject quotes, backticks, backslashes, dollar signs, and angle brackets. All other fields are already constrained by regex patterns that prevent injection.

literal('DesignSetGo', config.pluginName);

// Text domain in i18n calls.
literal('designsetgo', config.textDomain);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In buildJsScssReplacements, both 'designsetgo-' (line 142) and 'designsetgo' (line 148) are added as literal replacements. The sorting ensures longer matches come first, but 'designsetgo-' (13 chars) and 'designsetgo' (11 chars) would both match 'designsetgo-block'. The hyphenated version should match first due to length, but this creates fragile ordering dependency.

If for some reason the sort doesn't work as expected, 'designsetgo' could match first and create '{textDomain}-' which would then not match the 'designsetgo-' pattern.

Consider making the non-hyphenated replacement use a word boundary regex like \bdesignsetgo\b to avoid matching when followed by a hyphen, or add a comment explaining why the current approach is safe (because sorting guarantees order).

Suggested change
literal('designsetgo', config.textDomain);
// Use a regex so we don't match when immediately followed by a hyphen.
rules.push({
search: new RegExp(`\\b${DEFAULTS.textDomain}(?!-)\\b`, 'g'),
replace: config.textDomain,
from: DEFAULTS.textDomain,
});

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +110
function copyDirWithTransform(src, dest, transform, pattern) {
if (!fs.existsSync(src)) {
return;
}

fs.mkdirSync(dest, { recursive: true });

const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const renamedName = renameEntry(entry.name);
const destPath = path.join(dest, renamedName);

if (entry.isDirectory()) {
copyDirWithTransform(srcPath, destPath, transform, pattern);
} else if (pattern && pattern.test(entry.name)) {
const content = fs.readFileSync(srcPath, 'utf8');
const transformed = transform(content, srcPath);
fs.writeFileSync(destPath, transformed, 'utf8');
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copyDirWithTransform function recursively copies directories but doesn't handle symlinks. If the source directory tree contains symbolic links, they will be followed and copied as regular files/directories, which could lead to infinite loops if there's a circular symlink, or unexpected duplication of content.

Consider adding symlink detection (using lstatSync instead of or in addition to readdirSync with withFileTypes) and either: 1) skip symlinks with a warning, 2) copy them as symlinks, or 3) document that symlinks are not supported.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 498
/**
* White Label Replacement Rules
*
* Centralized replacement definitions shared by webpack loaders
* and the post-build PHP/pattern transformer.
*
* @package
*/

'use strict';

const path = require('path');
const fs = require('fs');

/**
* Default values matching the current DesignSetGo branding.
* When config matches these, no transformation occurs.
*/
const DEFAULTS = {
pluginName: 'DesignSetGo',
pluginSlug: 'designsetgo',
pluginUri: 'https://designsetgoblocks.com',
pluginDescription:
'Professional Gutenberg block library with 52 blocks and 16 powerful extensions - complete Form Builder, container system, interactive elements, maps, modals, breadcrumbs, timelines, scroll effects, and animations. Built with WordPress standards for guaranteed editor/frontend parity.',
pluginAuthor: 'DesignSetGo',
pluginAuthorUri: 'https://designsetgoblocks.com/nealey',
textDomain: 'designsetgo',
blockNamespace: 'designsetgo',
cssPrefix: 'dsgo',
shortPrefix: 'dsg',
phpNamespace: 'DesignSetGo',
phpFunctionPrefix: 'designsetgo',
phpConstantPrefix: 'DESIGNSETGO',
restNamespace: 'designsetgo/v1',
patternCategoryPrefix: 'dsgo',
postTypeSlug: 'dsgo_form_submission',
metaKeyPrefix: '_dsg',
transientPrefix: 'dsgo',
optionPrefix: 'designsetgo',
logoPath: 'src/admin/assets/logo.png',
};

/**
* Convert a hyphenated slug to camelCase.
* E.g., "my-blocks" → "myBlocks", "myblocks" → "myblocks"
*
* @param {string} slug Hyphenated slug.
* @return {string} camelCase version.
*/
function toCamelCase(slug) {
return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}

/**
* Load white-label config, returning null if not found or matching defaults.
*
* @return {Object|null} Config object or null if no transformation needed.
*/
function loadConfig() {
const configPath = path.resolve(__dirname, '..', 'white-label.json');
if (!fs.existsSync(configPath)) {
return null;
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// Strip JSON Schema reference and comment fields.
const cleaned = { ...config };
delete cleaned.$schema;
Object.keys(cleaned).forEach((key) => {
if (key.startsWith('_comment')) {
delete cleaned[key];
}
});

// Check if all values match defaults (no transformation needed).
const isDefault = Object.keys(DEFAULTS).every(
(key) => cleaned[key] === DEFAULTS[key]
);
if (isDefault) {
return null;
}

return { ...DEFAULTS, ...cleaned };
}

/**
* Build replacement pairs for JS and SCSS files (webpack string-replace-loader).
*
* Returns an array of { search: RegExp, replace: string } objects,
* ordered longest-match-first.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for string-replace-loader.
*/
function buildJsScssReplacements(config) {
const rules = [];

// Helper: add a literal string replacement.
function literal(from, to) {
if (from !== to) {
rules.push({
search: from,
replace: to,
from, // Keep original for sorting.
});
}
}

// --- Longest patterns first ---

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings (block names, context keys).
literal('designsetgo/', `${config.blockNamespace}/`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, for classnames() calls and string concatenation).
literal('dsgo-', `${config.cssPrefix}-`);

// JS global names passed via wp_localize_script.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// WP script handles (hyphenated) in JS references.
literal('designsetgo-', `${config.pluginSlug}-`);

// Display name in JS strings.
literal('DesignSetGo', config.pluginName);

// Text domain in i18n calls.
literal('designsetgo', config.textDomain);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
// Must come before short prefix rule since dsgo starts with dsg.
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers (animation keyframes, attribute names, globals).
// Match dsg followed by uppercase letter — e.g., dsgFadeIn, dsgLinkUrl, dsgStickyHeaderSettings.
// This must NOT match dsgo- (safe: dsgo is never followed by uppercase).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort literal rules: longest 'from' first to prevent partial matches.
// Regex rules go last since they handle what literals miss.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for PHP files.
*
* Returns an array of { search: string|RegExp, replace: string } objects.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildPhpReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// --- PHP-specific replacements (longest first) ---

// PHP namespace (backslash-prefixed references).
literal('\\DesignSetGo\\', `\\${config.phpNamespace}\\`);

// PHP namespace declarations.
literal('namespace DesignSetGo', `namespace ${config.phpNamespace}`);

// @package docblock tag.
literal('@package DesignSetGo', `@package ${config.phpNamespace}`);

// PHP constants (order: longest constant names first).
literal('DESIGNSETGO_BASENAME', `${config.phpConstantPrefix}_BASENAME`);
literal('DESIGNSETGO_VERSION', `${config.phpConstantPrefix}_VERSION`);
literal('DESIGNSETGO_FILE', `${config.phpConstantPrefix}_FILE`);
literal('DESIGNSETGO_PATH', `${config.phpConstantPrefix}_PATH`);
literal('DESIGNSETGO_URL', `${config.phpConstantPrefix}_URL`);

// REST namespace.
literal('designsetgo/v1', config.restNamespace);

// Post type slug.
literal('dsgo_form_submission', config.postTypeSlug);

// Option names.
literal(
'designsetgo_global_styles',
`${config.optionPrefix}_global_styles`
);
literal('designsetgo_settings', `${config.optionPrefix}_settings`);

// Transient prefixes.
literal('dsgo_has_blocks_', `${config.transientPrefix}_has_blocks_`);
literal(
'dsgo_form_submissions_count',
`${config.transientPrefix}_form_submissions_count`
);

// JS global names passed via wp_localize_script (must match JS side).
// These use camelCase variants of the plugin name.
literal(
'designSetGoRevisions',
`${toCamelCase(config.pluginSlug)}Revisions`
);
literal('designSetGoAdmin', `${toCamelCase(config.pluginSlug)}Admin`);
literal('designsetgoForm', `${config.textDomain}Form`);

// Cache group.
literal("'designsetgo'", `'${config.textDomain}'`);

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Block comment markers in pattern content.
literal('wp:designsetgo/', `wp:${config.blockNamespace}/`);

// Block namespace in strings.
literal('designsetgo/', `${config.blockNamespace}/`);

// Function prefix (hook names, function names).
literal('designsetgo_', `${config.phpFunctionPrefix}_`);

// WP script/style handles and admin page slugs (hyphenated).
literal('designsetgo-', `${config.pluginSlug}-`);

// Data attributes in PHP HTML output.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties in PHP inline styles.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (with dot for selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS class prefix in PHP HTML output (without dot for class attributes).
literal('dsgo-', `${config.cssPrefix}-`);

// Pattern category prefix.
literal('dsgo-hero', `${config.patternCategoryPrefix}-hero`);
literal('dsgo-contact', `${config.patternCategoryPrefix}-contact`);
literal('dsgo-features', `${config.patternCategoryPrefix}-features`);
literal('dsgo-cta', `${config.patternCategoryPrefix}-cta`);
literal('dsgo-faq', `${config.patternCategoryPrefix}-faq`);
literal('dsgo-gallery', `${config.patternCategoryPrefix}-gallery`);
literal('dsgo-homepage', `${config.patternCategoryPrefix}-homepage`);
literal('dsgo-modal', `${config.patternCategoryPrefix}-modal`);
literal('dsgo-pricing', `${config.patternCategoryPrefix}-pricing`);
literal('dsgo-team', `${config.patternCategoryPrefix}-team`);
literal(
'dsgo-testimonials',
`${config.patternCategoryPrefix}-testimonials`
);
literal('dsgo-content', `${config.patternCategoryPrefix}-content`);

// Meta key prefix.
literal('_dsg_', `${config.metaKeyPrefix}_`);

// CSS prefix in camelCase form for block attributes (dsgoAnimationEnabled, dsgoTextRevealColor, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for camelCase identifiers.
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`\\b${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Display name.
literal('DesignSetGo', config.pluginName);

// Catch-all: bare "designsetgo" (admin menu slug, page params, remaining occurrences).
// Must come last since it's the shortest and most general match.
literal('designsetgo', config.textDomain);

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for block.json files.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildBlockJsonReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// Block name namespace.
literal('"designsetgo/', `"${config.blockNamespace}/`);

// Context provider keys.
literal('designsetgo/', `${config.blockNamespace}/`);

// Text domain.
literal('"designsetgo"', `"${config.textDomain}"`);

// CSS class references in block.json metadata.
literal('dsgo-', `${config.cssPrefix}-`);

// CSS custom property references.
literal('--dsgo-', `--${config.cssPrefix}-`);

// WP auto-generated class prefix.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// CSS prefix in camelCase form for block attribute names (dsgoAnimationEnabled, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build replacement pairs for compiled CSS files (post-build).
*
* Applied to the CSS output after sass compilation, so SCSS variables
* are already resolved and only CSS class names, custom properties,
* data attributes, and keyframe names remain.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules.
*/
function buildCssReplacements(config) {
const rules = [];

function literal(from, to) {
if (from !== to) {
rules.push({ search: from, replace: to, from });
}
}

// WP auto-generated block CSS classes.
literal('wp-block-designsetgo-', `wp-block-${config.blockNamespace}-`);

// Data attributes.
literal('data-dsgo-', `data-${config.cssPrefix}-`);

// CSS custom properties.
literal('--dsgo-', `--${config.cssPrefix}-`);

// CSS classes (dot-prefixed selectors).
literal('.dsgo-', `.${config.cssPrefix}-`);

// CSS classes (without dot, e.g., in attribute selectors or animations).
literal('dsgo-', `${config.cssPrefix}-`);

// Admin dashboard CSS classes and WP theme.json custom properties using full name.
// E.g., .designsetgo-info-box, --wp--custom--designsetgo--border-radius
literal('designsetgo-', `${config.pluginSlug}-`);
literal('designsetgo', config.textDomain);

// Display name in CSS comments (e.g., @package DesignSetGo).
literal('DesignSetGo', config.pluginName);

// CSS prefix in camelCase form (dsgoAnimationEnabled in data attributes, etc.).
if (config.cssPrefix !== DEFAULTS.cssPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.cssPrefix}([A-Z])`, 'g'),
replace: `${config.cssPrefix}$1`,
from: `${DEFAULTS.cssPrefix}[A-Z]`,
});
}

// Short prefix for CSS @keyframes names (dsgFadeIn, dsgSlideInUp, etc.).
if (config.shortPrefix !== DEFAULTS.shortPrefix) {
rules.push({
search: new RegExp(`${DEFAULTS.shortPrefix}([A-Z])`, 'g'),
replace: `${config.shortPrefix}$1`,
from: `${DEFAULTS.shortPrefix}[A-Z]`,
});
}

// Sort: longest literal first, regexes last.
return rules.sort((a, b) => {
const aIsRegex = a.search instanceof RegExp;
const bIsRegex = b.search instanceof RegExp;
if (aIsRegex && !bIsRegex) {
return 1;
}
if (!aIsRegex && bIsRegex) {
return -1;
}
return (b.from || '').length - (a.from || '').length;
});
}

/**
* Build plugin file header replacements.
*
* @param {Object} config White-label config.
* @return {Array} Replacement rules for the main plugin PHP file header.
*/
function buildPluginHeaderReplacements(config) {
const rules = [];

function headerField(field, defaultVal, newVal) {
if (newVal !== defaultVal) {
rules.push({
search: `${field}${defaultVal}`,
replace: `${field}${newVal}`,
from: `${field}${defaultVal}`,
});
}
}

headerField(
' * Plugin Name: ',
DEFAULTS.pluginName,
config.pluginName
);
headerField(' * Plugin URI: ', DEFAULTS.pluginUri, config.pluginUri);
headerField(
' * Description: ',
DEFAULTS.pluginDescription,
config.pluginDescription
);
headerField(
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The white-label-replacements.js file is approaching 500 lines and contains multiple distinct responsibilities: config loading, JS replacements, PHP replacements, CSS replacements, block.json replacements, plugin header replacements, and the apply function. This exceeds the project's stated 300-line file size limit guideline.

Consider refactoring this into smaller modules, such as:

  • white-label-config.js (loadConfig, DEFAULTS)
  • white-label-rules-js.js (buildJsScssReplacements)
  • white-label-rules-php.js (buildPhpReplacements, buildPluginHeaderReplacements)
  • white-label-rules-css.js (buildCssReplacements)
  • white-label-rules-json.js (buildBlockJsonReplacements)
  • white-label-apply.js (applyReplacements)

Copilot generated this review using guidance from repository custom instructions.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not splitting this file. The replacement rules are logically cohesive — all rule builders share DEFAULTS, toCamelCase, and the same structure. Scattering them across 5 files would make it harder to see the complete replacement picture and maintain ordering consistency. The 300-line guideline is for block files with mixed concerns, not data-heavy modules.

Comment on lines 260 to 264
// Replace plugin name in readme header (first line is "=== PluginName ===").
readmeContent = readmeContent.replace(
`=== ${DEFAULTS.pluginName} ===`,
`=== ${config.pluginName} ===`
);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readme.txt transformation applies phpRules after replacing the plugin name in the header. However, phpRules includes a replacement for 'DesignSetGo' to config.pluginName (line 300 of white-label-replacements.js). This means the replacement will happen twice: once explicitly on line 262, and again via phpRules on line 265.

While this shouldn't cause issues (the second replacement is a no-op), it's redundant. Consider either removing the explicit replacement on lines 261-264, or excluding 'DesignSetGo' from phpRules when applied to readme.txt.

Suggested change
// Replace plugin name in readme header (first line is "=== PluginName ===").
readmeContent = readmeContent.replace(
`=== ${DEFAULTS.pluginName} ===`,
`=== ${config.pluginName} ===`
);
// Apply PHP-style white-label replacements to readme content (including plugin name).

Copilot uses AI. Check for mistakes.
Comment on lines 59 to 85
function loadConfig() {
const configPath = path.resolve(__dirname, '..', 'white-label.json');
if (!fs.existsSync(configPath)) {
return null;
}

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// Strip JSON Schema reference and comment fields.
const cleaned = { ...config };
delete cleaned.$schema;
Object.keys(cleaned).forEach((key) => {
if (key.startsWith('_comment')) {
delete cleaned[key];
}
});

// Check if all values match defaults (no transformation needed).
const isDefault = Object.keys(DEFAULTS).every(
(key) => cleaned[key] === DEFAULTS[key]
);
if (isDefault) {
return null;
}

return { ...DEFAULTS, ...cleaned };
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadConfig function silently returns null when white-label.json exists but all values match defaults. This could be confusing for users who create a config file expecting it to be used. Consider logging a message or adding a comment in the function explaining this behavior, or checking this condition in the validation script and warning the user.

Copilot uses AI. Check for mistakes.
Comment on lines 48 to 71
const tempDir = path.resolve(ROOT, config.pluginSlug);

// If temp dir already exists (shouldn't happen), clean it.
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}

fs.renameSync(DIST, tempDir);

try {
execFileSync('zip', ['-r', zipName, config.pluginSlug], {
cwd: ROOT,
stdio: 'pipe',
});
console.log(`\nCreated: ${zipName}`);
console.log(
'Install by uploading to WordPress > Plugins > Add New > Upload Plugin.'
);
} catch (err) {
console.error(`Error creating zip: ${err.message}`);
process.exit(1);
} finally {
// Restore dist/ directory.
fs.renameSync(tempDir, DIST);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The renameSync operation is used to temporarily rename dist/ to the plugin slug for zipping. If the script crashes or is interrupted between the rename and the finally block (e.g., user kills the process), the dist/ directory will remain renamed and could cause confusion. While the try-finally helps, it won't catch SIGKILL or system crashes.

Consider: 1) Adding a warning comment about this edge case, or 2) Checking for and recovering from this state at the start of the script (if temp dir exists, rename it back before proceeding).

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added recovery logic at script start that detects and restores a leftover temp dir from a previous interrupted run.

- Add path traversal validation for logoPath in white-label-php.js
- Add code injection prevention for free-text config fields
- Add defense-in-depth slug validation in zip script
- Add JSON parsing error handling and type validation in loadConfig
- Add DIST path safety check before rmSync
- Add try-catch around main build execution
- Add zip recovery from interrupted runs
- Hide default branded logo in white-label builds (conditional rendering)
- Update wp-env instructions to use --update flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jnealey-godaddy jnealey-godaddy marked this pull request as draft February 13, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants