@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
1,239 lines (1,098 loc) • 56 kB
JavaScript
/**
* Compodoc Template Playground Application
* Main JavaScript file that handles all playground functionality
*/
class TemplatePlayground {
constructor() {
this.editor = null;
this.currentTemplate = null;
this.currentData = {};
this.originalData = {};
this.customVariables = {};
this.debounceTimer = null;
this.sessionId = null;
// Track last visited doc URL in the iframe
this.lastVisitedDocUrl = null;
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'compodoc-iframe-navigate') {
this.lastVisitedDocUrl = event.data.url;
// Optionally persist in sessionStorage
sessionStorage.setItem('compodocLastVisitedDocUrl', this.lastVisitedDocUrl);
}
});
// Restore from sessionStorage if available
const storedUrl = sessionStorage.getItem('compodocLastVisitedDocUrl');
if (storedUrl) {
this.lastVisitedDocUrl = storedUrl;
}
this.init();
}
async init() {
try {
// Check JSZip availability on startup
setTimeout(() => {
if (typeof JSZip !== 'undefined') {
console.log('✅ JSZip loaded successfully');
} else if (window.JSZipLoadError) {
console.error('❌ JSZip failed to load from all CDNs');
} else {
console.warn('⚠️ JSZip still loading...');
}
}, 2000);
// First create a session
await this.createSession();
await this.initializeMonacoEditor();
this.setupEventListeners();
this.setupResizer();
await this.loadTemplateList();
console.log('🎨 Template Playground initialized successfully');
} catch (error) {
console.error('Failed to initialize Template Playground:', error);
this.showError('Failed to initialize editor. Please refresh the page.');
}
}
async createSession() {
try {
const response = await fetch('/api/session/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const result = await response.json();
this.sessionId = result.sessionId;
console.log('Session created:', this.sessionId);
} catch (error) {
console.error('Error creating session:', error);
throw error;
}
}
async loadTemplateList() {
try {
const response = await fetch(`/api/session/${this.sessionId}/templates`);
if (!response.ok) {
throw new Error('Failed to load templates');
}
const result = await response.json();
const templates = result.templates;
// Update the template dropdown
const dropdown = document.getElementById('templateSelect');
if (dropdown) {
dropdown.innerHTML = '<option value="">Select a template...</option>';
// Group templates by type
const mainTemplates = templates.filter(t => t.type === 'template');
const partials = templates.filter(t => t.type === 'partial');
if (mainTemplates.length > 0) {
const mainGroup = document.createElement('optgroup');
mainGroup.label = 'Main Templates';
mainTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.path;
option.textContent = template.name;
mainGroup.appendChild(option);
});
dropdown.appendChild(mainGroup);
}
if (partials.length > 0) {
const partialsGroup = document.createElement('optgroup');
partialsGroup.label = 'Partials';
partials.forEach(template => {
const option = document.createElement('option');
option.value = template.path;
option.textContent = template.name;
partialsGroup.appendChild(option);
});
dropdown.appendChild(partialsGroup);
}
}
} catch (error) {
console.error('Error loading template list:', error);
this.showError('Failed to load template list');
}
}
async initializeMonacoEditor() {
return new Promise((resolve, reject) => {
require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
require(['vs/editor/editor.main'], () => {
try {
// Register Handlebars language
monaco.languages.register({ id: 'handlebars' });
// Define Handlebars syntax highlighting
monaco.languages.setMonarchTokensProvider('handlebars', {
tokenizer: {
root: [
[/\{\{\{.*?\}\}\}/, 'string.html'],
[/\{\{.*?\}\}/, 'keyword'],
[/<[^>]+>/, 'tag'],
[/<!--.*?-->/, 'comment'],
[/"[^"]*"/, 'string'],
[/'[^']*'/, 'string'],
[/[{}[\]()]/, 'delimiter.bracket'],
[/[a-zA-Z_$][\w$]*/, 'identifier'],
]
}
});
// Create the editor
this.editor = monaco.editor.create(document.getElementById('templateEditor'), {
value: '<!-- Select a template to start editing -->',
language: 'handlebars',
theme: 'vs',
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
renderWhitespace: 'selection'
});
// Setup editor change listener with debouncing
this.editor.onDidChangeModelContent(() => {
this.debouncePreviewUpdate();
});
resolve();
} catch (error) {
reject(error);
}
});
});
}
setupEventListeners() {
// Template selection
document.getElementById('templateSelect').addEventListener('change', (e) => {
this.loadTemplate(e.target.value);
});
// Variable management
document.getElementById('resetVariables').addEventListener('click', () => {
this.resetVariables();
});
document.getElementById('exportData').addEventListener('click', () => {
this.exportData();
});
// Template actions
document.getElementById('refreshPreview').addEventListener('click', () => {
this.updatePreview();
});
document.getElementById('copyTemplate').addEventListener('click', () => {
this.copyTemplate();
});
document.getElementById('downloadTemplate').addEventListener('click', () => {
this.downloadTemplate();
});
// Enter key for adding variables
// ['newVariableName', 'newVariableType', 'newVariableValue'].forEach(id => {
// document.getElementById(id).addEventListener('keypress', (e) => {
// if (e.key === 'Enter' && !e.shiftKey) {
// e.preventDefault();
// this.addCustomVariable();
// }
// });
// });
}
setupResizer() {
const resizer = document.getElementById('resizer');
const variablesPanel = document.querySelector('.variables-panel');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
e.preventDefault();
});
function handleMouseMove(e) {
if (!isResizing) return;
const containerRect = document.querySelector('.playground-content').getBoundingClientRect();
const newWidth = e.clientX - containerRect.left;
if (newWidth >= 250 && newWidth <= containerRect.width - 400) {
variablesPanel.style.width = newWidth + 'px';
}
}
function handleMouseUp() {
isResizing = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
}
async loadTemplate(templatePath) {
if (!templatePath) {
this.clearTemplate();
return;
}
try {
this.showLoading('Loading template and configuration...');
// Load configuration data instead of template-specific data
const configResponse = await fetch(`/api/session/${this.sessionId}/config`);
if (!configResponse.ok) {
throw new Error('Failed to load configuration data');
}
const { config } = await configResponse.json();
// Format config data to match expected structure
this.currentData = {
categories: {
compodocConfig: {
title: 'Compodoc Configuration Options',
description: 'Edit these configuration options to customize the generated documentation. Changes will automatically regenerate the documentation.',
data: config
}
}
};
this.originalData = JSON.parse(JSON.stringify(this.currentData));
// Load template content - try specific template first, then fallback
let templateContent = '';
try {
const encodedTemplatePathForContent = encodeURIComponent(templatePath);
const templateResponse = await fetch(`/api/session/${this.sessionId}/template/${encodedTemplatePathForContent}`);
if (templateResponse.ok) {
const template = await templateResponse.json();
templateContent = template.content;
} else {
// Use a generic template based on type
templateContent = this.getGenericTemplate(templatePath);
}
} catch (error) {
console.warn('Could not load specific template, using generic:', error);
templateContent = this.getGenericTemplate(templatePath);
}
this.currentTemplate = {
path: templatePath,
content: templateContent
};
// Defensive: Only update metadata if config data is present
if (this.currentData && this.currentData.categories && this.currentData.categories.compodocConfig && this.currentData.categories.compodocConfig.data) {
this.updateTemplateMetadata(templatePath, this.currentData.categories.compodocConfig.data);
} else {
this.updateTemplateMetadata(templatePath, {});
}
this.editor.setValue(templateContent);
this.renderVariables();
this.updatePreview();
this.hideLoading();
} catch (error) {
console.error('Error loading template:', error);
this.showError(`Failed to load template: ${error.message}`);
}
}
getGenericTemplate(templatePath) {
const templates = {
component: `<ol class="breadcrumb">
<li class="breadcrumb-item">{{t "components" }}</li>
<li class="breadcrumb-item">{{name}}</li>
</ol>
<div class="component-info">
<h3>{{name}}</h3>
<p class="description">{{description}}</p>
{{#if selector}}
<p><strong>Selector:</strong> <code>{{selector}}</code></p>
{{/if}}
{{#if inputs}}
<h4>Inputs</h4>
<ul>
{{#each inputs}}
<li><strong>{{name}}</strong> ({{type}}): {{description}}</li>
{{/each}}
</ul>
{{/if}}
{{#if outputs}}
<h4>Outputs</h4>
<ul>
{{#each outputs}}
<li><strong>{{name}}</strong> ({{type}}): {{description}}</li>
{{/each}}
</ul>
{{/if}}
</div>`,
module: `<ol class="breadcrumb">
<li class="breadcrumb-item">{{t "modules" }}</li>
<li class="breadcrumb-item">{{name}}</li>
</ol>
<div class="module-info">
<h3>{{name}}</h3>
<p class="description">{{description}}</p>
{{#if declarations}}
<h4>Declarations</h4>
<ul>
{{#each declarations}}
<li>{{name}} ({{type}})</li>
{{/each}}
</ul>
{{/if}}
{{#if imports}}
<h4>Imports</h4>
<ul>
{{#each imports}}
<li>{{name}}</li>
{{/each}}
</ul>
{{/if}}
</div>`,
interface: `<ol class="breadcrumb">
<li class="breadcrumb-item">{{t "interfaces" }}</li>
<li class="breadcrumb-item">{{name}}</li>
</ol>
<div class="interface-info">
<h3>{{name}}</h3>
<p class="description">{{description}}</p>
{{#if properties}}
<h4>Properties</h4>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Optional</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{{#each properties}}
<tr>
<td><code>{{name}}</code></td>
<td><code>{{type}}</code></td>
<td>{{#if optional}}Yes{{else}}No{{/if}}</td>
<td>{{description}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</div>`,
class: `<ol class="breadcrumb">
<li class="breadcrumb-item">{{t "classes" }}</li>
<li class="breadcrumb-item">{{name}}</li>
</ol>
<div class="class-info">
<h3>{{name}}</h3>
<p class="description">{{description}}</p>
{{#if methods}}
<h4>Methods</h4>
{{#each methods}}
<div class="method">
<h5>{{name}}</h5>
<p>{{description}}</p>
<p><strong>Returns:</strong> <code>{{type}}</code></p>
</div>
{{/each}}
{{/if}}
</div>`
};
return templates[templatePath] || `<h3>{{name}}</h3>
<p>{{description}}</p>
<p><strong>Type:</strong> ${templatePath}</p>`;
}
updateTemplateMetadata(templatePath, data) {
const metadata = document.getElementById('templateMetadata');
document.getElementById('templateName').textContent = data.name || templatePath;
document.getElementById('templateFile').textContent = data.file || `${templatePath}.hbs`;
document.getElementById('templateDescription').textContent = data.description || `Template for ${templatePath}`;
metadata.style.display = 'block';
}
renderVariables() {
const container = document.getElementById('variablesList');
container.innerHTML = '';
// Check if we have the new categorized data format
if (this.currentData && this.currentData.categories) {
this.renderCategorizedVariables(container);
} else {
// Fallback to legacy format
this.renderLegacyVariables(container);
}
// Remove custom variables section
// this.renderCustomVariables(container);
}
renderCategorizedVariables(container) {
const categories = this.currentData.categories;
// Only render Compodoc Configuration section
if (categories.compodocConfig) {
// Add a special header for config section
const configHeader = document.createElement('div');
configHeader.innerHTML = `
<div style="
background: linear-gradient(135deg, #2196F3, #1976D2);
color: white;
padding: 8px 16px;
margin-bottom: 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
">
<i class="fas fa-cog"></i> Editable Configuration
</div>
`;
container.appendChild(configHeader);
this.createCategorySection(
container,
'compodoc-config',
categories.compodocConfig.title,
categories.compodocConfig.description,
categories.compodocConfig.data,
'#2196F3', // Blue for config
'config' // Mark as config category - these will be editable
);
}
// Template variables section removed - only show config options
}
createCategorySection(container, categoryId, title, description, data, accentColor, categoryType = 'template') {
const section = document.createElement('div');
section.className = 'variable-category';
section.style.marginBottom = '20px';
section.innerHTML = `
<div class="category-header" style="
background: linear-gradient(135deg, ${accentColor}15, ${accentColor}05);
border-left: 4px solid ${accentColor};
padding: 12px 16px;
margin-bottom: 10px;
border-radius: 0 6px 6px 0;
cursor: pointer;
transition: all 0.3s ease;
">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h3 style="
margin: 0;
color: ${accentColor};
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
">${title}</h3>
<p style="
margin: 4px 0 0 0;
color: #666;
font-size: 12px;
line-height: 1.4;
">${description}</p>
</div>
<button class="category-toggle" style="
background: ${accentColor};
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
transition: transform 0.3s ease;
">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="category-content" style="
padding: 0 16px;
max-height: 400px;
overflow-y: auto;
border-left: 4px solid ${accentColor}20;
margin-left: 12px;
">
<div class="category-variables"></div>
</div>
`;
// Add toggle functionality
const header = section.querySelector('.category-header');
const content = section.querySelector('.category-content');
const toggle = section.querySelector('.category-toggle');
header.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none';
content.style.display = isCollapsed ? 'block' : 'none';
toggle.style.transform = isCollapsed ? 'rotate(0deg)' : 'rotate(-180deg)';
});
container.appendChild(section);
// Populate variables in this category
const variableContainer = section.querySelector('.category-variables');
this.createVariableElements(data, '', variableContainer, 0, accentColor, categoryType);
}
renderLegacyVariables(container) {
// Legacy rendering for backward compatibility
const section = document.createElement('div');
section.innerHTML = `
<div class="category-header" style="padding: 12px 16px; background: #f8f9fa; margin-bottom: 10px;">
<h3 style="margin: 0; color: #333; font-size: 14px;">Template Data</h3>
<p style="margin: 4px 0 0 0; color: #666; font-size: 12px;">Available template variables and context</p>
</div>
<div class="category-variables"></div>
`;
container.appendChild(section);
const variableContainer = section.querySelector('.category-variables');
this.createVariableElements(this.currentData, '', variableContainer);
}
renderCustomVariables(container) {
if (Object.keys(this.customVariables).length === 0) return;
const section = document.createElement('div');
section.className = 'variable-category custom-variables';
section.style.marginTop = '20px';
section.innerHTML = `
<div class="category-header" style="
background: linear-gradient(135deg, #FF9800 15%, #FF9800 05%);
border-left: 4px solid #FF9800;
padding: 12px 16px;
margin-bottom: 10px;
border-radius: 0 6px 6px 0;
">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h3 style="
margin: 0;
color: #FF9800;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
">Custom Variables</h3>
<p style="
margin: 4px 0 0 0;
color: #666;
font-size: 12px;
line-height: 1.4;
">User-defined variables for template customization</p>
</div>
<button class="btn-icon" id="addCustomVariableBtn" style="
background: #FF9800;
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
" onclick="templatePlayground.addCustomVariable()" title="Add Custom Variable">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="custom-variables-content" style="
padding: 0 16px;
border-left: 4px solid #FF980020;
margin-left: 12px;
"></div>
`;
container.appendChild(section);
// Add custom variables
const customContainer = section.querySelector('.custom-variables-content');
Object.entries(this.customVariables).forEach(([key, value]) => {
this.createVariableElement(key, typeof value, value, customContainer, true, null, '#FF9800');
});
}
createVariableElements(obj, prefix, container, depth = 0, accentColor = '#007bff', categoryType = 'template') {
if (depth > 3) return; // Prevent too deep nesting
const sortedEntries = Object.entries(obj).sort(([a], [b]) => {
// Sort by importance: put functions and complex objects at the end
const aIsSimple = typeof obj[a] !== 'object' && typeof obj[a] !== 'function';
const bIsSimple = typeof obj[b] !== 'object' && typeof obj[b] !== 'function';
if (aIsSimple && !bIsSimple) return -1;
if (!aIsSimple && bIsSimple) return 1;
return a.localeCompare(b);
});
sortedEntries.forEach(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Create expandable object
if (depth < 3) {
const objectElement = document.createElement('div');
objectElement.className = 'variable-item expandable-object';
objectElement.style.marginBottom = '8px';
const isExpanded = depth === 0; // Expand top-level objects by default
objectElement.innerHTML = `
<div class="variable-header" style="
display: flex;
align-items: center;
padding: 8px 12px;
background: ${depth === 0 ? '#f8f9fa' : '#ffffff'};
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
">
<button class="expand-toggle" style="
background: none;
border: none;
color: ${accentColor};
font-size: 12px;
margin-right: 8px;
cursor: pointer;
transform: ${isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'};
transition: transform 0.2s ease;
">
<i class="fas fa-chevron-right"></i>
</button>
<div class="variable-name" style="
font-weight: ${depth === 0 ? '600' : '500'};
color: ${depth === 0 ? '#333' : '#555'};
flex: 1;
font-size: ${depth === 0 ? '13px' : '12px'};
">${key}</div>
<div class="variable-type" style="
font-size: 10px;
color: #888;
background: #f1f3f4;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
">object</div>
</div>
<div class="nested-variables" style="
margin-top: 4px;
padding-left: ${(depth + 1) * 16}px;
border-left: 2px solid ${accentColor}20;
margin-left: 20px;
display: ${isExpanded ? 'block' : 'none'};
"></div>
`;
// Add click handler for expansion
const header = objectElement.querySelector('.variable-header');
const toggle = objectElement.querySelector('.expand-toggle');
const nested = objectElement.querySelector('.nested-variables');
header.addEventListener('click', () => {
const isCurrentlyExpanded = nested.style.display !== 'none';
nested.style.display = isCurrentlyExpanded ? 'none' : 'block';
toggle.style.transform = isCurrentlyExpanded ? 'rotate(0deg)' : 'rotate(90deg)';
});
container.appendChild(objectElement);
const nestedContainer = objectElement.querySelector('.nested-variables');
this.createVariableElements(value, fullKey, nestedContainer, depth + 1, accentColor, categoryType);
}
} else {
this.createVariableElement(key, typeof value, value, container, false, fullKey, accentColor, categoryType);
}
});
}
createVariableElement(name, type, value, container, isCustom = false, fullPath = null, accentColor = '#007bff', categoryType = 'template') {
const variableElement = document.createElement('div');
variableElement.className = 'variable-item simple-variable';
variableElement.style.marginBottom = '6px';
let displayValue = value;
let rows = 1;
if (typeof value === 'object' && value !== null) {
displayValue = JSON.stringify(value, null, 2);
rows = Math.min(displayValue.split('\n').length, 6);
} else if (typeof value === 'string') {
displayValue = value;
rows = Math.min(displayValue.split('\n').length, 4);
} else if (typeof value === 'function') {
displayValue = value.toString().substring(0, 100) + '...';
type = 'function';
rows = 2;
} else {
displayValue = String(value);
}
// Determine if variable should be editable
// Config variables are editable, template variables are read-only, custom variables are always editable
const isEditable = isCustom || categoryType === 'config';
// Determine type color
const getTypeColor = (type) => {
switch (type) {
case 'string': return '#4CAF50';
case 'number': return '#2196F3';
case 'boolean': return '#FF9800';
case 'function': return '#9C27B0';
case 'object': return '#607D8B';
default: return '#666';
}
};
// Render dropdown for string config variables with known options
let inputElement = '';
const selectOptions = {
theme: [
'gitbook', 'laravel', 'material', 'readthedocs', 'postmark', 'vagrant', 'minimal', 'default', 'plain', 'stripe', 'aglio', 'book', 'github', 'vuepress', 'docusaurus', 'mkdocs', 'slate', 'swagger', 'modern', 'clean', 'classic', 'simple', 'bootstrap', 'angular', 'react', 'vue', 'bulma', 'tailwind', 'windicss', 'dracula', 'solarized', 'nord', 'night', 'light', 'dark', 'custom'
],
language: [
'en-US', 'fr-FR', 'de-DE', 'es-ES', 'it-IT', 'ja-JP', 'ko-KR', 'nl-NL', 'pl-PL', 'pt-BR', 'ru-RU', 'sk-SK', 'zh-CN', 'zh-TW', 'bg-BG', 'hu-HU', 'ka-GE'
],
exportFormat: [
'html', 'json', 'pdf'
]
};
if (isEditable && type === 'string' && selectOptions[name]) {
inputElement = `<select class="variable-value" onchange="templatePlayground.updateVariable('${fullPath || name}', this.value, ${isCustom}, '${categoryType}')">
${selectOptions[name].map(opt => `<option value="${opt}" ${value === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>`;
} else if (isEditable && type === 'boolean') {
inputElement = `<input type="checkbox" class="variable-value" style="width: 18px; height: 18px; margin-right: 8px; vertical-align: middle;" ${value ? 'checked' : ''} onchange="templatePlayground.updateVariable('${fullPath || name}', this.checked, ${isCustom}, '${categoryType}')">`;
} else {
inputElement = `<textarea class="variable-value" style="
width: 100%;
min-height: ${rows * 16}px;
border: 1px solid #ddd;
border-radius: 3px;
padding: 6px 8px;
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
background: #f8f9fa;
${isEditable ? '' : 'background: #f1f3f4; cursor: not-allowed;'}
}"
${isEditable ? `onchange=\"templatePlayground.updateVariable('${fullPath || name}', this.value, ${isCustom}, '${categoryType}')\"` : 'readonly'}
rows="${rows}">${displayValue}</textarea>`;
}
variableElement.innerHTML = `
<div style="
display: flex;
align-items: flex-start;
padding: 8px 12px;
background: white;
border: 1px solid #e9ecef;
border-radius: 4px;
transition: all 0.2s ease;
" onmouseover="this.style.borderColor='${accentColor}'" onmouseout="this.style.borderColor='#e9ecef'">
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<div class="variable-name" style="
font-weight: 500;
color: #333;
font-size: 12px;
margin-right: 8px;
font-family: 'Consolas', 'Monaco', monospace;
">${name}</div>
<div class="variable-type" style="
font-size: 9px;
color: ${getTypeColor(type)};
background: ${getTypeColor(type)}15;
padding: 2px 6px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
">${type}</div>
${isCustom ? `
<button class="btn-icon" style="
background: #ffebee;
color: #d32f2f;
border: none;
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: auto;
cursor: pointer;
font-size: 10px;
" onclick="templatePlayground.removeVariable('${name}')" title="Remove variable">
<i class="fas fa-times"></i>
</button>
` : ''}
</div>
${inputElement}
</div>
</div>
`;
container.appendChild(variableElement);
}
updateVariable(path, value, isCustom = false, categoryType = 'template') {
try {
let parsedValue;
// Try to parse as JSON first
try {
parsedValue = JSON.parse(value);
} catch {
// If not valid JSON, treat as string
parsedValue = value;
}
if (isCustom) {
this.customVariables[path] = parsedValue;
this.debouncePreviewUpdate();
} else if (categoryType === 'config') {
// Handle config variable updates
this.updateSessionConfig(path, parsedValue);
} else {
// Update nested property for template variables
this.setNestedProperty(this.currentData, path, parsedValue);
this.debouncePreviewUpdate();
}
} catch (error) {
console.error('Error updating variable:', error);
}
}
async updateSessionConfig(configPath, newValue) {
try {
// Update the local config data immediately for responsiveness
this.setNestedProperty(this.currentData.categories.compodocConfig.data, configPath, newValue);
// Prepare config update for server
const configUpdate = {};
this.setNestedProperty(configUpdate, configPath, newValue);
this.showMessage('💾 Saving configuration...', 'info');
// Send config update to server
const response = await fetch(`/api/session/${this.sessionId}/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
config: configUpdate
})
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.showSuccess('✅ Configuration saved! Documentation regenerating...');
// Automatically update the preview after config is saved
this.updatePreview();
} else {
throw new Error(result.message || 'Failed to save configuration');
}
} catch (error) {
console.error('Error updating session config:', error);
this.showError(`❌ Failed to save configuration: ${error.message}`);
// Revert the local change if save failed
this.renderVariables();
}
}
setNestedProperty(obj, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
const target = keys.reduce((current, key) => current && current[key], obj);
if (target && lastKey) {
target[lastKey] = value;
}
}
addCustomVariable() {
const nameInput = document.getElementById('newVariableName');
const typeInput = document.getElementById('newVariableType');
const valueInput = document.getElementById('newVariableValue');
const name = nameInput.value.trim();
const type = typeInput.value.trim() || 'string';
const valueStr = valueInput.value.trim();
if (!name) {
this.showError('Variable name is required');
return;
}
let value;
try {
if (valueStr) {
value = JSON.parse(valueStr);
} else {
value = type === 'boolean' ? false : type === 'number' ? 0 : '';
}
} catch {
value = valueStr;
}
this.customVariables[name] = value;
// Clear inputs
nameInput.value = '';
typeInput.value = '';
valueInput.value = '';
this.renderVariables();
this.debouncePreviewUpdate();
this.showSuccess('Variable added successfully');
}
removeVariable(name) {
delete this.customVariables[name];
this.renderVariables();
this.debouncePreviewUpdate();
}
resetVariables() {
this.currentData = JSON.parse(JSON.stringify(this.originalData));
this.customVariables = {};
this.renderVariables();
this.updatePreview();
this.showSuccess('Variables reset to default values');
}
debouncePreviewUpdate() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.updatePreview();
}, 300);
}
async updatePreview() {
if (!this.currentTemplate) return;
try {
this.setPreviewStatus('🚀 Generating documentation with CompoDoc CLI...', true);
const templateContent = this.editor.getValue();
// Use CompoDoc CLI generation API
const response = await fetch(`/api/session/${this.sessionId}/generate-docs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
customTemplateContent: templateContent,
templatePath: this.currentTemplate ? this.currentTemplate.path : null,
mockData: { ...this.currentData, ...this.customVariables }
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.details || `Server responded with ${response.status}`);
}
const result = await response.json();
if (result.success) {
// Documentation generated successfully, now load it in iframe
this.setPreviewStatus('📄 Loading generated documentation...', true);
// Point iframe to the last visited documentation page if available
const iframe = document.getElementById('templatePreviewFrame');
if (iframe) {
let url = `/docs/${this.sessionId}/index.html?t=` + Date.now();
if (this.lastVisitedDocUrl) {
// Remove /docs/<sessionId>/ prefix if present
let docPath = this.lastVisitedDocUrl.replace(new RegExp(`^/docs/${this.sessionId}/?`), '');
url = `/docs/${this.sessionId}/${docPath}`;
url += (url.includes('?') ? '&' : '?') + 't=' + Date.now();
}
iframe.src = url;
iframe.onload = () => {
this.setPreviewStatus('✅ Documentation loaded successfully', false);
setTimeout(() => {
this.setPreviewStatus('', false);
}, 2000);
};
iframe.onerror = () => {
this.setPreviewStatus('❌ Failed to load generated documentation', false);
};
} else {
this.setPreviewStatus('❌ Preview iframe not found', false);
}
} else {
throw new Error('Documentation generation failed');
}
} catch (error) {
console.error('Error generating documentation:', error);
this.setPreviewStatus(`❌ Error: ${error.message}`, false);
// Show error in iframe
const iframe = document.getElementById('templatePreviewFrame');
if (iframe) {
const errorHtml = `
<html>
<head>
<title>Documentation Generation Error</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 40px;
background: #f8f9fa;
color: #333;
line-height: 1.6;
}
.error-container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 600px;
margin: 0 auto;
}
.error-icon {
color: #dc3545;
font-size: 48px;
text-align: center;
margin-bottom: 20px;
}
.error-title {
color: #dc3545;
margin-bottom: 15px;
text-align: center;
}
.error-message {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
font-family: monospace;
}
.suggestions {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
padding: 15px;
border-radius: 4px;
}
.suggestions h4 {
margin-top: 0;
margin-bottom: 10px;
}
.suggestions ul {
margin-bottom: 0;
padding-left: 20px;
}
.retry-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 15px;
display: block;
margin-left: auto;
margin-right: auto;
}
.retry-button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<h2 class="error-title">Documentation Generation Failed</h2>
<div class="error-message">
<div class="suggestions">
<h4>Possible solutions:</h4>
<ul>
<li>Check if your Handlebars template syntax is valid</li>
<li>Ensure all referenced partials exist</li>
<li>Verify that template variables match the expected data structure</li>
<li>Try refreshing the page and loading a different template</li>
</ul>
</div>
<button class="retry-button" onclick="parent.templatePlayground.updatePreview()">
🔄 Retry Generation
</button>
</div>
</body>
</html>
`;
iframe.srcdoc = errorHtml;
}
}
}
setPreviewStatus(text, isLoading) {
const statusElement = document.getElementById('previewStatus');
statusElement.innerHTML = isLoading ?
`<div class="spinner"></div> ${text}` : text;
}
async copyTemplate() {
try {
await navigator.clipboard.writeText(this.editor.getValue());
this.showSuccess('Template copied to clipboard');
} catch (error) {
console.error('Error copying template:', error);
this.showError('Failed to copy template');
}
}
async downloadTemplate() {
try {
if (!this.sessionId) {
this.showError('No active session. Please refresh the page and try again.');
return;
}
// Show loading state
this.showLoading('Creating complete template package...');
// Call server-side ZIP creation endpoint for all templates
const response = await fetch(`/api/session/${this.sessionId}/download-all-templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.hideLoading();
if (!response.ok) {
if (response.headers.get('content-type')?.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create template package');
} else {
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
}
// Get the ZIP file as a blob
const zipBlob = await response.blob();
// Get filename from response headers or construct it
const conte