create-roadkit
Version:
Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export
260 lines (225 loc) • 8.21 kB
text/typescript
/**
* YAML parsing utilities for roadmap content
* Handles loading, parsing, and validating roadmap YAML files
*/
import { RoadmapData, RoadmapItem, RoadmapConfig, RoadmapStatus } from '../types/roadmap';
/**
* Parse YAML content into RoadmapData structure
* @param yamlContent Raw YAML content as string
* @returns Parsed and validated roadmap data
*/
export function parseRoadmapYaml(yamlContent: string): RoadmapData {
try {
// Use the yaml library for proper parsing
const YAML = require('yaml');
const data = YAML.parse(yamlContent);
// Validate and transform the parsed data
const validatedData = validateRoadmapData(data);
return validatedData;
} catch (error) {
throw new Error(`Failed to parse roadmap YAML: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Simple YAML parser for basic roadmap structures
* Note: In production, use a proper YAML library like 'yaml' or 'js-yaml'
*/
function parseSimpleYaml(content: string): any {
const lines = content.split('\n');
const result: any = {};
let currentSection = '';
let currentItem: any = null;
let itemIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) continue;
// Handle top-level sections
if (line.endsWith(':') && !line.startsWith(' ') && !line.startsWith('-')) {
currentSection = line.slice(0, -1);
if (currentSection === 'items') {
result[currentSection] = [];
} else if (currentSection === 'config') {
result[currentSection] = {};
}
continue;
}
// Handle array items (items starting with -)
if (line.startsWith('-') && currentSection === 'items') {
if (currentItem) {
result.items.push(currentItem);
}
currentItem = {
id: `item-${itemIndex++}`,
tags: [],
links: []
};
// Parse the first property after the dash
const firstProp = line.slice(1).trim();
if (firstProp.includes(':')) {
const [key, value] = firstProp.split(':', 2);
currentItem[key.trim()] = parseValue(value.trim());
}
continue;
}
// Handle properties within items or config
if (line.includes(':')) {
const [key, value] = line.split(':', 2);
const cleanKey = key.trim().replace(/^- /, '');
const cleanValue = parseValue(value.trim());
if (currentSection === 'items' && currentItem) {
// Handle special array properties
if (cleanKey === 'tags' || cleanKey === 'dependencies') {
if (typeof cleanValue === 'string') {
currentItem[cleanKey] = cleanValue.split(',').map(s => s.trim()).filter(Boolean);
} else {
currentItem[cleanKey] = [];
}
} else {
currentItem[cleanKey] = cleanValue;
}
} else if (currentSection === 'config') {
result.config[cleanKey] = cleanValue;
}
}
}
// Don't forget the last item
if (currentItem && currentSection === 'items') {
result.items.push(currentItem);
}
return result;
}
/**
* Parse individual YAML values with type inference
*/
function parseValue(value: string): any {
if (!value) return '';
// Remove quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
// Parse numbers
if (/^\d+$/.test(value)) {
return parseInt(value, 10);
}
if (/^\d+\.\d+$/.test(value)) {
return parseFloat(value);
}
// Parse booleans
if (value === 'true') return true;
if (value === 'false') return false;
return value;
}
/**
* Validate and normalize roadmap data structure
*/
function validateRoadmapData(data: any): RoadmapData {
// Provide default config if missing
const defaultConfig: RoadmapConfig = {
title: data.config?.title || 'Roadmap',
description: data.config?.description || '',
columns: [
{ id: 'backlog', title: 'Backlog', order: 0, color: '#64748b' },
{ id: 'todo', title: 'To Do', order: 1, color: '#3b82f6' },
{ id: 'in-progress', title: 'In Progress', order: 2, color: '#f59e0b' },
{ id: 'review', title: 'Review', order: 3, color: '#8b5cf6' },
{ id: 'done', title: 'Done', order: 4, color: '#10b981' },
],
settings: {
showPriority: data.config?.showPriority ?? true,
showEffort: data.config?.showEffort ?? true,
showDueDates: data.config?.showDueDates ?? true,
showAssignees: data.config?.showAssignees ?? true,
theme: data.config?.theme || 'auto',
enableDragDrop: data.config?.enableDragDrop ?? true,
}
};
// Validate and normalize items
const items: RoadmapItem[] = (data.items || []).map((item: any, index: number) => {
return {
id: item.id || `item-${index}`,
title: item.title || `Untitled Item ${index + 1}`,
description: item.description || '',
status: isValidStatus(item.status) ? item.status : 'backlog',
priority: ['low', 'medium', 'high', 'critical'].includes(item.priority) ? item.priority : 'medium',
tags: Array.isArray(item.tags) ? item.tags : [],
effort: typeof item.effort === 'number' ? item.effort : undefined,
dueDate: item.dueDate || undefined,
assignee: item.assignee || undefined,
links: Array.isArray(item.links) ? item.links : [],
dependencies: Array.isArray(item.dependencies) ? item.dependencies : [],
metadata: item.metadata || {}
};
});
return {
config: { ...defaultConfig, ...data.config },
items
};
}
/**
* Check if a status value is valid
*/
function isValidStatus(status: any): status is RoadmapStatus {
return ['backlog', 'todo', 'in-progress', 'review', 'done', 'blocked'].includes(status);
}
/**
* Convert RoadmapData back to YAML format
* @param data Roadmap data to serialize
* @returns YAML string representation
*/
export function serializeRoadmapToYaml(data: RoadmapData): string {
const lines: string[] = [];
// Add config section
lines.push('config:');
lines.push(` title: "${data.config.title}"`);
lines.push(` description: "${data.config.description}"`);
lines.push(` showPriority: ${data.config.settings.showPriority}`);
lines.push(` showEffort: ${data.config.settings.showEffort}`);
lines.push(` showDueDates: ${data.config.settings.showDueDates}`);
lines.push(` showAssignees: ${data.config.settings.showAssignees}`);
lines.push(` theme: ${data.config.settings.theme}`);
lines.push(` enableDragDrop: ${data.config.settings.enableDragDrop}`);
lines.push('');
// Add items section
lines.push('items:');
data.items.forEach(item => {
lines.push(`- id: ${item.id}`);
lines.push(` title: "${item.title}"`);
lines.push(` description: "${item.description}"`);
lines.push(` status: ${item.status}`);
lines.push(` priority: ${item.priority}`);
if (item.tags.length > 0) {
lines.push(` tags: ${item.tags.join(', ')}`);
}
if (item.effort !== undefined) {
lines.push(` effort: ${item.effort}`);
}
if (item.dueDate) {
lines.push(` dueDate: ${item.dueDate}`);
}
if (item.assignee) {
lines.push(` assignee: "${item.assignee}"`);
}
if (item.dependencies && item.dependencies.length > 0) {
lines.push(` dependencies: ${item.dependencies.join(', ')}`);
}
lines.push('');
});
return lines.join('\n');
}
/**
* Load roadmap data from a file path
* @param filePath Path to the YAML file
* @returns Promise resolving to parsed roadmap data
*/
export async function loadRoadmapFromFile(filePath: string): Promise<RoadmapData> {
try {
// Use Node.js file system for compatibility with Next.js build
const fs = await import('fs/promises');
const content = await fs.readFile(filePath, 'utf-8');
return parseRoadmapYaml(content);
} catch (error) {
throw new Error(`Failed to load roadmap from ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}