'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 ` ${escapeHtml(PLUGIN.name)}

${escapeHtml(PLUGIN.name)}

Profile editor baseline for MeshCentral UI theming. Logged in as ${escapeHtml(username)}.
${notice ? `
${escapeHtml(notice)}
` : ''} ${error ? `
${escapeHtml(error)}
` : ''}
Plugin
${escapeHtml(PLUGIN.version)}
Profile
${escapeHtml(profile.name || '')}
Variables
${Object.keys(variables).length}
Mode
${locked ? 'Locked default' : 'Editable'}

Profile Editor

${locked ? 'This is the locked MeshCentral Default profile. Clone it to create an editable theme.' : 'Edit values, save the profile, then export or apply after the global CSS hook is built.'}

${CONTROL_GROUPS.map(function(group) { return renderControlGroup(group, variables, locked); }).join('')}

Implementation Boundary

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 || '')}

Raw Variable Inventory

Kept for audit and future mapping work. The editor above is the primary workflow.

${rawRows || '

No variables were found.

'}
`; } function renderProfileOptions(profiles, activeId) { return profiles.map(function(profile) { const label = `${profile.name || profile.id}${profile.locked ? ' (locked)' : ''}`; return ``; }).join(''); } function renderControlGroup(group, variables, locked) { const fields = group.fields.map(function(field) { return renderField(field, variables[field.key], locked); }).join(''); return `

${escapeHtml(group.label)}

${escapeHtml(group.description)}

${fields}
`; } function renderField(field, value, locked) { const name = formFieldName(field.key); const id = `field-${name}`; const current = value === undefined || value === null ? '' : String(value); const disabled = locked ? 'disabled' : ''; const readonly = locked ? 'readonly' : ''; const fieldClass = field.type === 'color' ? 'field color' : 'field'; let control = ''; if (field.type === 'select') { control = ``; } else if (field.type === 'textarea') { control = ``; } else if (field.type === 'color') { const color = isHexColor(current) ? current : '#000000'; control = ``; } else { control = ``; } return `
${control}
`; } function renderSelectOptions(options, current) { const seen = new Set(); const all = options.slice(); if (current && all.indexOf(current) === -1) all.unshift(current); return all.filter(function(option) { if (seen.has(option)) return false; seen.add(option); return true; }).map(function(option) { return ``; }).join(''); } function renderRawInventory(groups) { const order = ['brand', 'font', 'layout', 'color', 'button', 'table', 'icons', 'effects', 'advanced', 'other']; return order.filter(function(group) { return groups[group] && groups[group].length; }).map(function(group) { const body = groups[group].map(function(row) { return `${escapeHtml(row.key)}${renderValue(row.value)}`; }).join(''); return `

${escapeHtml(titleCase(group))}

${body}
VariableValue
`; }).join(''); } function getEditableFields() { return CONTROL_GROUPS.reduce(function(fields, group) { return fields.concat(group.fields); }, []); } function groupVariables(variables) { const groups = {}; Object.keys(variables).sort().forEach(function(key) { const dot = key.indexOf('.'); const group = dot > 0 ? key.substring(0, dot) : 'other'; if (!groups[group]) groups[group] = []; groups[group].push({ key, value: variables[key] }); }); return groups; } function renderValue(value) { const text = String(value); const color = isColorValue(text) ? `` : ''; return `${color}${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, '`'); }