|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Copy the course content subset required by the CSE content engine. |
| 4 | + * |
| 5 | + * Usage: |
| 6 | + * npm run copy:content-engine |
| 7 | + */ |
| 8 | + |
| 9 | +const { |
| 10 | + cpSync, |
| 11 | + existsSync, |
| 12 | + mkdirSync, |
| 13 | + readdirSync, |
| 14 | + readFileSync, |
| 15 | + statSync, |
| 16 | + writeFileSync, |
| 17 | +} = require('fs'); |
| 18 | +const { dirname, join, relative, resolve } = require('path'); |
| 19 | + |
| 20 | +const sourceRoot = process.cwd(); |
| 21 | +const destinationRoot = '/Users/danwahlin/Desktop/projects/cse-content-engine/content/learning-pathways/copilot-cli-for-beginners'; |
| 22 | +const destinationParent = dirname(destinationRoot); |
| 23 | +const contentEngineSchema = { |
| 24 | + $schema: 'http://json-schema.org/draft-07/schema#', |
| 25 | + type: 'object', |
| 26 | + properties: { |
| 27 | + aliases: { |
| 28 | + type: 'array', |
| 29 | + description: 'Relative paths to redirect to this item', |
| 30 | + items: { |
| 31 | + type: 'string', |
| 32 | + description: 'A relative path to redirect to this item', |
| 33 | + }, |
| 34 | + }, |
| 35 | + audience: { |
| 36 | + type: 'string', |
| 37 | + description: 'The intended audience for the guide', |
| 38 | + }, |
| 39 | + description: { |
| 40 | + type: 'string', |
| 41 | + description: 'A brief description of the item', |
| 42 | + }, |
| 43 | + icon: { |
| 44 | + type: 'string', |
| 45 | + description: 'An icon to represent the item', |
| 46 | + }, |
| 47 | + id: { |
| 48 | + type: 'string', |
| 49 | + description: 'A unique identifier for the guide', |
| 50 | + }, |
| 51 | + params: { |
| 52 | + type: 'object', |
| 53 | + description: "Flexible parameters that don't affect presentation", |
| 54 | + }, |
| 55 | + slug: { |
| 56 | + type: 'string', |
| 57 | + description: 'A kebab-case identifier', |
| 58 | + }, |
| 59 | + title: { |
| 60 | + type: 'string', |
| 61 | + description: 'The display name of the item', |
| 62 | + }, |
| 63 | + weight: { |
| 64 | + type: 'integer', |
| 65 | + description: 'The order to display the item in', |
| 66 | + }, |
| 67 | + }, |
| 68 | + required: ['title', 'description', 'weight'], |
| 69 | + additionalProperties: true, |
| 70 | +}; |
| 71 | + |
| 72 | +function log(message) { |
| 73 | + console.log(` ${message}`); |
| 74 | +} |
| 75 | + |
| 76 | +function fail(message) { |
| 77 | + console.error(`\nError: ${message}`); |
| 78 | + process.exit(1); |
| 79 | +} |
| 80 | + |
| 81 | +function ensureSafeDestination() { |
| 82 | + if (!existsSync(destinationParent)) { |
| 83 | + fail(`Destination parent does not exist: ${destinationParent}`); |
| 84 | + } |
| 85 | + |
| 86 | + const resolvedSource = resolve(sourceRoot); |
| 87 | + const resolvedDestination = resolve(destinationRoot); |
| 88 | + |
| 89 | + if (resolvedSource === resolvedDestination) { |
| 90 | + fail('Destination cannot be the source repository root.'); |
| 91 | + } |
| 92 | + |
| 93 | + if (resolvedDestination.startsWith(`${resolvedSource}/`)) { |
| 94 | + fail('Destination cannot be inside the source repository.'); |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +function copyFile(sourcePath, destinationPath) { |
| 99 | + mkdirSync(dirname(destinationPath), { recursive: true }); |
| 100 | + |
| 101 | + if (sourcePath.endsWith('.md')) { |
| 102 | + writeFileSync(destinationPath, prepareMarkdownForContentEngine(sourcePath), 'utf8'); |
| 103 | + } else { |
| 104 | + cpSync(sourcePath, destinationPath); |
| 105 | + } |
| 106 | + |
| 107 | + log(`Copied ${relative(sourceRoot, sourcePath)} -> ${relative(destinationRoot, destinationPath)}`); |
| 108 | +} |
| 109 | + |
| 110 | +function prepareMarkdownForContentEngine(sourcePath) { |
| 111 | + const markdown = readFileSync(sourcePath, 'utf8'); |
| 112 | + const frontmatter = getMarkdownFrontmatter(markdown); |
| 113 | + |
| 114 | + if (!frontmatter) { |
| 115 | + return markdown; |
| 116 | + } |
| 117 | + |
| 118 | + return markdown.replace(/^<!--\r?\n---\r?\n[\s\S]*?\r?\n---\r?\n-->\r?\n*/, `---\n${frontmatter}\n---\n\n`); |
| 119 | +} |
| 120 | + |
| 121 | +function getMarkdownFrontmatter(markdown) { |
| 122 | + const hiddenFrontmatter = markdown.match(/^<!--\r?\n---\r?\n([\s\S]*?)\r?\n---\r?\n-->/)?.[1]; |
| 123 | + const visibleFrontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]; |
| 124 | + |
| 125 | + return (hiddenFrontmatter ?? visibleFrontmatter)?.replace(/\r\n/g, '\n'); |
| 126 | +} |
| 127 | + |
| 128 | +function getFrontmatterField(frontmatter, field) { |
| 129 | + return frontmatter.match(new RegExp(`^${field}:.*$`, 'm'))?.[0]; |
| 130 | +} |
| 131 | + |
| 132 | +function writeIndexFromReadme(sourceReadmePath, destinationDirectory, extraFields = []) { |
| 133 | + const markdown = readFileSync(sourceReadmePath, 'utf8'); |
| 134 | + const frontmatter = getMarkdownFrontmatter(markdown); |
| 135 | + |
| 136 | + if (!frontmatter) { |
| 137 | + fail(`Cannot create index.yml because ${relative(sourceRoot, sourceReadmePath)} has no frontmatter.`); |
| 138 | + } |
| 139 | + |
| 140 | + const indexFields = ['title', 'description', 'slug', 'weight', 'icon'] |
| 141 | + .map((field) => getFrontmatterField(frontmatter, field)) |
| 142 | + .filter(Boolean); |
| 143 | + indexFields.push(...extraFields); |
| 144 | + |
| 145 | + if (indexFields.length === 0) { |
| 146 | + fail(`Cannot create index.yml because ${relative(sourceRoot, sourceReadmePath)} has no index metadata.`); |
| 147 | + } |
| 148 | + |
| 149 | + mkdirSync(destinationDirectory, { recursive: true }); |
| 150 | + writeFileSync(join(destinationDirectory, 'index.yml'), `${indexFields.join('\n')}\n`, 'utf8'); |
| 151 | + log(`Generated ${relative(destinationRoot, join(destinationDirectory, 'index.yml'))}`); |
| 152 | +} |
| 153 | + |
| 154 | +function writeContentEngineSchema() { |
| 155 | + const destinationPath = join(destinationRoot, 'schema.json'); |
| 156 | + writeFileSync(destinationPath, `${JSON.stringify(contentEngineSchema, null, 2)}\n`, 'utf8'); |
| 157 | + log(`Generated ${relative(destinationRoot, destinationPath)}`); |
| 158 | +} |
| 159 | + |
| 160 | +function copyDirectory(sourcePath, destinationPath) { |
| 161 | + if (!existsSync(sourcePath)) { |
| 162 | + fail(`Required directory does not exist: ${relative(sourceRoot, sourcePath)}`); |
| 163 | + } |
| 164 | + |
| 165 | + cpSync(sourcePath, destinationPath, { recursive: true }); |
| 166 | + log(`Copied ${relative(sourceRoot, sourcePath)}/ -> ${relative(destinationRoot, destinationPath)}/`); |
| 167 | +} |
| 168 | + |
| 169 | +function getChapterFolders() { |
| 170 | + return readdirSync(sourceRoot) |
| 171 | + .filter((entry) => /^0[0-7]-/.test(entry)) |
| 172 | + .filter((entry) => statSync(join(sourceRoot, entry)).isDirectory()) |
| 173 | + .sort(); |
| 174 | +} |
| 175 | + |
| 176 | +function stripFragmentAndQuery(target) { |
| 177 | + return target.split('#')[0].split('?')[0]; |
| 178 | +} |
| 179 | + |
| 180 | +function isExternalLink(target) { |
| 181 | + return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//') || target.startsWith('#'); |
| 182 | +} |
| 183 | + |
| 184 | +function getChapterLocalMarkdownLinks(chapterPath) { |
| 185 | + const readmePath = join(chapterPath, 'README.md'); |
| 186 | + const readme = readFileSync(readmePath, 'utf8'); |
| 187 | + const links = new Set(); |
| 188 | + const patterns = [ |
| 189 | + /\[[^\]]+\]\(([^)\s]+\.md(?:#[^)]+)?)(?:\s+"[^"]*")?\)/gi, |
| 190 | + /<a\b[^>]*\bhref=["']([^"']+\.md(?:#[^"']+)?)["']/gi, |
| 191 | + ]; |
| 192 | + |
| 193 | + for (const pattern of patterns) { |
| 194 | + for (const match of readme.matchAll(pattern)) { |
| 195 | + const target = stripFragmentAndQuery(match[1]); |
| 196 | + if (!target || isExternalLink(target)) { |
| 197 | + continue; |
| 198 | + } |
| 199 | + |
| 200 | + const resolvedTarget = resolve(chapterPath, target); |
| 201 | + if (dirname(resolvedTarget) === resolve(chapterPath) && resolvedTarget !== resolve(readmePath)) { |
| 202 | + links.add(resolvedTarget); |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + return [...links].sort(); |
| 208 | +} |
| 209 | + |
| 210 | +function copyAppendices() { |
| 211 | + const sourceAppendices = join(sourceRoot, 'appendices'); |
| 212 | + const destinationAppendices = join(destinationRoot, 'appendices'); |
| 213 | + |
| 214 | + if (!existsSync(sourceAppendices)) { |
| 215 | + fail('Required appendices directory does not exist.'); |
| 216 | + } |
| 217 | + |
| 218 | + writeIndexFromReadme(join(sourceAppendices, 'README.md'), destinationAppendices); |
| 219 | + |
| 220 | + for (const markdownFile of findMarkdownFiles(sourceAppendices)) { |
| 221 | + copyFile(markdownFile, join(destinationAppendices, relative(sourceAppendices, markdownFile))); |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +function copyCourseContent() { |
| 226 | + console.log(`Overlaying course content into:\n${destinationRoot}\n`); |
| 227 | + |
| 228 | + mkdirSync(destinationRoot, { recursive: true }); |
| 229 | + |
| 230 | + copyFile(join(sourceRoot, 'README.md'), join(destinationRoot, 'README.md')); |
| 231 | + writeContentEngineSchema(); |
| 232 | + writeIndexFromReadme(join(sourceRoot, 'README.md'), destinationRoot, ['icon: CopilotIcon']); |
| 233 | + copyDirectory(join(sourceRoot, 'assets'), join(destinationRoot, 'assets')); |
| 234 | + |
| 235 | + for (const chapterFolder of getChapterFolders()) { |
| 236 | + const sourceChapter = join(sourceRoot, chapterFolder); |
| 237 | + const destinationChapter = join(destinationRoot, chapterFolder); |
| 238 | + |
| 239 | + mkdirSync(destinationChapter, { recursive: true }); |
| 240 | + copyFile(join(sourceChapter, 'README.md'), join(destinationChapter, 'README.md')); |
| 241 | + writeIndexFromReadme(join(sourceChapter, 'README.md'), destinationChapter); |
| 242 | + copyDirectory(join(sourceChapter, 'assets'), join(destinationChapter, 'assets')); |
| 243 | + |
| 244 | + for (const linkedMarkdown of getChapterLocalMarkdownLinks(sourceChapter)) { |
| 245 | + copyFile(linkedMarkdown, join(destinationChapter, relative(sourceChapter, linkedMarkdown))); |
| 246 | + } |
| 247 | + } |
| 248 | + |
| 249 | + copyAppendices(); |
| 250 | +} |
| 251 | + |
| 252 | +function findMarkdownFiles(directory) { |
| 253 | + const files = []; |
| 254 | + |
| 255 | + for (const entry of readdirSync(directory)) { |
| 256 | + const path = join(directory, entry); |
| 257 | + const stat = statSync(path); |
| 258 | + |
| 259 | + if (stat.isDirectory()) { |
| 260 | + files.push(...findMarkdownFiles(path)); |
| 261 | + } else if (entry.endsWith('.md')) { |
| 262 | + files.push(path); |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + return files; |
| 267 | +} |
| 268 | + |
| 269 | +function validateMarkdownImagePaths() { |
| 270 | + const imagePatterns = [ |
| 271 | + /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, |
| 272 | + /<img\b[^>]*\bsrc=["']([^"']+)["']/gi, |
| 273 | + ]; |
| 274 | + const brokenLinks = []; |
| 275 | + |
| 276 | + for (const markdownFile of findMarkdownFiles(destinationRoot)) { |
| 277 | + const markdown = readFileSync(markdownFile, 'utf8'); |
| 278 | + |
| 279 | + for (const pattern of imagePatterns) { |
| 280 | + for (const match of markdown.matchAll(pattern)) { |
| 281 | + const target = stripFragmentAndQuery(match[1]); |
| 282 | + if (!target || isExternalLink(target)) { |
| 283 | + continue; |
| 284 | + } |
| 285 | + |
| 286 | + const resolvedTarget = target.startsWith('/') |
| 287 | + ? join(destinationRoot, target.slice(1)) |
| 288 | + : resolve(dirname(markdownFile), target); |
| 289 | + |
| 290 | + if (!existsSync(resolvedTarget)) { |
| 291 | + const line = markdown.slice(0, match.index).split('\n').length; |
| 292 | + brokenLinks.push(`${relative(destinationRoot, markdownFile)}:${line} -> ${target}`); |
| 293 | + } |
| 294 | + } |
| 295 | + } |
| 296 | + } |
| 297 | + |
| 298 | + if (brokenLinks.length > 0) { |
| 299 | + fail(`Broken copied Markdown image references:\n${brokenLinks.join('\n')}`); |
| 300 | + } |
| 301 | + |
| 302 | + console.log('\nValidation passed: all copied Markdown image references resolve.'); |
| 303 | +} |
| 304 | + |
| 305 | +function validateMarkdownFrontmatter() { |
| 306 | + const requiredFields = contentEngineSchema.required ?? []; |
| 307 | + const missingFrontmatter = []; |
| 308 | + |
| 309 | + for (const markdownFile of findMarkdownFiles(destinationRoot)) { |
| 310 | + const markdown = readFileSync(markdownFile, 'utf8'); |
| 311 | + const frontmatter = markdown.match(/^---\n([\s\S]*?)\n---\n/)?.[1]; |
| 312 | + const relativePath = relative(destinationRoot, markdownFile); |
| 313 | + |
| 314 | + if (!frontmatter) { |
| 315 | + missingFrontmatter.push(`${relativePath}: missing frontmatter`); |
| 316 | + continue; |
| 317 | + } |
| 318 | + |
| 319 | + const missingFields = requiredFields.filter( |
| 320 | + (field) => !new RegExp(`^${field}:`, 'm').test(frontmatter), |
| 321 | + ); |
| 322 | + |
| 323 | + if (missingFields.length > 0) { |
| 324 | + missingFrontmatter.push(`${relativePath}: missing ${missingFields.join(', ')}`); |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + if (missingFrontmatter.length > 0) { |
| 329 | + fail(`Copied Markdown frontmatter does not match schema requirements:\n${missingFrontmatter.join('\n')}`); |
| 330 | + } |
| 331 | + |
| 332 | + console.log('Validation passed: copied Markdown frontmatter includes required schema fields.'); |
| 333 | +} |
| 334 | + |
| 335 | +ensureSafeDestination(); |
| 336 | +copyCourseContent(); |
| 337 | +validateMarkdownFrontmatter(); |
| 338 | +validateMarkdownImagePaths(); |
| 339 | +console.log('\nDone.'); |
0 commit comments