import { readFile, writeFile, unlink } from 'fs/promises'; import { resolve as pathResolve } from 'path'; import { existsSync } from 'fs'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; import { headerConfigSchema } from './header-builder.schemas'; import type { HeaderConfig, HeaderNavItem } from './header-builder.schemas'; const OVERRIDES_DIR = pathResolve(env.MKDOCS_DOCS_PATH, 'overrides'); const CONFIG_PATH = pathResolve(OVERRIDES_DIR, 'header-config.json'); const MAIN_HTML_PATH = pathResolve(OVERRIDES_DIR, 'main.html'); /** Default built-in navigation items (pre-populated when no config exists) */ const DEFAULT_ITEMS: HeaderNavItem[] = [ { id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'campaign', enabled: true, order: 0, type: 'builtin' }, { id: 'map', label: 'Map', path: '/map', icon: 'map', enabled: true, order: 1, type: 'builtin' }, { id: 'shifts', label: 'Volunteer', path: '/shifts', icon: 'groups', enabled: true, order: 2, type: 'builtin' }, { id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'play_circle', enabled: false, order: 3, type: 'builtin' }, { id: 'responses', label: 'Responses', path: '/responses', icon: 'forum', enabled: false, order: 4, type: 'builtin' }, { id: 'donate', label: 'Donate', path: '/donate', icon: 'favorite', enabled: false, order: 5, type: 'builtin' }, { id: 'login', label: 'Sign In', path: '/login', icon: 'login', enabled: true, order: 6, type: 'builtin' }, ]; const DEFAULT_CONFIG: HeaderConfig = { enabled: false, items: DEFAULT_ITEMS, style: { backgroundColor: '#6f42c1', textColor: '#ffffff', hoverColor: 'rgba(255,255,255,0.15)', height: '40px', }, }; /** * Escape a string for safe embedding inside a Jinja2/HTML template. * Prevents XSS if user-supplied labels or paths contain special chars. */ function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Map Ant Design icon IDs to Material Icons Outlined names for MkDocs */ const ANT_ICON_TO_MATERIAL: Record = { HomeOutlined: 'home', SendOutlined: 'send', EnvironmentOutlined: 'place', ScheduleOutlined: 'schedule', CalendarOutlined: 'event', BarChartOutlined: 'bar_chart', PlayCircleOutlined: 'play_circle', HeartOutlined: 'favorite_border', DollarOutlined: 'attach_money', ShoppingOutlined: 'shopping_bag', LinkOutlined: 'link', GlobalOutlined: 'language', BookOutlined: 'menu_book', TagOutlined: 'sell', VideoCameraOutlined: 'videocam', FileTextOutlined: 'description', TrophyOutlined: 'emoji_events', FolderOutlined: 'folder', AppstoreOutlined: 'apps', WalletOutlined: 'account_balance_wallet', }; /** * Convert an Ant Design icon name to a Material Icons ligature name. * Uses the explicit mapping first, then falls back to stripping the * Outlined/Filled/TwoTone suffix and converting PascalCase to snake_case. */ function toMaterialIcon(antIcon: string): string { if (ANT_ICON_TO_MATERIAL[antIcon]) return ANT_ICON_TO_MATERIAL[antIcon]; // Fallback: strip suffix, convert PascalCase → snake_case const base = antIcon.replace(/(Outlined|Filled|TwoTone)$/, ''); return base.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); } interface NavConfigItem { id: string; label: string; path: string; icon: string; enabled: boolean; order: number; type: 'builtin' | 'custom' | 'group'; featureFlag?: string; external?: boolean; children?: NavConfigItem[]; } /** Extended nav item with rendering hints (not persisted in schema) */ interface RenderNavItem extends HeaderNavItem { /** When true, path is rendered as direct href (not rewritten via data-path JS) */ isAbsoluteHref?: boolean; } /** A group of nav items rendered as a dropdown on desktop / expandable section on mobile */ interface RenderNavGroup { id: string; label: string; icon: string; type: 'group'; children: RenderNavItem[]; } /** A renderable item: either a single link or a group dropdown */ type RenderItem = RenderNavItem | RenderNavGroup; class HeaderBuilderService { /** * Read the current header config from disk. * Returns defaults if no config file exists. */ async readConfig(): Promise { try { if (!existsSync(CONFIG_PATH)) { return { ...DEFAULT_CONFIG }; } const raw = await readFile(CONFIG_PATH, 'utf-8'); const parsed = JSON.parse(raw); const validated = headerConfigSchema.parse(parsed); return validated; } catch (err) { logger.warn('Failed to read header config, returning defaults', err); return { ...DEFAULT_CONFIG }; } } /** * Validate, save config, and regenerate main.html. */ async writeConfig(config: HeaderConfig): Promise { // Validate with Zod const validated = headerConfigSchema.parse(config); // Write config JSON await writeFile(CONFIG_PATH, JSON.stringify(validated, null, 2), 'utf-8'); logger.info('Header config saved'); // Generate or remove main.html if (validated.enabled) { const html = this.generateMainHtml(validated); await writeFile(MAIN_HTML_PATH, html, 'utf-8'); logger.info('Generated main.html with header nav bar'); } else { // Write minimal passthrough so landing pages still extend main.html const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n'; await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8'); logger.info('Generated passthrough main.html (header disabled)'); } } /** * Reset to defaults: remove config file and main.html. */ async resetToDefaults(): Promise { try { await unlink(CONFIG_PATH); } catch { /* file may not exist */ } try { await unlink(MAIN_HTML_PATH); } catch { /* file may not exist */ } logger.info('Header config reset to defaults'); } /** * Generate the Jinja2 main.html template from config. * Mirrors the PublicNavBar style: 56px gradient bar, left brand, right nav links. */ generateMainHtml(config: HeaderConfig | { enabled: boolean; items: RenderNavItem[]; style: HeaderConfig['style'] & { colorBgBase?: string; colorBgContainer?: string } }): string { const enabledItems = config.items .filter((item) => item.enabled) .sort((a, b) => a.order - b.order); const links = enabledItems.map((item) => this.renderNavLink(item)).join('\n '); const { backgroundColor, textColor } = config.style; const colorBgBase = ('colorBgBase' in config.style ? config.style.colorBgBase : undefined) || '#0d1b2a'; const colorBgContainer = ('colorBgContainer' in config.style ? config.style.colorBgContainer : undefined) || '#1b2838'; return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #} {% extends "base.html" %} {% block announce %}
{{ config.site_name }}
{% endblock %} `; } /** * Regenerate main.html from the centralized navConfig stored in SiteSettings. * Called when navConfig changes via the settings API. */ async regenerateFromNavConfig( navConfigItems: NavConfigItem[], settings?: { publicHeaderGradient?: string; publicColorBgBase?: string; publicColorBgContainer?: string; // Feature flags enableInfluence?: boolean | null; enableMap?: boolean | null; enableMediaFeatures?: boolean | null; enablePayments?: boolean | null; enableEvents?: boolean | null; enableMeetingPlanner?: boolean | null; enableTicketedEvents?: boolean | null; enableSocial?: boolean | null; enableMeet?: boolean | null; enableLandingPages?: boolean | null; }, ): Promise { try { // Opt-out flags: visible by default, hidden only when explicitly false const OPT_OUT_FLAGS = new Set(['enableInfluence', 'enableMap', 'enableMediaFeatures', 'enableEvents']); const featureFlagMap: Record = { enableInfluence: settings?.enableInfluence, enableMap: settings?.enableMap, enableMediaFeatures: settings?.enableMediaFeatures, enablePayments: settings?.enablePayments, enableEvents: settings?.enableEvents, enableMeetingPlanner: settings?.enableMeetingPlanner, enableTicketedEvents: settings?.enableTicketedEvents, enableSocial: settings?.enableSocial, enableMeet: settings?.enableMeet, enableLandingPages: settings?.enableLandingPages, }; /** Check if an item passes its feature flag filter */ const passesFeatureFlag = (item: NavConfigItem): boolean => { if (!item.featureFlag) return true; if (OPT_OUT_FLAGS.has(item.featureFlag)) { return featureFlagMap[item.featureFlag] !== false; } return featureFlagMap[item.featureFlag] === true; }; /** Convert a NavConfigItem to a RenderNavItem */ const toRenderItem = (item: NavConfigItem): RenderNavItem => { let resolvedPath = item.path; if (item.path === '$landing') resolvedPath = '/'; else if (item.path === '$docs') resolvedPath = '/docs/'; return { id: item.id, label: item.label, path: resolvedPath, icon: toMaterialIcon(item.icon), enabled: true, order: item.order, type: item.type as 'builtin' | 'custom', openInNewTab: item.path.startsWith('$') ? false : item.external, isAbsoluteHref: item.path.startsWith('$'), }; }; // Build group-aware RenderItem[] preserving dropdown structure const renderItems: RenderItem[] = []; const sortedTopLevel = [...navConfigItems].sort((a, b) => a.order - b.order); for (const item of sortedTopLevel) { if (!item.enabled) continue; if (item.type === 'group' && item.children) { // Check group-level feature flag if (!passesFeatureFlag(item)) continue; // Filter children individually const enabledChildren = item.children .filter((child) => child.enabled && passesFeatureFlag(child)) .sort((a, b) => a.order - b.order) .map(toRenderItem); if (enabledChildren.length === 0) continue; // If only one child survives, render it as a flat item instead of a dropdown if (enabledChildren.length === 1) { renderItems.push(enabledChildren[0]); } else { renderItems.push({ id: item.id, label: item.label, icon: toMaterialIcon(item.icon), type: 'group', children: enabledChildren, }); } } else { if (!passesFeatureFlag(item)) continue; renderItems.push(toRenderItem(item)); } } if (renderItems.length === 0) { const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n'; await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8'); logger.info('Generated passthrough main.html (no nav items enabled)'); return; } const backgroundColor = settings?.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)'; const colorBgBase = settings?.publicColorBgBase || '#0d1b2a'; const colorBgContainer = settings?.publicColorBgContainer || '#1b2838'; const html = this.generateMainHtmlV2(renderItems, { backgroundColor, textColor: '#ffffff', colorBgBase, colorBgContainer, }); await writeFile(MAIN_HTML_PATH, html, 'utf-8'); logger.info('Regenerated main.html from navConfig'); } catch (err) { logger.error('Failed to regenerate main.html from navConfig', err); } } /** * Generate main.html from a mixed RenderItem[] (groups + flat items). * Groups render as CSS hover dropdowns on desktop and expandable sections on mobile. */ private generateMainHtmlV2( items: RenderItem[], style: { backgroundColor: string; textColor: string; colorBgBase: string; colorBgContainer: string }, ): string { const { backgroundColor, textColor, colorBgBase, colorBgContainer } = style; const desktopLinks = items .map((item) => item.type === 'group' ? this.renderNavDropdown(item as RenderNavGroup) : this.renderNavLink(item as RenderNavItem), ) .join('\n '); const mobileLinks = items .map((item) => item.type === 'group' ? this.renderMobileGroup(item as RenderNavGroup) : this.renderMobileNavLink(item as RenderNavItem), ) .join('\n '); return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #} {% extends "base.html" %} {% block announce %}
{{ config.site_name }}
{% endblock %} {% block header %}
{% if config.theme.palette %} {% if not config.theme.palette is mapping %} {% include "partials/palette.html" %} {% endif %} {% endif %}
{% if "material/search" in config.plugins %} {% include "partials/search.html" %} {% endif %}
{% endblock %} {% block tabs %}{% endblock %} `; } /** * Render a desktop dropdown menu for a nav group. * Uses pure CSS :hover — no JavaScript needed for desktop. */ private renderNavDropdown(group: RenderNavGroup): string { const iconHtml = group.icon ? `${escapeHtml(group.icon)}` : ''; const childLinks = group.children .map((child) => { const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://'); const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''; const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : ''; const childIcon = child.icon ? `${escapeHtml(child.icon)}` : ''; const href = isAbsolute ? `href="${escapeHtml(child.path)}"` : `href="#" data-path="${escapeHtml(child.path)}"`; return ` ${childIcon}${escapeHtml(child.label)}`; }) .join('\n'); return `
${iconHtml} ${escapeHtml(group.label)} expand_more
${childLinks}
`; } /** * Render a mobile expandable group section. * Uses JS click handler to toggle visibility. */ private renderMobileGroup(group: RenderNavGroup): string { const iconHtml = group.icon ? `${escapeHtml(group.icon)}` : ''; const childLinks = group.children .map((child) => { const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://'); const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''; const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : ''; const childIcon = child.icon ? `${escapeHtml(child.icon)}` : ''; const href = isAbsolute ? `href="${escapeHtml(child.path)}"` : `href="#" data-path="${escapeHtml(child.path)}"`; return ` ${childIcon}${escapeHtml(child.label)}`; }) .join('\n'); return `
${iconHtml} ${escapeHtml(group.label)} expand_more
${childLinks}
`; } /** * Render a single nav link element. * Items with isAbsoluteHref use direct href (e.g. $token-resolved paths like / or /docs/). */ private renderNavLink(item: RenderNavItem): string { const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://'); const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''; const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : ''; const iconHtml = item.icon ? `${escapeHtml(item.icon)}` : ''; if (isAbsolute) { return `${iconHtml}${escapeHtml(item.label)}`; } return `${iconHtml}${escapeHtml(item.label)}`; } /** * Render a single mobile drawer nav link. */ private renderMobileNavLink(item: RenderNavItem): string { const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://'); const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''; const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : ''; const iconHtml = item.icon ? `${escapeHtml(item.icon)}` : ''; if (isAbsolute) { return `${iconHtml}${escapeHtml(item.label)}`; } return `${iconHtml}${escapeHtml(item.label)}`; } } export const headerBuilderService = new HeaderBuilderService();