Skip to content

Commit bddd5ef

Browse files
authored
Merge pull request #161 from github/fix/content-engine-copy-generated-metadata
Fix content-engine metadata copy
2 parents d79510e + 60e305a commit bddd5ef

8 files changed

Lines changed: 384 additions & 49 deletions

File tree

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
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.');

06-mcp-servers/mcp-custom-server.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
<!--
2+
---
3+
id: CopilotCLI-06-Custom-MCP-Server
4+
title: !translate Building a Custom MCP Server
5+
description: !translate Build a simple custom MCP server in Python to connect GitHub Copilot CLI to your own APIs.
6+
audience: Developers / Students / Terminal users
7+
slug: building-a-custom-mcp-server
8+
weight: 61
9+
---
10+
-->
11+
112
# Building a Custom MCP Server
213

314
> ⚠️ **This content is completely optional.** You can be highly productive with Copilot CLI using only the pre-built MCP servers (GitHub, filesystem, Context7). This guide is for developers who want to connect Copilot to custom internal APIs. See the [MCP for Beginners course](https://github.com/microsoft/mcp-for-beginners) for more details.

appendices/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
<!--
2+
---
3+
id: CopilotCLI-Appendices
4+
title: !translate Appendices
5+
description: !translate Explore optional reference material that extends the GitHub Copilot CLI for Beginners course.
6+
audience: Developers / Students / Terminal users
7+
slug: appendices
8+
weight: 9
9+
---
10+
-->
11+
112
# Appendices
213

314
These appendices cover additional topics that extend the core course content. They're optional reading for when you need these specific capabilities.

appendices/additional-context.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
<!--
2+
---
3+
id: CopilotCLI-Appendix-Additional-Context
4+
title: !translate Additional Context Features
5+
description: !translate Learn how to use image context and manage permissions across multiple directories in GitHub Copilot CLI.
6+
audience: Developers / Students / Terminal users
7+
slug: additional-context-features
8+
weight: 92
9+
---
10+
-->
11+
112
# Additional Context Features
213

314
> 📖 **Prerequisite**: Complete [Chapter 02: Context and Conversations](../02-context-conversations/README.md) before reading this appendix.

appendices/ci-cd-integration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
<!--
2+
---
3+
id: CopilotCLI-Appendix-CI-CD-Integration
4+
title: !translate CI/CD Integration
5+
description: !translate Integrate GitHub Copilot CLI into GitHub Actions workflows for automated pull request reviews.
6+
audience: Developers / Students / Terminal users
7+
slug: ci-cd-integration
8+
weight: 91
9+
---
10+
-->
11+
112
# CI/CD Integration
213

314
> 📖 **Prerequisite**: Complete [Chapter 07: Putting It All Together](../07-putting-it-together/README.md) before reading this appendix.

0 commit comments

Comments
 (0)