oneie
Version:
🤝 ONE Personal Collaborative Intelligence - Creates personalized AI workspace from your me.md profile. Simple: npx oneie → edit me.md → generate personalized agents, workflows & missions. From students to enterprises, ONE adapts to your context.
748 lines (647 loc) • 26 kB
JavaScript
/**
* Template Processor for Apple-Style Book Generation
* Handles hierarchical book structure detection, image mapping, and content processing
* Supports 5-part structure: Foundation → ATTRACT → CONVERT → GROW → REFINE + Appendix
*/
import { promises as fs, accessSync } from 'fs';
import path from 'path';
/**
* Strip YAML frontmatter from markdown content
* @param {string} content - Markdown content with potential frontmatter
* @returns {string} Content without frontmatter
*/
function stripYamlFrontmatter(content) {
// Remove YAML frontmatter (--- ... --- at start of file)
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
content = content.replace(frontmatterRegex, '');
// Also remove any remaining --- ... --- blocks in the content
const inlineYamlRegex = /\n---\s*\n([\s\S]*?)\n---?\s*\n/g;
content = content.replace(inlineYamlRegex, '\n\n');
return content.trim();
}
/**
* Detect hierarchical book structure from content directory
* Supports folder-based structure: 0-introduction/, 1-foundation/, 2-attract/, etc.
* @param {string} contentDir - Path to content directory
* @param {string} imagesDir - Path to images directory
* @returns {Object} Detected hierarchical book structure
*/
async function detectHierarchicalBookStructure(contentDir, imagesDir) {
const structure = {
introduction: { files: [], title: 'Introduction' },
parts: [
{ number: 1, title: 'Foundation', chapters: [], folder: '' },
{ number: 2, title: 'ATTRACT', chapters: [], folder: '' },
{ number: 3, title: 'CONVERT', chapters: [], folder: '' },
{ number: 4, title: 'GROW', chapters: [], folder: '' },
{ number: 5, title: 'REFINE', chapters: [], folder: '' }
],
appendix: { files: [], title: 'Appendix' },
images: {}
};
try {
const entries = await fs.readdir(contentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const folderName = entry.name;
const folderPath = path.join(contentDir, folderName);
if (folderName.match(/^0-introduction/)) {
// Introduction folder
const introFiles = await scanFolderForFiles(folderPath, imagesDir);
structure.introduction.files = introFiles;
} else if (folderName.match(/^[1-5]-/)) {
// Part folders (1-foundation, 2-attract, etc.)
const partMatch = folderName.match(/^(\d+)-(.+)$/);
if (partMatch) {
const [, partNum, partName] = partMatch;
const partIndex = parseInt(partNum) - 1;
if (partIndex >= 0 && partIndex < structure.parts.length) {
const partFiles = await scanFolderForFiles(folderPath, imagesDir);
structure.parts[partIndex].title = partName.toUpperCase();
structure.parts[partIndex].folder = folderName;
structure.parts[partIndex].chapters = await parseHierarchicalFiles(partFiles, folderName);
}
}
} else if (folderName === 'appendix') {
// Appendix folder
const appendixFiles = await scanFolderForFiles(folderPath, imagesDir);
structure.appendix.files = appendixFiles;
}
}
}
// Sort all parts and their chapters
structure.parts.forEach(part => {
part.chapters.sort((a, b) => {
// Sort by hierarchical numbering
const aSort = `${a.chapterNumber || 0}.${a.sectionNumber || 0}.${a.subSectionNumber || 0}`;
const bSort = `${b.chapterNumber || 0}.${b.sectionNumber || 0}.${b.subSectionNumber || 0}`;
return aSort.localeCompare(bSort, undefined, { numeric: true });
});
// Sort sections within each chapter
part.chapters.forEach(chapter => {
if (chapter.sections) {
chapter.sections.sort((a, b) => {
const aSort = `${a.sectionNumber || 0}.${a.subSectionNumber || 0}`;
const bSort = `${b.sectionNumber || 0}.${b.subSectionNumber || 0}`;
return aSort.localeCompare(bSort, undefined, { numeric: true });
});
}
});
});
// Sort introduction and appendix files
structure.introduction.files.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
structure.appendix.files.sort((a, b) => a.number.localeCompare(b.number));
return structure;
} catch (error) {
console.error('Error detecting hierarchical book structure:', error);
throw error;
}
}
/**
* Scan a folder for markdown files and return file information
* @param {string} folderPath - Path to folder
* @param {string} imagesDir - Images directory path
* @returns {Array} Array of file information objects
*/
async function scanFolderForFiles(folderPath, imagesDir) {
const files = [];
try {
const entries = await fs.readdir(folderPath);
for (const entry of entries) {
if (entry.endsWith('.md')) {
const filePath = path.join(folderPath, entry);
const fileInfo = await parseHierarchicalFile(entry, folderPath, imagesDir);
if (fileInfo) {
files.push(fileInfo);
}
}
}
} catch (error) {
console.error(`Error scanning folder ${folderPath}:`, error);
}
return files;
}
/**
* Parse hierarchical files within a folder
* @param {Array} files - Array of file information objects
* @param {string} folderName - Name of the folder
* @returns {Array} Array of parsed chapter/section objects
*/
async function parseHierarchicalFiles(files, folderName) {
const chapters = [];
for (const file of files) {
const fileName = file.file;
// Parse files like: 1-foundation.md, 1.1-company.md, 1.1.2-tokenomics.md
const match = fileName.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?-(.+)\.md$/);
if (match) {
const [, chapterNum, sectionNum, subSectionNum, name] = match;
const chapterInfo = {
file: fileName,
folder: folderName,
chapterNumber: parseInt(chapterNum),
sectionNumber: sectionNum ? parseInt(sectionNum) : null,
subSectionNumber: subSectionNum ? parseInt(subSectionNum) : null,
name: name,
title: formatTitle(name),
number: fileName.match(/^(\d+(?:\.\d+)*)/)[1],
level: sectionNum ? (subSectionNum ? 3 : 2) : 1,
sections: [],
fullPageImage: false,
imageName: null
};
// Check for associated image
await checkForImage(chapterInfo, file.imagesDir);
chapters.push(chapterInfo);
}
}
return chapters;
}
/**
* Parse hierarchical file information
* @param {string} file - Filename
* @param {string} folderPath - Path to folder containing the file
* @param {string} imagesDir - Images directory path
* @returns {Object|null} Parsed file information
*/
async function parseHierarchicalFile(file, folderPath, imagesDir) {
const fileInfo = {
file,
folderPath,
imagesDir,
fullPageImage: false,
imageName: null
};
return fileInfo;
}
/**
* Format title from filename
* @param {string} name - Name from filename
* @returns {string} Formatted title
*/
function formatTitle(name) {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
/**
* Check for associated image for a chapter/section
* @param {Object} chapterInfo - Chapter information object
* @param {string} imagesDir - Images directory path
*/
async function checkForImage(chapterInfo, imagesDir) {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
// Try different image naming patterns
const patterns = [
`${chapterInfo.number}-${chapterInfo.name}`, // e.g., 1.1-company
`${chapterInfo.title.replace(/\s+/g, '')}`, // e.g., Company
`${chapterInfo.name}` // e.g., company
];
for (const pattern of patterns) {
for (const ext of imageExtensions) {
const imageName = `${pattern}.${ext}`;
const imagePath = path.join(imagesDir, imageName);
try {
await fs.access(imagePath);
chapterInfo.fullPageImage = true;
chapterInfo.imageName = imageName;
return;
} catch (err) {
// Image doesn't exist, continue checking
}
}
}
}
/**
* Add file to hierarchical structure
* @param {Object} structure - Book structure
* @param {Object} fileInfo - File information
*/
function addToHierarchicalStructure(structure, fileInfo) {
const numberParts = fileInfo.number.split('.');
if (fileInfo.number.startsWith('0.')) {
// Introduction files
structure.introduction.files.push(fileInfo);
} else if (fileInfo.number.startsWith('A.')) {
// Appendix files
structure.appendix.files.push(fileInfo);
} else if (numberParts.length >= 2) {
// Part files (1.x, 2.x, etc.)
const partNum = parseInt(numberParts[0]);
const part = structure.parts.find(p => p.number === partNum);
if (part) {
if (numberParts.length === 2) {
// Chapter level (e.g., 2.1-attract.md = Part 2, Chapter 1)
const chapterNum = parseInt(numberParts[1]);
const chapter = {
...fileInfo,
chapterNumber: chapterNum,
partNumber: partNum,
sections: [],
isChapter: true
};
part.chapters.push(chapter);
} else {
// Section level (e.g., 2.1.1-attract-introduction.md = Part 2, Chapter 1, Section 1)
const chapterNum = parseInt(numberParts[1]);
const chapterKey = `${partNum}.${chapterNum}`;
let parentChapter = part.chapters.find(c => c.chapterNumber === chapterNum);
if (!parentChapter) {
// Create placeholder chapter if it doesn't exist
parentChapter = {
number: chapterKey,
chapterNumber: chapterNum,
partNumber: partNum,
name: 'placeholder',
title: 'Placeholder Chapter',
file: '',
sections: [],
isChapter: true,
fullPageImage: false
};
part.chapters.push(parentChapter);
}
const sectionNum = parseInt(numberParts[2]);
parentChapter.sections.push({
...fileInfo,
sectionNumber: sectionNum,
chapterNumber: chapterNum,
partNumber: partNum,
isSection: true
});
}
}
}
}
/**
* Legacy function for backward compatibility
* @param {string} contentDir - Path to content directory
* @param {string} imagesDir - Path to images directory
* @returns {Object} Detected book structure
*/
async function detectChapterStructure(contentDir, imagesDir) {
// Try hierarchical structure first
const hierarchicalStructure = await detectHierarchicalBookStructure(contentDir, imagesDir);
// Convert to legacy format for backward compatibility
const legacyStructure = {
chapters: [],
images: hierarchicalStructure.images,
sections: {}
};
// Flatten hierarchical structure to legacy format
hierarchicalStructure.parts.forEach(part => {
part.chapters.forEach(chapter => {
if (chapter.isChapter) {
legacyStructure.chapters.push({
file: chapter.file,
number: parseFloat(chapter.number),
name: chapter.name,
title: chapter.title,
titlePage: true,
fullPageImage: chapter.fullPageImage,
sections: chapter.sections.map(section => ({
file: section.file,
chapterNumber: parseInt(chapter.number.split('.')[0]),
sectionNumber: parseInt(section.number.split('.').slice(-1)[0]),
name: section.name,
title: section.title,
fullPath: section.number
}))
});
// Add sections to legacy sections object
chapter.sections.forEach(section => {
legacyStructure.sections[section.file] = {
file: section.file,
chapterNumber: parseInt(chapter.number.split('.')[0]),
sectionNumber: parseInt(section.number.split('.').slice(-1)[0]),
name: section.name,
title: section.title,
fullPath: section.number
};
});
}
});
});
return legacyStructure;
}
/**
* Process a hierarchical file (part, chapter, or section) with appropriate formatting
* @param {Object} item - File item (part, chapter, or section)
* @param {string} content - File content
* @param {string} imagesDir - Images directory path
* @param {string} type - Type of item ('part', 'chapter', 'section', 'introduction', 'appendix')
* @returns {string} Processed content
*/
function processHierarchicalItem(item, content, imagesDir, type = 'chapter') {
let processed = '';
if (type === 'part') {
// Add part title page
processed += `\\newpart{${item.number}}{${item.title}}\n\n`;
} else if (type === 'chapter') {
// Add chapter title page marker with simple chapter number
const chapterNum = item.chapterNumber || item.number;
processed += `\\newchapter{${chapterNum}}{${item.title}}\n\n`;
} else if (type === 'section') {
// Add section header with simple section number
const sectionNum = item.sectionNumber || item.number;
processed += `\\newsection{${sectionNum}}{${item.title}}\n\n`;
}
// Add full-page image only if it exists
if (item.fullPageImage && item.imageName) {
const imagePath = path.join(imagesDir, item.imageName);
// Verify the image file exists before including it
try {
accessSync(imagePath);
processed += `\n\n`;
} catch (err) {
console.warn(`Image not found: ${imagePath} for ${type} ${item.number}`);
// Don't include image if it doesn't exist
}
}
// Process content for image references
const processedContent = processImageReferences(content, item.file, imagesDir);
processed += processedContent;
return processed;
}
/**
* Legacy function for backward compatibility
* @param {Object} chapter - Chapter object
* @param {string} content - Chapter content
* @param {string} imagesDir - Images directory path
* @returns {string} Processed content
*/
function processChapter(chapter, content, imagesDir) {
return processHierarchicalItem(chapter, content, imagesDir, 'chapter');
}
/**
* Process image references in markdown content
* @param {string} content - Markdown content
* @param {string} currentFile - Current file being processed
* @param {string} imagesDir - Images directory path
* @returns {string} Content with resolved image paths
*/
function processImageReferences(content, currentFile, imagesDir) {
// First, handle Unicode characters that LaTeX doesn't handle well
content = content.replace(/✓/g, '[X]');
content = content.replace(/✗/g, '[ ]');
content = content.replace(/→/g, '->');
content = content.replace(/←/g, '<-');
content = content.replace(/↑/g, '^');
content = content.replace(/↓/g, 'v');
content = content.replace(/•/g, '*');
content = content.replace(/…/g, '...');
// Then process image references
return content.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(match, alt, src) => {
// If already absolute path or URL, leave as is
if (src.startsWith('/') || src.startsWith('http')) {
return match;
}
// Handle images/ path references by converting to images/
let imagePath = src;
if (src.startsWith('images/')) {
imagePath = src.replace('images/', '');
}
// Resolve relative path to images directory
const fullPath = path.join(imagesDir, imagePath);
// Check if file exists
try {
// Use synchronous access for now since we're in a replace callback
accessSync(fullPath);
return ``;
} catch (err) {
console.warn(`Image not found: ${src} in ${currentFile}`);
// Return empty string or skip image if not found to prevent broken images
return '';
}
}
);
}
/**
* Generate consolidated markdown for hierarchical book structure
* @param {Object} structure - Hierarchical book structure
* @param {string} contentDir - Content directory path
* @param {string} imagesDir - Images directory path
* @returns {string} Consolidated markdown content
*/
async function generateHierarchicalMarkdown(structure, contentDir, imagesDir) {
let consolidated = '';
// Process Introduction (just content, no chapter/part structure)
if (structure.introduction.files.length > 0) {
for (const file of structure.introduction.files) {
if (file.file) {
const filePath = path.join(file.folderPath, file.file);
let content = await fs.readFile(filePath, 'utf8');
content = stripYamlFrontmatter(content);
// Convert markdown headers to proper LaTeX sections
const lines = content.split('\n');
const processedLines = [];
for (const line of lines) {
if (line.startsWith('# ')) {
processedLines.push(`\\section*{${line.substring(2)}}`);
} else if (line.startsWith('## ')) {
processedLines.push(`\\subsection*{${line.substring(3)}}`);
} else if (line.startsWith('### ')) {
processedLines.push(`\\subsubsection*{${line.substring(4)}}`);
} else {
processedLines.push(line);
}
}
content = processedLines.join('\n');
// Remove CSS references from frontmatter to avoid conflicts
content = content.replace(/^css:\s*.*$/gm, '');
// Add full-page image if exists
if (file.fullPageImage && file.imageName) {
const imagePath = path.join(imagesDir, file.imageName);
try {
accessSync(imagePath);
consolidated += `\n\n`;
} catch (err) {
// Image doesn't exist, skip
}
}
// Process image references in introduction content
const processedContent = processImageReferences(content, file.file, imagesDir);
consolidated += processedContent + '\n\n';
}
}
}
// Process Parts
for (const part of structure.parts) {
if (part.chapters.length > 0) {
consolidated += `# ${part.title}\n\n`;
for (const chapter of part.chapters) {
if (chapter.file) {
const chapterPath = path.join(contentDir, part.folder, chapter.file);
let chapterContent = await fs.readFile(chapterPath, 'utf8');
chapterContent = stripYamlFrontmatter(chapterContent);
// Remove markdown headers since we're using LaTeX structure
chapterContent = chapterContent.replace(/^#+\s+/gm, '');
// Remove CSS references from frontmatter to avoid conflicts
chapterContent = chapterContent.replace(/^css:\s*.*$/gm, '');
const chapterNum = chapter.chapterNumber || 1;
consolidated += `# ${chapter.title}\n\n`;
// Note: Images are now handled by the content files themselves
// Removed automatic image insertion to prevent duplicates
// Process image references in chapter content
const processedChapterContent = processImageReferences(chapterContent, chapter.file, imagesDir);
consolidated += processedChapterContent + '\n\n';
}
// Add sections
for (const section of chapter.sections) {
if (section.file) {
const sectionPath = path.join(contentDir, part.folder, section.file);
let sectionContent = await fs.readFile(sectionPath, 'utf8');
sectionContent = stripYamlFrontmatter(sectionContent);
// Remove markdown headers since we're using LaTeX structure
sectionContent = sectionContent.replace(/^#+\s+/gm, '');
const sectionNum = section.sectionNumber || 1;
consolidated += `\\section{${section.title}}\n\n`;
// Process image references in section content
const processedSectionContent = processImageReferences(sectionContent, section.file, imagesDir);
consolidated += processedSectionContent + '\n\n';
}
}
}
}
}
// Process Appendix
if (structure.appendix.files.length > 0) {
consolidated += '# Appendix\n\n';
for (const file of structure.appendix.files) {
if (file.file) {
const filePath = path.join(file.folderPath, file.file);
let content = await fs.readFile(filePath, 'utf8');
content = stripYamlFrontmatter(content);
// Remove markdown headers since we're using LaTeX structure
content = content.replace(/^#+\s+/gm, '');
consolidated += `# ${file.title}\n\n`;
// Process image references in appendix content
const processedContent = processImageReferences(content, file.file, imagesDir);
consolidated += processedContent + '\n\n';
}
}
}
return consolidated;
}
/**
* Legacy function for backward compatibility
* @param {Object} structure - Book structure
* @param {string} contentDir - Content directory path
* @param {string} imagesDir - Images directory path
* @returns {string} Consolidated markdown content
*/
async function generateConsolidatedMarkdown(structure, contentDir, imagesDir) {
// Check if this is a hierarchical structure
if (structure.parts && structure.introduction && structure.appendix) {
return generateHierarchicalMarkdown(structure, contentDir, imagesDir);
}
// Legacy processing for old structure
let consolidated = '';
for (const chapter of structure.chapters) {
// Read chapter content
const chapterPath = path.join(contentDir, chapter.file);
let chapterContent = await fs.readFile(chapterPath, 'utf8');
chapterContent = stripYamlFrontmatter(chapterContent);
// Process chapter with title page and image
const processedChapter = processChapter(chapter, chapterContent, imagesDir);
consolidated += processedChapter + '\n\n';
// Add sections
for (const section of chapter.sections) {
const sectionPath = path.join(contentDir, section.file);
let sectionContent = await fs.readFile(sectionPath, 'utf8');
sectionContent = stripYamlFrontmatter(sectionContent);
// Process section content
const processedSection = processImageReferences(sectionContent, section.file, imagesDir);
consolidated += processedSection + '\n\n';
}
}
return consolidated;
}
/**
* Create hierarchical table of contents from book structure
* @param {Object} structure - Hierarchical book structure
* @returns {string} Table of contents markdown
*/
function generateHierarchicalTableOfContents(structure) {
let toc = '# Table of Contents\n\n';
// Introduction
if (structure.introduction.files.length > 0) {
toc += '## Introduction\n\n';
for (const file of structure.introduction.files) {
// Extract title from filename or use formatted title
const match = file.file.match(/^(\d+)-(.+)\.md$/);
const title = match ? formatTitle(match[2]) : file.title || 'Introduction';
toc += `- [${title}](#introduction)\n`;
}
toc += '\n';
}
// Parts
for (const part of structure.parts) {
if (part.chapters.length > 0) {
toc += `## Part ${part.number}: ${part.title}\n\n`;
for (const chapter of part.chapters) {
if (chapter.file) {
const chapterNum = chapter.number || chapter.chapterNumber;
toc += `### ${chapterNum}: ${chapter.title}\n\n`;
}
// Group sections by level
const level2Sections = chapter.sections?.filter(s => s.level === 2) || [];
const level3Sections = chapter.sections?.filter(s => s.level === 3) || [];
for (const section of level2Sections) {
const sectionNum = section.number || section.sectionNumber;
toc += `- [${sectionNum} ${section.title}](#${section.number.replace(/\./g, '-')})\n`;
}
for (const section of level3Sections) {
const sectionNum = section.number || section.sectionNumber;
toc += ` - [${sectionNum} ${section.title}](#${section.number.replace(/\./g, '-')})\n`;
}
if (chapter.sections && chapter.sections.length > 0) {
toc += '\n';
}
}
}
}
// Appendix
if (structure.appendix.files.length > 0) {
toc += '## Appendix\n\n';
for (const file of structure.appendix.files) {
const match = file.file.match(/^A\.(\d+)-(.+)\.md$/);
const title = match ? formatTitle(match[2]) : file.title || 'Appendix';
toc += `- [${title}](#appendix)\n`;
}
toc += '\n';
}
return toc;
}
/**
* Legacy function for backward compatibility
* @param {Object} structure - Book structure
* @returns {string} Table of contents markdown
*/
function generateTableOfContents(structure) {
// Check if this is a hierarchical structure
if (structure.parts && structure.introduction && structure.appendix) {
return generateHierarchicalTableOfContents(structure);
}
// Legacy TOC generation
let toc = '# Table of Contents\n\n';
for (const chapter of structure.chapters) {
toc += `## Chapter ${chapter.number}: ${chapter.title}\n\n`;
for (const section of chapter.sections) {
toc += `### ${section.fullPath} ${section.title}\n\n`;
}
}
return toc;
}
export {
detectHierarchicalBookStructure,
detectChapterStructure,
processHierarchicalItem,
processChapter,
processImageReferences,
generateHierarchicalMarkdown,
generateConsolidatedMarkdown,
generateHierarchicalTableOfContents,
generateTableOfContents
};