Skip to content

Commit 60e305a

Browse files
DanWahlinCopilot
andcommitted
fix(content-engine): generate metadata during copy
Generate content-engine schema and directory indexes during the copy step so the source repo does not need root migration metadata files. Also unwrap hidden Markdown metadata only in the copied output and add missing hidden metadata for optional pages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d79510e commit 60e305a

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)