'use strict'; /** * Plugin Name: GracePress Theme Studio * Description: MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers. * Version: 0.0.3 * Stage: Alpha * Release Channel: Internal * Public Release: No * Public Candidate: No * Category: MeshCentral * Settings Slug: gracepress-theme-studio * WordPress.org Slug: * MeshCentral Short Name: gpthemestudio * MeshCentral Config URL: https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json * MeshCentral Compatibility: >=1.1.0 * Requires PHP: 8.0 * Icon: 🧩 */ const fs = require('fs'); const path = require('path'); const querystring = require('querystring'); const PLUGIN = { name: 'GracePress Theme Studio', shortName: 'gpthemestudio', version: '0.0.3', }; const PROFILE_DIR = 'themes'; const DEFAULT_PROFILE_ID = 'meshcentral-default'; const ACTIVE_FILE = 'active-theme.json'; const CONTROL_GROUPS = [ { id: 'brand', label: 'Brand', description: 'Visible identity and non-CSS presentation values.', fields: [ { key: 'brand.title', label: 'Site title', type: 'text' }, { key: 'brand.subtitle', label: 'Subtitle', type: 'text' }, { key: 'brand.logoUrl', label: 'Logo URL', type: 'text', placeholder: 'https://...' }, { key: 'brand.footerText', label: 'Footer text', type: 'text', placeholder: 'Terms & Privacy' }, ], }, { id: 'color', label: 'Colors', description: 'Primary MeshCentral chrome, panel, link, and state colors.', fields: [ { key: 'color.headerBackground', label: 'Header background', type: 'color' }, { key: 'color.headerText', label: 'Header text', type: 'color' }, { key: 'color.leftbarBackground', label: 'Icon rail background', type: 'color' }, { key: 'color.sidebarBackground', label: 'Sidebar background', type: 'color' }, { key: 'color.tabActiveBackground', label: 'Active tab', type: 'color' }, { key: 'color.panelBackground', label: 'Panel background', type: 'color' }, { key: 'color.panelText', label: 'Panel text', type: 'color' }, { key: 'color.link', label: 'Links', type: 'color' }, { key: 'color.success', label: 'Success', type: 'color' }, { key: 'color.warning', label: 'Warning', type: 'color' }, { key: 'color.danger', label: 'Danger', type: 'color' }, ], }, { id: 'font', label: 'Typography', description: 'Dropdown-driven font decisions instead of blank freehand values.', fields: [ { key: 'font.family', label: 'Font family', type: 'select', options: [ 'Arial, Helvetica, sans-serif', 'Inter, Arial, Helvetica, sans-serif', 'Segoe UI, Arial, Helvetica, sans-serif', 'Roboto, Arial, Helvetica, sans-serif', 'Georgia, serif', 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', ], }, { key: 'font.baseSize', label: 'Base size', type: 'select', options: ['14px', '15px', '16px', '17px', '18px'] }, { key: 'font.headingSize', label: 'Heading size', type: 'select', options: ['24px', '28px', '32px', '36px', '40px'] }, ], }, { id: 'layout', label: 'Layout', description: 'Stable layout tokens for later CSS generation.', fields: [ { key: 'layout.density', label: 'Density', type: 'select', options: ['compact', 'default', 'comfortable'] }, { key: 'layout.leftbarWidth', label: 'Icon rail width', type: 'select', options: ['60px', '68px', '75px', '84px', '96px'] }, { key: 'layout.borderRadius', label: 'Border radius', type: 'select', options: ['0px', '3px', '5px', '8px'] }, ], }, { id: 'icons', label: 'Icons', description: 'Icon pack selection. Custom pack upload/generation comes after the mapping is proven.', fields: [ { key: 'icons.set', label: 'Icon set', type: 'select', options: ['meshcentral-classic', 'gracepress-classic', 'gracepress-flat', 'custom-pack'] }, { key: 'icons.size', label: 'Icon size', type: 'select', options: ['40px', '48px', '56px', '64px'] }, { key: 'icons.packPath', label: 'Custom pack path', type: 'text', placeholder: '/path/to/icons' }, ], }, { id: 'advanced', label: 'Advanced', description: 'Recorded but not globally injected until rollback and startup loading are built.', fields: [ { key: 'advanced.customCss', label: 'Custom CSS notes', type: 'textarea' }, ], }, ]; module.exports.gpthemestudio = function(parent) { const obj = { parent }; obj.exports = []; ensureProfileStore(parent); console.log(`${PLUGIN.name} loaded.`); obj.handleAdminReq = function(req, res, user) { handleAdminRequest(parent, req, res, user); }; return obj; }; function handleAdminRequest(parent, req, res, user) { if (req && req.method === 'POST') { readBody(req, function(err, body) { const username = getUsername(user); let notice = ''; let error = ''; if (err) { error = err.message; } else { const result = handleAction(parent, body); notice = result.notice || ''; error = result.error || ''; } const state = loadState(parent); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderAdmin(username, state, notice, error)); }); return; } const state = loadState(parent, req); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderAdmin(getUsername(user), state, '', '')); } function readBody(req, done) { let body = ''; req.on('data', function(chunk) { body += chunk; if (body.length > 262144) req.destroy(new Error('Request body too large.')); }); req.on('end', function() { try { done(null, querystring.parse(body)); } catch (err) { done(err); } }); req.on('error', done); } function handleAction(parent, data) { const action = getPostedValue(data.action); if (action === 'clone') return cloneProfile(parent, data); if (action === 'save') return saveProfile(parent, data); if (action === 'select') return selectActiveProfile(parent, data.profileId); if (action === 'export') return exportProfile(parent, data.profileId); return { error: 'Unknown Theme Studio action.' }; } function cloneProfile(parent, data) { const base = getProfileById(parent, String(data.sourceProfileId || DEFAULT_PROFILE_ID)) || loadDefaultTheme(parent); const name = cleanProfileName(data.newProfileName || 'Christ IT Theme'); const id = uniqueProfileId(parent, slugify(name)); const clone = JSON.parse(JSON.stringify(base)); clone.id = id; clone.name = name; clone.locked = false; clone.capturedAt = new Date().toISOString().slice(0, 10); clone.source = `Cloned from ${base.name || base.id || DEFAULT_PROFILE_ID}`; clone.description = `Editable theme profile cloned from ${base.name || 'MeshCentral Default'}.`; clone.notes = ['Editable GracePress Theme Studio profile.']; clone.branding = clone.branding || {}; clone.generated = clone.generated || {}; writeProfile(parent, clone); writeActiveProfile(parent, id); return { notice: `Created editable theme profile "${name}".` }; } function saveProfile(parent, data) { const profileId = String(data.profileId || ''); const profile = getProfileById(parent, profileId); if (!profile) return { error: 'Selected profile could not be found.' }; if (profile.locked) return { error: 'The default MeshCentral profile is locked. Clone it before editing.' }; profile.name = cleanProfileName(data.profileName || profile.name || profile.id); profile.description = String(data.profileDescription || profile.description || ''); profile.updatedAt = new Date().toISOString(); profile.variables = profile.variables || {}; getEditableFields().forEach(function(field) { const formKey = formFieldName(field.key); if (Object.prototype.hasOwnProperty.call(data, formKey)) { profile.variables[field.key] = normalizeFieldValue(field, data[formKey]); } }); writeProfile(parent, profile); writeActiveProfile(parent, profile.id); return { notice: `Saved "${profile.name}".` }; } function selectActiveProfile(parent, profileId) { const profile = getProfileById(parent, String(profileId || '')); if (!profile) return { error: 'Selected profile could not be found.' }; writeActiveProfile(parent, profile.id); return { notice: `Selected "${profile.name}" as the active editing profile.` }; } function exportProfile(parent, profileId) { const profile = getProfileById(parent, String(profileId || '')); if (!profile) return { error: 'Selected profile could not be found.' }; const exportDir = path.join(getProfileDir(parent), 'exports'); fs.mkdirSync(exportDir, { recursive: true }); const file = path.join(exportDir, `${safeFileName(profile.id)}-${Date.now()}.json`); fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8'); return { notice: `Exported "${profile.name}" to ${file}.` }; } function ensureProfileStore(parent) { const dir = getProfileDir(parent); fs.mkdirSync(dir, { recursive: true }); const defaultFile = path.join(dir, `${DEFAULT_PROFILE_ID}.json`); if (!fs.existsSync(defaultFile)) { writeProfile(parent, loadDefaultTheme(parent)); } if (!fs.existsSync(path.join(dir, ACTIVE_FILE))) { writeActiveProfile(parent, DEFAULT_PROFILE_ID); } } function loadState(parent, req) { ensureProfileStore(parent); const profiles = loadProfiles(parent); const requestedId = getRequestProfileId(req); const activeId = requestedId || readActiveProfile(parent) || DEFAULT_PROFILE_ID; const active = profiles.find(function(profile) { return profile.id === activeId; }) || profiles[0] || loadDefaultTheme(parent); return { profiles, active, activeId: active.id, storagePath: getProfileDir(parent), }; } function getRequestProfileId(req) { if (!req || !req.url || req.url.indexOf('?') === -1) return ''; const query = querystring.parse(req.url.split('?').slice(1).join('?')); return String(query.profileId || ''); } function loadProfiles(parent) { const dir = getProfileDir(parent); const files = fs.readdirSync(dir) .filter(function(file) { return file.endsWith('.json') && file !== ACTIVE_FILE; }) .sort(); const profiles = files.map(function(file) { try { return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); } catch (err) { return null; } }).filter(Boolean); profiles.sort(function(left, right) { if (left.id === DEFAULT_PROFILE_ID) return -1; if (right.id === DEFAULT_PROFILE_ID) return 1; return String(left.name || left.id).localeCompare(String(right.name || right.id)); }); return profiles.length ? profiles : [loadDefaultTheme(parent)]; } function getProfileById(parent, profileId) { return loadProfiles(parent).find(function(profile) { return profile.id === profileId; }) || null; } function getProfileDir(parent) { return path.join(__dirname, PROFILE_DIR); } function readActiveProfile(parent) { try { return JSON.parse(fs.readFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), 'utf8')).activeProfileId || DEFAULT_PROFILE_ID; } catch (err) { return DEFAULT_PROFILE_ID; } } function writeActiveProfile(parent, profileId) { fs.writeFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), JSON.stringify({ activeProfileId: profileId || DEFAULT_PROFILE_ID, selectedAt: new Date().toISOString(), applied: false, }, null, 2) + '\n', 'utf8'); } function writeProfile(parent, profile) { fs.mkdirSync(getProfileDir(parent), { recursive: true }); fs.writeFileSync(path.join(getProfileDir(parent), `${safeFileName(profile.id)}.json`), JSON.stringify(profile, null, 2) + '\n', 'utf8'); } function loadDefaultTheme(parent) { const candidates = [ path.join(__dirname, 'default-theme.json'), ].filter(Boolean); for (const file of candidates) { try { if (fs.existsSync(file)) { const theme = JSON.parse(fs.readFileSync(file, 'utf8')); theme.id = DEFAULT_PROFILE_ID; theme.name = theme.name || 'MeshCentral Default'; theme.locked = true; theme.variables = withProfileDefaults(theme.variables || {}); return theme; } } catch (err) { return fallbackDefaultTheme(file, err.message); } } return fallbackDefaultTheme('', ''); } function fallbackDefaultTheme(source, error) { return { schema: 1, id: DEFAULT_PROFILE_ID, name: 'MeshCentral Default', description: error ? 'Default theme profile could not be loaded.' : 'Default theme profile is missing from this plugin package.', locked: true, capturedAt: '', source, variables: withProfileDefaults({}), error, }; } function withProfileDefaults(variables) { const defaults = { 'brand.logoUrl': '', 'brand.footerText': 'Terms & Privacy', 'icons.packPath': '', 'advanced.customCss': '', }; return Object.assign({}, defaults, variables); } function renderAdmin(username, state, notice, error) { const profile = state.active || loadDefaultTheme(); const variables = withProfileDefaults(profile.variables || {}); const locked = !!profile.locked; const profiles = state.profiles || []; const rawGroups = groupVariables(variables); const rawRows = renderRawInventory(rawGroups); return `
Theme Studio now saves editable profiles. Apply is intentionally disabled until the generated CSS hook, startup reapply behavior, and rollback marker are implemented.
| ID | ${escapeHtml(profile.id || '')} |
|---|---|
| Storage | ${escapeHtml(state.storagePath || '')} |
| Source | ${escapeHtml(profile.source || '')} |
| Updated | ${escapeHtml(profile.updatedAt || profile.capturedAt || '')} |
Kept for audit and future mapping work. The editor above is the primary workflow.
No variables were found.
'}${escapeHtml(group.description)}
${escapeHtml(row.key)}| Variable | Value |
|---|
${escapeHtml(text)}`;
}
function normalizeFieldValue(field, value) {
value = getPostedValue(value);
value = String(value || '').trim();
if (field.type === 'color' && !isHexColor(value)) return '#000000';
return value;
}
function getPostedValue(value) {
if (Array.isArray(value)) return String(value[value.length - 1] || '');
return String(value || '');
}
function formFieldName(key) {
return `var_${String(key).replace(/[^a-zA-Z0-9]/g, '_')}`;
}
function uniqueProfileId(parent, base) {
base = base || 'theme';
const existing = new Set(loadProfiles(parent).map(function(profile) { return profile.id; }));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}-${index}`)) index += 1;
return `${base}-${index}`;
}
function cleanProfileName(value) {
const name = String(value || '').trim().replace(/\s+/g, ' ');
return name || 'Untitled Theme';
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'theme';
}
function safeFileName(value) {
return String(value || 'theme').replace(/[^a-zA-Z0-9._-]/g, '_');
}
function suggestCloneName(name) {
if (!name || name === 'MeshCentral Default') return 'Christ IT Theme';
return `${name} Copy`;
}
function getUsername(user) {
return (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin';
}
function isHexColor(value) {
return /^#([0-9a-f]{6})$/i.test(String(value || ''));
}
function isColorValue(value) {
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) || /^rgba?\(/i.test(value);
}
function titleCase(value) {
return String(value).replace(/[-_]/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function(c) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '`');
}