Import/Export Site Settings via the REST API: A Multi-Client Rollout Script

An agency running a dozen Brndle sites for a dozen different clients hits the same wall eventually: client fourteen wants “the same look as the Northstar Legal site, but with our colors.” Not a theme fork, not a custom build, just the same header layout, the same font pairing, the same structural choices, with one different accent color. Doing that by hand means opening Brndle → Colors, Brndle → Typography, Brndle → Header, and Brndle → Footer on the new site and clicking through every control to match what a human remembers (or, worse, screenshots) from the reference site. It is slow, it is error-prone, and it does not scale past two or three sites.
Brndle’s settings layer is a REST API first and an admin screen second: every value a site owner clicks into existence in Brndle → Settings lives in one JSON-shaped option, reachable over wp-json/brndle/v1/settings. That is the detail that makes a rollout script possible at all. This post confirms, directly from the current brndle-themes/brndle source read on 12 November 2026, whether export and import are genuinely separate REST routes or just a pattern layered on the base settings endpoint, then builds a real Node.js script that pulls a source site’s design tokens and rolls them out to any number of target sites, with a backup and a dry-run step before anything gets overwritten.
Table of contents
- The multi-client design-consistency problem
- The real export/import mechanism, confirmed from source
- What’s actually in the payload: tokens vs. everything else
- Authentication: setting up Application Passwords
- The worked rollout script, end to end
- Running it: dry run, then apply
- Safety considerations
- FAQ
- The takeaway
The multi-client design-consistency problem
Two versions of this problem show up constantly in agency work, and they look superficially different but need the same tool. The first is templating a known-good configuration onto a brand-new site: a new client’s build starts from a design a previous client already approved, with one accent color swapped in, because reinventing the header style, footer layout, and type scale from scratch for every new engagement is wasted billable time on a decision that was already made correctly once. The second is syncing a design update across several live sites: an agency standardizes on a new footer style or a tightened heading scale across its whole Brndle client base after settling on a house style, and wants that change applied consistently rather than re-clicked by hand on each site, with the usual risk of one site getting a slightly different value than the rest.
Both are the same underlying operation: read a set of settings from one site, write that same set of settings to one or more other sites. WordPress does not have a built-in concept of “clone these theme settings to another install.” Brndle does, once you know where to look, because its settings layer was built REST-first rather than admin-screen-first.
The real export/import mechanism, confirmed from source
We pulled app/Providers/SettingsServiceProvider.php directly from the brndle-themes/brndle repository’s default branch on 12 November 2026, specifically to settle one question before writing a line of script: does Brndle ship dedicated export/import REST routes, or is “export/import” just a pattern built on the base GET/POST /settings endpoint? The answer is both, in a way worth being precise about. Here is registerRestRoutes() in full:
register_rest_route($namespace, '/settings', [
['methods' => 'GET', 'callback' => [$this, 'handleGetSettings'], 'permission_callback' => [$this, 'checkManageOptions']],
['methods' => 'POST', 'callback' => [$this, 'handleSaveSettings'], 'permission_callback' => [$this, 'checkManageOptions']],
['methods' => 'DELETE', 'callback' => [$this, 'handleResetSettings'], 'permission_callback' => [$this, 'checkManageOptions']],
]);
register_rest_route($namespace, '/settings/export', [
'methods' => 'GET', 'callback' => [$this, 'handleExportSettings'], 'permission_callback' => [$this, 'checkManageOptions'],
]);
register_rest_route($namespace, '/settings/import', [
'methods' => 'POST', 'callback' => [$this, 'handleImportSettings'], 'permission_callback' => [$this, 'checkManageOptions'],
]);
/brndle/v1/settings/export and /brndle/v1/settings/import are real, dedicated REST routes, not a documentation convention layered on top of the plain settings endpoint. They exist as their own registered routes with their own callbacks, confirming the premise an earlier post on this site assumed when checking whether a brndle/css_variables override would “survive a settings export/import round trip.” But read the two handlers themselves, and the second, more useful fact appears:
public function handleExportSettings(): WP_REST_Response
{
return new WP_REST_Response(Settings::all(), 200);
}
public function handleGetSettings(): WP_REST_Response
{
return new WP_REST_Response(Settings::all(), 200);
}
handleExportSettings() and handleGetSettings() are, as of this reading of the source, byte-for-byte identical. Both call Settings::all() and return it with a 200 status; neither adds a version stamp, a timestamp, a site identifier, or any export-specific wrapper around the data. GET /settings/export and plain GET /settings return the exact same JSON body today.
The import side tells the same story, with one more layer worth separating out:
public function handleImportSettings(WP_REST_Request $request): WP_REST_Response
{
$input = $request->get_json_params();
if (! is_array($input) || empty($input)) {
return new WP_REST_Response(['success' => false, 'message' => __('Invalid import data.', 'brndle')], 400);
}
$allowedKeys = array_keys(Defaults::all());
$filtered = array_intersect_key($input, array_flip($allowedKeys));
if (empty($filtered)) {
return new WP_REST_Response(['success' => false, 'message' => __('No valid settings keys in import data.', 'brndle')], 400);
}
$sanitized = Sanitizer::sanitizeAll($filtered);
Settings::save($sanitized);
return new WP_REST_Response(['success' => true, 'settings' => Settings::all()], 200);
}
Line for line, that is the identical allowlist-then-sanitize-then-save sequence handleSaveSettings() runs for a plain POST /settings call: intersect the incoming keys against Defaults::all() so nothing outside the known setting schema can be written, run every surviving value through Sanitizer::sanitizeAll(), then call Settings::save(), which itself merges the sanitized values over whatever is already stored rather than replacing the option wholesale. The only functional difference between the two POST routes, today, is the wording of the 400-error message when the body is empty or contains no recognized keys.
So the honest, confirmed answer to “is there a real export/import mechanism, or is it just GET/POST used as a pattern” is: there are two dedicated routes with their own names and their own registered callbacks, and as of this reading of the source they do exactly what the base settings route already does. That is not a bug to report, it is useful information for anyone building against this API: using /settings/export and /settings/import instead of the base route costs nothing and buys forward-compatibility, since a future release could legitimately add export-specific behavior (a version stamp, a different response shape, migration metadata) to those two routes without touching the plain GET/POST/DELETE trio other code may depend on. The rollout script in this post uses the dedicated routes for that reason, even though swapping in the base route would behave identically today.
What’s actually in the payload: tokens vs. everything else
Before pointing a script at a live client site, it matters a great deal what Settings::all() actually returns, because “export settings” sounds like “export the design,” and that is not quite what happens. Defaults.php defines the full schema, grouped by section, and the groups fall into two very different categories for a cross-site rollout.
Genuine design tokens, the kind that make sense to clone from one site’s brand configuration to another’s: color_scheme, custom_accent, dark_mode_default, dark_mode_toggle, dark_mode_toggle_position, font_pair, font_size_base, heading_scale, header_style, header_sticky_mode, header_mobile_style, footer_style, footer_columns, footer_show_social, and archive_layout. These describe “what the design looks like” independent of any one site’s content or credentials, which is exactly what the agency scenario in this post’s intro wants to copy.
Site-specific state that should never travel verbatim between sites, confirmed by reading Defaults::all() directly: site_logo_light and site_logo_dark store a media URL into that specific site’s uploads directory; copying the raw value to a different install points at an image that does not exist there (or, worse, at the source site’s own logo on the source site’s own domain). social_links, header_cta_url, and footer_copyright are per-client content, not design. And three keys are outright secrets with no special handling in the export path: mailchimp_api_key, form_webhook_url, and form_notification_email. handleExportSettings() does not filter, redact, or flag any of these before returning the full Settings::all() array. An agency that pipes a raw export straight into a target site’s import call is one copy-paste away from wiring client A’s Mailchimp API key and webhook URL into client B’s forms.
This is the single most important design decision in the script below: it does not touch the full export at all. It defines its own allowlist of token keys, pulls only those from the source site’s export, and only ever sends that narrower set to a target’s import route. The dedicated /settings/export route is still the right thing to call, since it returns the full picture needed to decide what is safe to copy; the filtering happens in the script, not in the API.
Authentication: setting up Application Passwords
Both the export and import routes are gated behind the same permission callback:
public function checkManageOptions(): bool
{
return current_user_can('manage_options');
}
That is a WordPress capability check, not a Brndle-specific API key or secret. It works identically to every other admin-gated REST route in WordPress core, which means the correct way to call it from an external script, one that is not a logged-in browser session with a cookie and a REST nonce, is Application Passwords, the per-user credential system that has shipped in WordPress core since 5.6. No Brndle-specific setup is required beyond having an administrator account on each site.
On every target site, including the source:
- Log in as a user with the
manage_optionscapability (an Administrator). - Go to Users → Profile (or Users → All Users → [your user] if logged in as a different account).
- Scroll to the Application Passwords section near the bottom of the profile screen.
- Enter a name that identifies the purpose, for example
brndle-token-rollout, and click Add New Application Password. - WordPress generates a password, shown exactly once, formatted with spaces for readability (
xxxx xxxx xxxx xxxx xxxx xxxx). Copy it immediately; it cannot be retrieved again, only revoked and regenerated. - Store it as an environment variable, never in a committed config file. The script below reads one environment variable per target site.
Two things worth confirming rather than assuming. First, Application Passwords require the site to be served over HTTPS; WordPress core disables the feature on a plain-HTTP origin (wp_is_application_passwords_available() returns false unless the request is over SSL, a local-by-flywheel or Local site with a .local HTTPS certificate, or an environment that has explicitly filtered the check). A staging site still on HTTP will not show the Application Passwords section at all. Second, the credential authenticates over HTTP Basic Auth, Authorization: Basic base64(username:application_password), sent on every request, so it should be treated with the same care as a real password: one application password per integration, revoked the moment the rollout script retires or a team member who had access leaves.
The worked rollout script, end to end
The script below is plain Node.js (18+, for the built-in global fetch), no dependencies to install. It reads a small JSON config describing one source site and any number of target sites, pulls the source’s full export, narrows it to the design-token allowlist, backs up each target’s current values before touching anything, prints a diff, and only writes when explicitly told to with --apply.
First, the config, rollout.config.json, checked into the agency’s own internal tooling repo (credentials are not in this file, only environment variable names):
{
"source": {
"name": "northstar-legal",
"url": "https://northstar-legal.com",
"user": "rollout-bot",
"envVar": "BRNDLE_APP_PASSWORD_NORTHSTAR"
},
"targets": [
{ "name": "client-a", "url": "https://client-a.com", "user": "rollout-bot", "envVar": "BRNDLE_APP_PASSWORD_CLIENT_A" },
{ "name": "client-b", "url": "https://client-b.com", "user": "rollout-bot", "envVar": "BRNDLE_APP_PASSWORD_CLIENT_B" },
{ "name": "client-c", "url": "https://client-c.com", "user": "rollout-bot", "envVar": "BRNDLE_APP_PASSWORD_CLIENT_C" }
]
}
And the script itself, brndle-rollout.mjs:
#!/usr/bin/env node
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
// Design tokens only. Deliberately excludes site_logo_light/dark (media URLs
// are per-site), social_links/header_cta_url/footer_copyright (client content,
// not design), and every forms/Mailchimp key (secrets with no place in a
// cross-site rollout).
const TOKEN_KEYS = [
'color_scheme', 'custom_accent',
'dark_mode_default', 'dark_mode_toggle', 'dark_mode_toggle_position',
'font_pair', 'font_size_base', 'heading_scale',
'header_style', 'header_sticky_mode', 'header_mobile_style',
'footer_style', 'footer_columns', 'footer_show_social',
'archive_layout',
];
function authHeader(user, password) {
const token = Buffer.from(`${user}:${password}`).toString('base64');
return { Authorization: `Basic ${token}` };
}
async function fetchExport(site, password) {
const res = await fetch(`${site.url}/wp-json/brndle/v1/settings/export`, {
headers: authHeader(site.user, password),
});
if (!res.ok) {
throw new Error(`${site.name}: export failed (${res.status} ${res.statusText})`);
}
return res.json();
}
async function importTokens(site, password, payload) {
const res = await fetch(`${site.url}/wp-json/brndle/v1/settings/import`, {
method: 'POST',
headers: { ...authHeader(site.user, password), 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await res.json();
if (!res.ok || body.success !== true) {
throw new Error(`${site.name}: import failed (${res.status}) ${body.message ?? ''}`);
}
return body;
}
async function purgeCache(site, password) {
await fetch(`${site.url}/wp-json/brndle/v1/cache/purge`, {
method: 'POST',
headers: authHeader(site.user, password),
}).catch(() => {}); // best-effort; a failed purge is not worth aborting the rollout over
}
function pickTokens(settings) {
const out = {};
for (const key of TOKEN_KEYS) {
if (key in settings) out[key] = settings[key];
}
return out;
}
function diffTokens(current, incoming) {
const changes = [];
for (const key of TOKEN_KEYS) {
const before = current[key];
const after = incoming[key];
if (JSON.stringify(before) !== JSON.stringify(after)) {
changes.push({ key, before, after });
}
}
return changes;
}
function backup(site, settings) {
const dir = './backups';
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const path = `${dir}/${site.name}-${stamp}.json`;
writeFileSync(path, JSON.stringify(settings, null, 2));
return path;
}
async function main() {
const apply = process.argv.includes('--apply');
const onlyArg = process.argv.find((a) => a.startsWith('--only='));
const only = onlyArg ? onlyArg.split('=')[1].split(',') : null;
const keys = only ? TOKEN_KEYS.filter((k) => only.includes(k)) : TOKEN_KEYS;
const config = JSON.parse(readFileSync('./rollout.config.json', 'utf8'));
const sourcePassword = process.env[config.source.envVar];
if (!sourcePassword) {
throw new Error(`Missing env var ${config.source.envVar} for source site`);
}
console.log(`Reading design tokens from ${config.source.name} (${config.source.url})...`);
const sourceSettings = await fetchExport(config.source, sourcePassword);
const sourceTokens = pickTokens(sourceSettings);
const payload = {};
for (const key of keys) if (key in sourceTokens) payload[key] = sourceTokens[key];
console.log(`\nTokens to roll out: ${keys.join(', ')}\n`);
for (const target of config.targets) {
const password = process.env[target.envVar];
if (!password) {
console.error(`Skipping ${target.name}: missing env var ${target.envVar}`);
continue;
}
try {
const current = await fetchExport(target, password);
const incoming = Object.assign({}, current, payload);
const changes = diffTokens(current, incoming);
console.log(`--- ${target.name} (${target.url}) ---`);
if (changes.length === 0) {
console.log(' Already matches. Nothing to do.\n');
continue;
}
for (const c of changes) {
console.log(` ${c.key}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`);
}
const backupPath = backup(target, current);
console.log(` Backed up current settings to ${backupPath}`);
if (!apply) {
console.log(' Dry run only. Re-run with --apply to write these changes.\n');
continue;
}
await importTokens(target, password, payload);
await purgeCache(target, password);
console.log(' Applied and cache purged.\n');
} catch (err) {
console.error(` ERROR on ${target.name}: ${err.message}\n`);
// Deliberately continue to the next target rather than aborting the
// whole rollout because one site was unreachable or misconfigured.
}
// Small courtesy delay between targets; this is an admin-gated route on
// someone else's production site, not a bulk API meant to be hammered.
await new Promise((r) => setTimeout(r, 400));
}
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
Three decisions in this script are deliberate and worth calling out explicitly, because each one maps to a real failure mode this post’s research surfaced.
The allowlist lives in the script, not in the API call. Since /settings/export returns everything, including mailchimp_api_key and the other secrets covered above, pickTokens() throws away every key outside TOKEN_KEYS immediately after the fetch, before the data is ever logged, diffed, or sent anywhere. A target’s import call only ever receives the narrow token payload, never the full export.
Dry run is the default, not an opt-in. apply only becomes true with an explicit --apply flag; running the script with no arguments fetches, diffs, and backs up, but never calls importTokens(). This matches how handleImportSettings() itself behaves, a straightforward overwrite with no confirmation step server-side, which means any safety has to come from the calling script, not the API.
One target’s failure does not abort the rest. The try/catch inside the loop logs the error and moves to the next site rather than throwing out of main(). A rollout across a dozen client sites should not fail entirely because one site’s Application Password expired or one site’s DNS hiccupped; it should report that one failure clearly and finish the other eleven.
Running it: dry run, then apply
With the environment variables exported and rollout.config.json in place:
# Dry run: fetch, diff, and back up every target, write nothing
node brndle-rollout.mjs
# Roll out only the color scheme and accent, leaving fonts and layout alone
node brndle-rollout.mjs --only=color_scheme,custom_accent --apply
# Full token set, applied for real
node brndle-rollout.mjs --apply
A dry run against the three-target config above prints something like:
Reading design tokens from northstar-legal (https://northstar-legal.com)...
Tokens to roll out: color_scheme, custom_accent, dark_mode_default...
--- client-a (https://client-a.com) ---
color_scheme: "sapphire" -> "northstar"
custom_accent: "" -> "#1b4d89"
font_pair: "inter" -> "sans-serif-pro"
Backed up current settings to ./backups/client-a-2026-11-12T...json
Dry run only. Re-run with --apply to write these changes.
That diff output is the actual review step: it is the point where a human confirms the change list is what was intended, not a guess at what a sync “probably” did, before anything irreversible happens.
Safety considerations
A script that can overwrite live client sites’ settings in a loop earns a short, explicit list of rules, not just good intentions.
Never skip the backup step. The script writes each target’s pre-change settings to ./backups/ before calling import, every run, dry or applied. If a rollout goes wrong, the fastest recovery is importTokens(target, password, JSON.parse(readFileSync(backupPath))), not reconstructing what a site looked like from memory or a screenshot.
Always look at the diff before adding --apply. diffTokens() exists specifically so “nothing to do” and “fifteen keys about to change” are visually distinct before anything is written. A script that silently applies with no printed diff is a script a tired engineer will eventually run against the wrong target.
Never import the full export. The payload sent to /settings/import should always be the output of pickTokens() filtered against an explicit allowlist, never the raw object returned by /settings/export. The three secret keys covered earlier have no business crossing a client boundary, and the two logo URLs will simply be wrong on a different site’s media library.
Treat import as a merge, not a wholesale replace, but don’t rely on that for safety. Settings::save() does array_merge($saved, $sanitized), so keys the script does not send are left untouched on the target. That is good news for a partial rollout (sending only color_scheme and custom_accent genuinely leaves the target’s header and footer choices alone) and bad news if the mental model is “this makes the target identical to the source,” because it does not reset anything the target had customized outside the sent keys.
Run rollouts one at a time from one machine, not in parallel from multiple scripts. Settings::save() reads the current option, merges, and writes it back in a single PHP request; there is no cross-request locking. Two concurrent import calls against the same target, from two different script invocations, can race, and the one that writes last silently wins regardless of which one had the more complete payload. The script above already serializes targets within one run; do not also run two instances of it against the same target.
Purge the cache, and know what that does and does not reach. The script calls /brndle/v1/cache/purge after every successful import, which clears Brndle’s compiled Blade views, its in-request settings cache, and wp_cache_flush(). It does not reach a CDN or a full-page cache sitting in front of WordPress (Cloudflare, Varnish, a hosting provider’s edge cache); if a target site’s homepage still shows the old accent color five minutes after a successful import, check that layer before suspecting the script.
A rolled-out color does not inherit a contrast guarantee it didn’t already have. As a sibling post on this site found when auditing brndle/color_presets, the derived on-accent and dark-on-accent values come from an unverified binary luminance split, and some accent colors land close enough to the line to fail a WCAG AA check in dark mode. Rolling out color_scheme or custom_accent to a new target reproduces the source’s exact derived palette, contrast gap included if one exists. The rollout script does not introduce new risk here, but it does not fix a pre-existing one either; run the source color through a contrast check once, and every target that inherits it is covered by the same check.
FAQ
Is there a real, dedicated export/import mechanism in Brndle, or is it just GET/POST on the base settings endpoint?
Both things are true at once, confirmed by reading app/Providers/SettingsServiceProvider.php directly. GET /brndle/v1/settings/export and POST /brndle/v1/settings/import are genuinely separate, dedicated REST routes with their own registered callbacks. As of this reading of the source, though, handleExportSettings() is identical to handleGetSettings() (both just return Settings::all()), and handleImportSettings() runs the same allowlist-sanitize-save sequence as handleSaveSettings(). The routes exist and are real; they just do not yet do anything the base route couldn’t.
What authentication does the import route require for a script, rather than a logged-in admin clicking a button?
Both routes are gated by current_user_can('manage_options') through a standard WP REST permission callback, with no Brndle-specific key or secret. For a script that is not a browser session with a cookie and nonce, the correct mechanism is WordPress core’s own Application Passwords (available since WP 5.6), sent as an HTTP Basic Auth header on every request.
Does the export route return literally everything, including API keys and webhook URLs?
Yes. handleExportSettings() returns Settings::all() with no filtering, and Defaults.php confirms mailchimp_api_key, form_webhook_url, and form_notification_email are ordinary keys in that same array, with no special redaction anywhere in the export path. A rollout script must filter these out itself; the API will not do it.
Can I import a partial set of settings, or does the payload have to be the full settings object?
Partial. handleImportSettings() allowlists whatever keys you send against Defaults::all(), sanitizes them, and Settings::save() merges them over the target’s existing saved option (array_merge($saved, $sanitized)). Keys you do not send are left exactly as they were on the target.
Will importing a source site’s settings overwrite the target’s logo or Mailchimp configuration?
Only if you send those keys, and the rollout script in this post deliberately never does. site_logo_light/site_logo_dark store media URLs scoped to one site’s uploads directory; copying them to a different install points at an image that is not there. Scope any rollout to a design-token allowlist and leave identity, content, and integration keys alone.
Does the REST layer enforce the same min/max ranges the admin UI shows for things like font size or heading scale?
No. Those ranges (font_size_base: 12–24, heading_scale: 1.10–1.50, and so on) live in Defaults::schema() purely as metadata for rendering the admin UI’s range sliders. Sanitizer::sanitize() type-checks and casts a value (an int, a float, a hex color) but does not consult schema() for range clamping. A script sending font_size_base: 9999 would have that value saved as the integer 9999, not clamped to 24. Validate ranges in the script before sending.
What happens if two rollout runs hit the same target site at the same time?
There is no cross-request locking around Settings::save(); it is a read-merge-write sequence within a single PHP request, and two concurrent requests against the same target can race, with whichever write lands last silently winning. Run one rollout invocation at a time per target rather than firing parallel scripts at the same site.
Should I purge the cache after every import?
Worth doing, and the script does it automatically. POST /brndle/v1/cache/purge clears compiled Blade views, the in-request settings cache, and calls wp_cache_flush(). It does not reach a CDN or a full-page cache sitting in front of WordPress; if a change doesn’t show up on the live page after a successful import, check that separate layer before assuming the import failed.
Does a color rolled out this way automatically pass the same WCAG contrast checks the source site passes?
It inherits whatever the source already had, nothing more and nothing less. The derived on-accent/dark-on-accent values come purely from the accent hex via ColorPalette::generate(); rolling out color_scheme or custom_accent reproduces the identical derived palette on the target, contrast gap included if the source had one. Check the source’s accent once; every target that inherits it inherits the same result.
Can this rollout script run against a WordPress multisite network in one call?
No, not as a single request. The brndle_settings option, like any un-networked WordPress option, is stored per site (per blog ID) even inside a multisite network. A rollout across several sites in a network still requires one REST call per site, exactly as it does across several entirely separate WordPress installs; the script already loops per target for this reason.
The takeaway
/brndle/v1/settings/export and /brndle/v1/settings/import are real, dedicated routes, confirmed directly from SettingsServiceProvider.php, and as of this reading of the source they are functionally identical to the base GET/POST /settings endpoint underneath a different name. That is enough to build a genuinely useful multi-client rollout on: read a source site’s tokens with Application Password auth, filter hard down to a design-token allowlist that deliberately excludes secrets and site-specific media, diff against each target before writing anything, back up what was there, and only apply with an explicit flag. None of that safety comes from the API, which will cheerfully overwrite whatever you POST at it with no confirmation step; all of it has to come from the script sitting in front of the API, which is exactly what makes this a rollout tool an agency can trust against live client sites rather than a one-off curl command run carefully once and never again.
Sources, read on 12 November 2026
- brndle-themes/brndle: app/Providers/SettingsServiceProvider.php, the
/settings,/settings/export,/settings/import, and/cache/purgeREST route registrations and their handlers - brndle-themes/brndle: app/Settings/Settings.php,
Settings::all(),Settings::save()‘s merge-not-replace behavior, and thebrndle_settingsoption key - brndle-themes/brndle: app/Settings/Defaults.php, the full settings schema, the secret and site-identity keys, and the admin-UI-only range metadata in
schema() - brndle-themes/brndle: app/Settings/Sanitizer.php, confirming type-based sanitization with no range clamping against
schema() - Using brndle/color_presets to Ship a Client’s Brand Palette as a 13th Scheme, this post’s source for the on-accent contrast gap that travels with a rolled-out color
- The brndle/css_variables Hook: Injecting Design Tokens Beyond the Admin Panel, confirming that an environment-driven override lives outside
Settings::all()and is unaffected by an export/import round trip - WordPress Developer Reference: Application Passwords