|
| 1 | +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 2 | +import { join } from 'node:path' |
| 3 | +import fg from 'fast-glob' |
| 4 | + |
| 5 | +interface TutorialInfo { |
| 6 | + slug: string |
| 7 | + title: string |
| 8 | + dir: string |
| 9 | + lessons: LessonInfo[] |
| 10 | +} |
| 11 | + |
| 12 | +interface LessonInfo { |
| 13 | + number: number |
| 14 | + title: string |
| 15 | + contentPath: string |
| 16 | + solutionDir: string | null |
| 17 | +} |
| 18 | + |
| 19 | +async function scanTutorials(): Promise<TutorialInfo[]> { |
| 20 | + const tutorialDirs = await fg('src/content/tutorial/*-*/', { onlyDirectories: true }) |
| 21 | + |
| 22 | + const tutorials: TutorialInfo[] = [] |
| 23 | + |
| 24 | + for (const dir of tutorialDirs) { |
| 25 | + try { |
| 26 | + const metaPath = join(dir, 'meta.md') |
| 27 | + const metaContent = readFileSync(metaPath, 'utf-8') |
| 28 | + |
| 29 | + const titleMatch = metaContent.match(/title:\s*(.+)/) |
| 30 | + const title = titleMatch ? titleMatch[1].trim() : 'Untitled' |
| 31 | + |
| 32 | + const slug = dir.split('/').pop()!.replace(/^\d+-/, '') |
| 33 | + |
| 34 | + const lessonDirs = await fg(`${dir}/*-*/`, { onlyDirectories: true }) |
| 35 | + const lessons: LessonInfo[] = [] |
| 36 | + |
| 37 | + for (const lessonDir of lessonDirs) { |
| 38 | + const lessonName = lessonDir.split('/').pop()! |
| 39 | + const numberMatch = lessonName.match(/^(\d+)-/) |
| 40 | + const number = numberMatch ? Number.parseInt(numberMatch[1]) : 0 |
| 41 | + |
| 42 | + const contentPath = join(lessonDir, 'content.md') |
| 43 | + const solutionDir = join(lessonDir, '_solution') |
| 44 | + |
| 45 | + const lessonContent = readFileSync(contentPath, 'utf-8') |
| 46 | + const lessonTitleMatch = lessonContent.match(/title:\s*(.+)/) |
| 47 | + const lessonTitle = lessonTitleMatch ? lessonTitleMatch[1].trim() : lessonName |
| 48 | + |
| 49 | + lessons.push({ |
| 50 | + number, |
| 51 | + title: lessonTitle, |
| 52 | + contentPath, |
| 53 | + solutionDir: await fg(`${solutionDir}/*`).then(files => files.length > 0 ? solutionDir : null), |
| 54 | + }) |
| 55 | + } |
| 56 | + |
| 57 | + lessons.sort((a, b) => a.number - b.number) |
| 58 | + |
| 59 | + tutorials.push({ slug, title, dir, lessons }) |
| 60 | + } |
| 61 | + catch (error) { |
| 62 | + console.warn(`[llms-plugin] ⚠️ Skipping ${dir}: ${error}`) |
| 63 | + continue |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + tutorials.sort((a, b) => a.dir.localeCompare(b.dir)) |
| 68 | + |
| 69 | + return tutorials |
| 70 | +} |
| 71 | + |
| 72 | +async function generateTutorialFile(tutorial: TutorialInfo): Promise<string> { |
| 73 | + let content = `# ${tutorial.title}\n\n` |
| 74 | + |
| 75 | + for (const lesson of tutorial.lessons) { |
| 76 | + content += `## Lesson ${lesson.number}: ${lesson.title}\n\n` |
| 77 | + |
| 78 | + const lessonContent = readFileSync(lesson.contentPath, 'utf-8') |
| 79 | + const contentWithoutFrontmatter = lessonContent.replace(/^---\n[\s\S]*?\n---\n/, '') |
| 80 | + content += `${contentWithoutFrontmatter}\n\n` |
| 81 | + |
| 82 | + if (lesson.solutionDir) { |
| 83 | + content += `### Solution Code\n\n` |
| 84 | + |
| 85 | + const solutionFiles = (await fg(`${lesson.solutionDir}/*`)).sort() |
| 86 | + for (const file of solutionFiles) { |
| 87 | + const fileName = file.split('/').pop()! |
| 88 | + const fileContent = readFileSync(file, 'utf-8') |
| 89 | + const ext = fileName.split('.').pop() |
| 90 | + |
| 91 | + content += `#### ${fileName}\n\`\`\`${ext}\n${fileContent}\n\`\`\`\n\n` |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + content += `---\n\n` |
| 96 | + } |
| 97 | + |
| 98 | + return content |
| 99 | +} |
| 100 | + |
| 101 | +function generateRootIndex(tutorials: TutorialInfo[]): string { |
| 102 | + let content = `# Nimiq Tutorials\n\n` |
| 103 | + content += `Learn to build on Nimiq blockchain through interactive tutorials.\n\n` |
| 104 | + content += `## Available Tutorials\n\n` |
| 105 | + |
| 106 | + for (const tutorial of tutorials) { |
| 107 | + content += `- [${tutorial.title}](/tutorial/${tutorial.slug}.txt): ${tutorial.title}\n` |
| 108 | + } |
| 109 | + |
| 110 | + content += `\n## Complete Documentation\n\n` |
| 111 | + content += `- [All Tutorials](/llms-full.txt): Full combined documentation\n` |
| 112 | + |
| 113 | + return content |
| 114 | +} |
| 115 | + |
| 116 | +async function generateAllFiles(outputDir = 'dist') { |
| 117 | + try { |
| 118 | + const tutorials = await scanTutorials() |
| 119 | + const tutorialDir = join(outputDir, 'tutorial') |
| 120 | + mkdirSync(tutorialDir, { recursive: true }) |
| 121 | + |
| 122 | + // Cache content to avoid regenerating for llms-full.txt |
| 123 | + const tutorialContents: string[] = [] |
| 124 | + for (const tutorial of tutorials) { |
| 125 | + const content = await generateTutorialFile(tutorial) |
| 126 | + tutorialContents.push(content) |
| 127 | + const outputPath = join(tutorialDir, `${tutorial.slug}.txt`) |
| 128 | + writeFileSync(outputPath, content, 'utf-8') |
| 129 | + } |
| 130 | + |
| 131 | + const rootIndex = generateRootIndex(tutorials) |
| 132 | + writeFileSync(join(outputDir, 'llms.txt'), rootIndex, 'utf-8') |
| 133 | + |
| 134 | + const fullContent = tutorialContents.join(`\n\n${'='.repeat(80)}\n\n`) |
| 135 | + writeFileSync(join(outputDir, 'llms-full.txt'), fullContent, 'utf-8') |
| 136 | + |
| 137 | + return tutorials.length |
| 138 | + } |
| 139 | + catch (error) { |
| 140 | + console.error('[llms-plugin] ❌ Error generating files:', error) |
| 141 | + throw error |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +export function llmsPlugin() { |
| 146 | + let config: any |
| 147 | + |
| 148 | + return { |
| 149 | + name: 'llms-plugin', |
| 150 | + |
| 151 | + configResolved(resolvedConfig: any) { |
| 152 | + config = resolvedConfig |
| 153 | + }, |
| 154 | + |
| 155 | + async buildEnd() { |
| 156 | + console.log('[llms-plugin] Generating llms.txt files...') |
| 157 | + const outputDir = config.build?.outDir || 'dist' |
| 158 | + const count = await generateAllFiles(outputDir) |
| 159 | + console.log(`[llms-plugin] ✅ Generated ${count} tutorial files`) |
| 160 | + }, |
| 161 | + |
| 162 | + configureServer(server: any) { |
| 163 | + server.watcher.on('change', async (path: string) => { |
| 164 | + if (path.includes('src/content/tutorial') && (path.endsWith('.md') || path.includes('/_solution/'))) { |
| 165 | + console.log('[llms-plugin] Tutorial content changed, regenerating...') |
| 166 | + await generateAllFiles('dist') |
| 167 | + console.log('[llms-plugin] ✅ Regenerated llms.txt files') |
| 168 | + } |
| 169 | + }) |
| 170 | + }, |
| 171 | + } |
| 172 | +} |
0 commit comments