Skip to content

Commit

Permalink
Merge branch 'dev' into 90-avalanche-academy-glacier-course
Browse files Browse the repository at this point in the history
Signed-off-by: Owen <[email protected]>
  • Loading branch information
owenwahlgren authored Sep 17, 2024
2 parents b41f2a9 + 69861f2 commit 2cc01e9
Show file tree
Hide file tree
Showing 130 changed files with 3,592 additions and 1,846 deletions.
136 changes: 136 additions & 0 deletions .github/linkChecker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import get from 'axios';
import { readFileSync } from 'fs';
import { load } from 'cheerio';
import { sync as globSync } from 'glob';

const baseUrl = 'https://academy.avax.network';

const whitelist = [] // some websites return 404 for head requests, so we need to whitelist them, (fix: pass header -H 'Accept: text/html' and parse text/html)
// see https://github.com/rust-lang/crates.io/issues/788

interface LinkCheckResult {
file: string;
link: string;
line: number;
isValid: boolean;
}

function isValidURLOrPath(url: string): boolean {
try {
new URL(url)
return true
} catch {
if (url.startsWith("{") && url.endsWith("}")) { // is a a JSX component, ignore
return false;
}
else if (url.indexOf('.') > -1) { // is a url or misconfigured path
return true;
}
// where all our content lives
return url.startsWith("/");
}
}

async function checkLink(url: string): Promise<boolean> {
try {
const response = await get(url, {
timeout: 10000, // timeout to 10 seconds
maxRedirects: 5, // handle up to 5 redirects
validateStatus: function (status) {
return status >= 200 && status < 400; // resolve only if the status code is less than 400
},
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LinkChecker/1.0)', // Custom User-Agent
}
});
return response.status === 200;
} catch {
return false;
}
}

function extractLinksWithLineNumbers(mdxContent: string): { link: string; line: number }[] {
const lines = mdxContent.split('\n');
const links: { link: string; line: number }[] = [];

lines.forEach((line, index) => {
const $ = load(`<div>${line}</div>`);
$('a').each((i, elem) => {
const href = $(elem).attr('href');
if (href && isValidURLOrPath(href)) {
links.push({ link: href, line: index + 1 });
}
});

const markdownLinkRegex = /\[.*?\]\((.*?)\)/g;
let match;
while ((match = markdownLinkRegex.exec(line)) !== null) {
const link = match[1];
if (isValidURLOrPath(link)) {
links.push({ link, line: index + 1 });
}
}
});

return links;
}

async function checkAllMdxFiles(): Promise<void> {
const files = globSync('content/**/*.mdx');
console.log(`Found ${files.length} MDX files.`);

const results: LinkCheckResult[] = [];

for (const file of files) {
console.log(`Processing file: ${file}`);

const content = readFileSync(file, 'utf-8');
const links = extractLinksWithLineNumbers(content);

const cache: { [link: string]: boolean } = {};
let isValid: boolean;

for (const { link, line } of links) {
console.log(`Checking link: ${link} in file: ${file} (line ${line})`);

if (cache[link]) {
isValid = cache[link];
} else {
isValid = await checkLink(link); // check the link
if (!isValid) {
isValid = await checkLink(baseUrl + link); // if link failed check first time, try adding the base url (for internal relative links)
}
for (const wl of whitelist) {
if (link.includes(wl)) {
isValid = true;
break;
}
}
cache[link] = isValid;
}
results.push({ file, link, line, isValid });

if (!isValid) {
console.error(`\x1b[31mBroken link found\x1b[0m in ${file} (line ${line}): \x1b[33m${link}\x1b[0m`);
}
}
}


const brokenLinks = results.filter(result => !result.isValid);
if (brokenLinks.length > 0) {
console.error(`\n\x1b[31mSummary of broken links:\x1b[0m`);
brokenLinks.forEach(result => {
console.error(`File: \x1b[36m${result.file}\x1b[0m, Line: \x1b[33m${result.line}\x1b[0m, Link: \x1b[31m${result.link}\x1b[0m`);
});
process.exit(1);
} else {
console.log(`\x1b[32mAll links are valid.\x1b[0m`);
}
}

checkAllMdxFiles().catch(error => {
console.error('\x1b[31mError checking links:\x1b[0m', error);
process.exit(1);
});

21 changes: 21 additions & 0 deletions .github/workflows/checklinks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Check MDX Links

on:
pull_request:
paths:
- '**/*.mdx'

jobs:
check-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '19'
- name: Install dependencies
run: |
yarn install --frozen-lockfile
yarn global add tsx
- name: Check links
run: yarn check-links
2 changes: 1 addition & 1 deletion app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function HomePage(): React.ReactElement {
<>
<main className="container relative">
<Hero />
<Courses title="Explore our Courses" description="We offer fundamental courses specifically designed for individuals who are new to the Avalanche ecosystem, and advanced courses for those who wish to master the art of configuring, modifying, or even creating entirely new Virtual Machines from scratch." courses={COURSES.official} />
<Courses title="Explore our Courses" description="We offer fundamental courses specifically designed for individuals who are new to the Avalanche ecosystem, and advanced courses for those who wish to master the art of configuring, modifying, or even creating entirely new Virtual Machines from scratch." courses={COURSES.official_featured} />

{COURSES.ecosystem.length > 0 && <Courses title="Ecosystem Courses" description="Check out the courses provided by our ecosystem partners." courses={COURSES.ecosystem} />}

Expand Down
54 changes: 54 additions & 0 deletions app/api/generate-certificate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { PDFDocument } from 'pdf-lib';

const courseMapping: Record<string, string> = {
'avalanche-fundamentals': 'Avalanche Fundamentals',
};

function getCourseName(courseId: string): string {
return courseMapping[courseId] || courseId;
}

export async function POST(req: NextRequest) {
try {
const { courseId, userName } = await req.json();
if (!courseId || !userName) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); }
const courseName = getCourseName(courseId);
const protocol = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host') || 'localhost:3000';
const serverUrl = `${protocol}://${host}`;
const templateUrl = `${serverUrl}/certificates/AvalancheAcademy_Certificate.pdf`;
const templateResponse = await fetch(templateUrl);

if (!templateResponse.ok) { throw new Error(`Failed to fetch template`); }

const templateArrayBuffer = await templateResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(templateArrayBuffer);
const form = pdfDoc.getForm();

try {
// fills the form fields in our certificate template
form.getTextField('FullName').setText(userName);
form.getTextField('Class').setText(courseName);
form.getTextField('Awarded').setText(new Date().toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }));
form.getTextField('Id').setText(Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15));
} catch (error) {
throw new Error('Failed to fill form fields');
}

form.flatten();
const pdfBytes = await pdfDoc.save();
return new NextResponse(pdfBytes, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename=${courseId}_certificate.pdf`,
},
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate certificate, contact the Avalanche team.', details: (error as Error).message },
{ status: 500 }
);
}
}
23 changes: 16 additions & 7 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { getPages } from '@/utils/source';
import { getCoursePages } from '@/utils/course-loader';
import { getGuidePages } from '@/utils/guide-loader';
import { createSearchAPI } from 'fumadocs-core/search/server';

export const { GET } = createSearchAPI('advanced', {
indexes: getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.exports.structuredData,
id: page.url,
url: page.url,
})),
indexes: [
...getCoursePages().map((page) => ({
title: page.data.title,
structuredData: page.data.exports.structuredData,
id: page.url,
url: page.url,
})),
...getGuidePages().map((page) => ({
title: page.data.title,
structuredData: page.data.exports.structuredData,
id: page.url,
url: page.url,
})),
],
});
15 changes: 9 additions & 6 deletions app/course/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { DocsPage, DocsBody } from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import { getPage, getPages, type Page } from '@/utils/source';
import { getCoursePage, getCoursePages, type Page } from '@/utils/course-loader';
import { createMetadata } from '@/utils/metadata';
import IndexedDBComponent from '@/components/tracker'
import { Callout } from 'fumadocs-ui/components/callout';
Expand All @@ -12,6 +12,7 @@ import Instructors from '@/components/instructor';
import Link from 'next/link';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import COURSES from '@/content/courses';

interface Param {
slug: string[];
Expand All @@ -33,7 +34,7 @@ export default function Page({
}: {
params: Param;
}): React.ReactElement {
const page = getPage(params.slug);
const page = getCoursePage(params.slug);

if (!page) notFound();

Expand All @@ -43,6 +44,8 @@ export default function Page({
const updated = new Date(page.data.updated);
const [updatedISO, updatedHuman] = [updated.toISOString(), formatDate(updated)];

const course = COURSES.official.find(c => c.slug === page.slugs[0]);

return (
<DocsPage
toc={page.data.exports.toc}
Expand All @@ -53,7 +56,7 @@ export default function Page({
<div className="flex flex-col gap-6">
<div className='flex flex-col gap-y-4 text-sm text-muted-foreground'>
<div>Instructors:</div>
<Instructors names={["Martin Eckardt", "Andrea Vargas", "Ash", "Owen Wahlgren"]}/>
<Instructors names={course?.instructors || []} />
</div>

<Link href="https://t.me/avalancheacademy"
Expand Down Expand Up @@ -105,7 +108,7 @@ export default function Page({
}

function Category({ page }: { page: Page }): React.ReactElement {
const filtered = getPages()
const filtered = getCoursePages()
.filter(
(item) =>
item.file.dirname === page.file.dirname && item.file.name !== 'index',
Expand All @@ -126,7 +129,7 @@ function Category({ page }: { page: Page }): React.ReactElement {
}

export function generateMetadata({ params }: { params: Param }): Metadata {
const page = getPage(params.slug);
const page = getCoursePage(params.slug);

if (!page) notFound();

Expand Down Expand Up @@ -158,7 +161,7 @@ export function generateMetadata({ params }: { params: Param }): Metadata {
}

export function generateStaticParams(): Param[] {
return getPages().map<Param>((page) => ({
return getCoursePages().map<Param>((page) => ({
slug: page.slugs,
}));
}
8 changes: 4 additions & 4 deletions app/guide/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { guide } from '@/utils/source';
import { getGuidePage, getGuidePages } from '@/utils/guide-loader';
import { createMetadata } from '@/utils/metadata';
import { buttonVariants } from '@/components/ui/button';
import { ArrowUpRightIcon, MessagesSquare } from 'lucide-react';
Expand All @@ -21,7 +21,7 @@ export default function Page({
}: {
params: Param;
}): React.ReactElement {
const page = guide.getPage([params.slug]);
const page = getGuidePage([params.slug]);

if (!page) notFound();

Expand Down Expand Up @@ -114,7 +114,7 @@ export default function Page({
}

export function generateMetadata({ params }: { params: Param }): Metadata {
const page = guide.getPage([params.slug]);
const page = getGuidePage([params.slug]);

if (!page) notFound();

Expand Down Expand Up @@ -147,7 +147,7 @@ export function generateMetadata({ params }: { params: Param }): Metadata {


export function generateStaticParams(): Param[] {
return guide.getPages().map<Param>((page) => ({
return getGuidePages().map<Param>((page) => ({
slug: page.slugs[0],
}));
}
4 changes: 2 additions & 2 deletions app/guide/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Link from 'next/link';
import { guide } from '@/utils/source';
import { getGuidePages } from '@/utils/guide-loader';
import { buttonVariants } from '@/components/ui/button';
import { SiX } from '@icons-pack/react-simple-icons';

export default function Page(): React.ReactElement {
const guides = [...guide.getPages()].sort(
const guides = [...getGuidePages()].sort(
(a, b) =>
new Date(b.data.date ?? b.file.name).getTime() -
new Date(a.data.date ?? a.file.name).getTime(),
Expand Down
Loading

0 comments on commit 2cc01e9

Please sign in to comment.