feat: Add white-label build pipeline for plugin forks#245
feat: Add white-label build pipeline for plugin forks#245jnealey-godaddy wants to merge 4 commits intomainfrom
Conversation
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>
|
Claude finished @jnealey88's task in 3m 8s —— View job Code Review CompleteI'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. SummaryIssues Found: 12 total
Critical Issues Requiring Immediate Attention🔴 High Severity Issues
🟡 Medium Severity Issues
Additional Issues (Lower Priority)Code Quality & Maintainability
Documentation
Positive Observations✅ Good architectural decisions:
✅ Security considerations already in place:
✅ Code quality:
RecommendationsBefore Merging:
Nice to Have:
Overall AssessmentThis is a well-designed white-label system with good separation of concerns and comprehensive coverage of branded identifiers. The main concerns are:
With the security fixes applied, this would be a solid, production-ready white-label build pipeline. |
scripts/white-label-replacements.js
Outdated
| return null; | ||
| } | ||
|
|
||
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); |
There was a problem hiding this comment.
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;
}There was a problem hiding this comment.
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.
scripts/white-label-php.js
Outdated
|
|
||
| // 10. Handle custom logo if specified. | ||
| if (config.logoPath && config.logoPath !== DEFAULTS.logoPath) { | ||
| const logoSrc = path.join(ROOT, config.logoPath); |
There was a problem hiding this comment.
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}`);
}
}There was a problem hiding this comment.
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], { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
scripts/white-label-php.js
Outdated
|
|
||
| // Clean dist/ directory. | ||
| if (fs.existsSync(DIST)) { | ||
| fs.rmSync(DIST, { recursive: true, force: true }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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')); |
There was a problem hiding this comment.
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);
}
scripts/white-label-validate.js
Outdated
| config.cssPrefix && | ||
| (config.cssPrefix.length < 2 || config.cssPrefix.length > 6) | ||
| ) { | ||
| errors.push( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'. |
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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')) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 |
| /** | ||
| * 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()); | ||
| } |
There was a problem hiding this comment.
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."
| /** | ||
| * 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( |
There was a problem hiding this comment.
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.
| 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.' | ||
| ); |
There was a problem hiding this comment.
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.
| /** | ||
| * 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( |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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).
| 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, | |
| }); |
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| /** | ||
| * 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( |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
scripts/white-label-php.js
Outdated
| // Replace plugin name in readme header (first line is "=== PluginName ==="). | ||
| readmeContent = readmeContent.replace( | ||
| `=== ${DEFAULTS.pluginName} ===`, | ||
| `=== ${config.pluginName} ===` | ||
| ); |
There was a problem hiding this comment.
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.
| // 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). |
| 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 }; | ||
| } |
There was a problem hiding this comment.
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.
scripts/white-label-zip.js
Outdated
| 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); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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>
Summary
white-label.json) and runnpm run white-label:buildto get a fully rebranded plugin indist/What gets rebranded
Plugin name, text domain, block namespace (
designsetgo/accordion→myblocks/accordion), CSS prefix (dsgo-→mb-), short prefix (dsgFadeIn→mbFadeIn), 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
string-replace-loadertransforms JS/JSX at compile time; custom loader transformsblock.jsonfilesdist/, applying transformations and renaming branded filenamesNew npm scripts
white-label:builddist/white-label:zip.zipwhite-label:validatewhite-label:cleandist/wp-env:white-labeldist/wp-env:white-label:resetVerified
dist/across PHP, JS, CSS, and JSON (excludingvendor/andlanguages/)white-label.json) produces identical output to current behaviorclass-dsgo-handlers.php→class-mb-handlers.php)Test plan
white-label.json, runnpm run build— confirm output matches current behaviorwhite-label.json.exampletowhite-label.json, runnpm run white-label:buildgrep -r "designsetgo\|dsgo-\|_dsg_\|DesignSetGo\|DESIGNSETGO" dist/returns zero results (excluding vendor/languages)npm run wp-env:white-labelthennpm run wp-env:start— plugin should activate without errorsnpm run wp-env:white-label:resetto revertnpm run white-label:zip— verify installable zip is produced🤖 Generated with Claude Code