'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const { parseThemeMetadata } = require('../src/themeMetadata'); const ROOT = path.join(__dirname, '..'); function read(relativePath) { return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); } function attributeValues(html, attribute) { const pattern = new RegExp(`${attribute}="([^"]+)"`, 'g'); return [...html.matchAll(pattern)].map(match => match[1]); } function declaredProperties(css) { const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); return new Set([...withoutComments.matchAll(/(--[a-z0-9-]+)\s*:/gi)].map(match => match[1])); } function cssRules(css) { const source = css.replace(/\/\*[\s\S]*?\*\//g, ''); const stack = []; const rules = []; let tokenStart = 0; for (let i = 0; i < source.length; i++) { if (source[i] === '{') { const prelude = source.slice(tokenStart, i).trim(); const entry = prelude.startsWith('@') ? { type: 'at-rule', prelude } : { type: 'rule', selector: prelude, bodyStart: i + 1, atRules: stack.filter(item => item.type === 'at-rule').map(item => item.prelude), }; stack.push(entry); tokenStart = i + 1; continue; } if (source[i] === '}') { const entry = stack.pop(); if (entry?.type === 'rule') { entry.declarations = source.slice(entry.bodyStart, i); rules.push(entry); } tokenStart = i + 1; continue; } if (source[i] === ';') tokenStart = i + 1; } return rules; } function declarationNames(block) { return [...block.matchAll(/(?:^|;)\s*([a-z-]+)\s*:/gi)].map(match => match[1]); } function declarationMap(block) { return Object.fromEntries( [...block.matchAll(/(?:^|;)\s*([a-z-]+)\s*:\s*([^;]+)/gi)] .map(match => [match[1], match[2].trim()]) ); } function contrastRatio(first, second) { const luminance = hex => { const channels = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255); const linear = channels.map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; }; const values = [luminance(first), luminance(second)].sort((a, b) => b - a); return (values[0] + 0.05) / (values[1] + 0.05); } const APP_REGIONS = [ 'app-shell', 'workspace', 'server-rail', 'server-list', 'navigation-sidebar', 'account', 'sidebar-content', 'join-channel', 'create-channel', 'channels', 'direct-messages', 'sidebar-footer', 'sidebar-actions', 'theme-picker', 'main', 'channel-header', 'welcome', 'message-area', 'webcams', 'screen-shares', 'music-player', 'pinned-messages', 'message-list', 'composer', 'soundboard', 'context-sidebar', 'search-results', 'voice-roster', 'member-list', 'voice-settings', 'voice-controls', 'status-bar', 'thread-panel', 'settings' ]; const AUTH_REGIONS = [ 'auth-shell', 'auth-card', 'auth-header', 'theme-picker' ]; const PUBLIC_TOKENS = [ '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover', '--bg-active', '--bg-input', '--bg-card', '--accent', '--accent-hover', '--accent-glow', '--accent-text', '--text-primary', '--text-secondary', '--text-muted', '--text-link', '--border', '--border-light', '--success', '--success-text', '--danger', '--danger-text', '--warning', '--warning-text', '--led-on', '--led-off', '--led-glow', '--font-main', '--font-mono', '--font-heading', '--radius', '--radius-sm', '--transition', '--sidebar-width', '--right-width', '--msg-glow', '--scanline' ]; const APP_REGION_IDS = { 'app-shell': 'app', workspace: 'app-body', 'server-rail': 'server-bar', 'server-list': 'server-list', 'sidebar-content': 'sidebar-mod-container', 'create-channel': 'admin-controls', channels: 'channels-pane', 'direct-messages': 'dm-pane', 'theme-picker': 'theme-selector', welcome: 'no-channel-msg', 'message-area': 'message-area', webcams: 'webcam-container', 'screen-shares': 'screen-share-container', 'music-player': 'music-panel', 'pinned-messages': 'pinned-panel', 'message-list': 'messages', composer: 'message-input-area', soundboard: 'sb-sidebar-panel', 'context-sidebar': 'right-sidebar', 'search-results': 'search-panel', 'voice-roster': 'right-sidebar-voice', 'member-list': 'right-sidebar-users', 'voice-settings': 'voice-settings-panel', 'voice-controls': 'voice-panel', 'status-bar': 'status-bar', 'thread-panel': 'thread-panel' }; function openingTagById(html, id) { return html.match(new RegExp(`<[^>]+\\bid="${id}"[^>]*>`))?.[0] || ''; } function markdownSection(markdown, start, end) { const startIndex = markdown.indexOf(start); const endIndex = markdown.indexOf(end, startIndex + start.length); assert.notEqual(startIndex, -1, `missing documentation section: ${start}`); assert.notEqual(endIndex, -1, `missing documentation section: ${end}`); return markdown.slice(startIndex, endIndex); } test('app and authentication pages expose Theme API v1 page markers', () => { const appHtml = read('public/app.html'); const authHtml = read('public/index.html'); assert.match(appHtml, /]*\bdata-haven-theme-api="1"/); assert.match(authHtml, /]*\bdata-haven-theme-api="1"/); assert.match(appHtml, /
]*\bdata-haven-page="app"/); assert.match(authHtml, /]*\bdata-haven-page="auth"/); }); test('application regions are present exactly once', () => { const appHtml = read('public/app.html'); const regions = attributeValues(appHtml, 'data-haven-region'); assert.deepEqual([...regions].sort(), [...APP_REGIONS].sort()); assert.equal(new Set(regions).size, regions.length); for (const [region, id] of Object.entries(APP_REGION_IDS)) { assert.match(openingTagById(appHtml, id), new RegExp(`data-haven-region="${region}"`)); } assert.match(appHtml, /