Skip to content

Commit 49d1ab1

Browse files
committed
feat: improve certprep UX, routing, and discovery metadata
1 parent fc57eb5 commit 49d1ab1

20 files changed

Lines changed: 560 additions & 95 deletions

src/components/DomainDetails.astro

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ const { track }: { track: Track } = Astro.props;
2323
<div class="domain-list">
2424
{track.domains.map((domain) => (
2525
<article class="domain-item">
26-
<h4>{domain.name} · {domain.weight}%</h4>
26+
<h4>
27+
{domain.name} <span style="color:var(--text-muted); font-weight:400;"{domain.weight}%</span>
28+
</h4>
29+
<div class="domain-weight-bar">
30+
<div class="domain-weight-fill" style={`width:${domain.weight}%`}></div>
31+
</div>
2732
<ul>
2833
{domain.focus.map((focus) => (
2934
<li>{focus}</li>
@@ -33,15 +38,13 @@ const { track }: { track: Track } = Astro.props;
3338
))}
3439
</div>
3540

36-
<div style="margin-top:1.5rem; display:flex; flex-wrap:wrap; gap:1rem;">
41+
<div class="lab-cards">
3742
{track.labs.map((lab) => (
38-
<div style="border:1px dashed rgba(249,115,22,0.5); border-radius:14px; padding:0.85rem 1rem; flex:1 1 220px;">
39-
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--brand-muted);">
40-
{lab.type}
41-
</div>
43+
<div class="lab-card">
44+
<span class={`lab-badge lab-badge-${lab.type}`}>{lab.type}</span>
4245
<strong>{lab.title}</strong>
43-
<p style="margin:0.25rem 0 0.75rem; color:var(--text-muted);">{lab.description}</p>
44-
<a href={`${track.repoUrl}/tree/main/${lab.path}`} target="_blank" rel="noreferrer" style="color:var(--brand-accent); font-weight:600;">
46+
<p style="margin:0; color:var(--text-muted);">{lab.description}</p>
47+
<a href={`${track.repoUrl}/tree/main/${lab.path}`} target="_blank" rel="noreferrer">
4548
Open resource →
4649
</a>
4750
</div>

src/components/TrackCard.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { track }: { track: Track } = Astro.props;
55
---
66

77
<article class="card" id={track.id}>
8-
<div class="tag">{track.examCode}: {track.title}</div>
8+
<div class="tag">{track.examCode}</div>
99
<h3>{track.title}</h3>
1010
<p>{track.summary}</p>
1111
<ul style="margin:0; padding-left:1.15rem; color:var(--text-muted);">
@@ -17,7 +17,7 @@ const { track }: { track: Track } = Astro.props;
1717
<a class="button" href={track.repoUrl} target="_blank" rel="noreferrer">
1818
View Repo
1919
</a>
20-
<a class="button" style="border-color:rgba(148,163,184,.4)" href={`${track.repoUrl}/blob/main/${track.quickStart}`} target="_blank" rel="noreferrer">
20+
<a class="button secondary" href={`${track.repoUrl}/blob/main/${track.quickStart}`} target="_blank" rel="noreferrer">
2121
Quick Start
2222
</a>
2323
</div>

src/data/value-props.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
export type ValueProp = {
2+
icon: string;
23
title: string;
34
description: string;
45
};
56

67
export const valueProps: ValueProp[] = [
78
{
9+
icon: "🎯",
810
title: "Blueprint-first study",
911
description: "Every guide tracks the official domain weights so time spent matches the scoring rubric."
1012
},
1113
{
14+
icon: "🧪",
1215
title: "Labs you can fork",
1316
description: "Each concept ships with runnable scripts, repo patterns, and automation you can adapt to real teams."
1417
},
1518
{
19+
icon: "🤝",
1620
title: "Community maintained",
1721
description: "Discussion threads, PR reviews, and contributor checklists keep content accurate and fresh."
1822
},
1923
{
24+
icon: "🔁",
2025
title: "Review built in",
2126
description: "Quick references, mock questions, and domain maps make repetition and recall part of the workflow."
2227
}

src/layouts/BaseLayout.astro

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
---
22
import "../styles/global.css";
3+
import { DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE, SITE_NAME, absoluteUrl } from "../lib/site";
34
45
const {
5-
title = "CertPrep | Learn. Build. Certify.",
6-
description = "Open certification prep labs engineered for GitHub, cloud, and DevSecOps practitioners."
6+
title = `${SITE_NAME} | Learn. Build. Certify.`,
7+
description = DEFAULT_DESCRIPTION,
8+
image = DEFAULT_OG_IMAGE,
9+
noindex = false,
10+
keywords = [],
11+
structuredData = []
712
} = Astro.props;
13+
14+
const canonicalUrl = Astro.url ? Astro.url.toString() : absoluteUrl("/");
15+
const imageUrl = image.startsWith("http") ? image : absoluteUrl(image);
16+
const robots = noindex ? "noindex, nofollow" : "index, follow, max-image-preview:large";
17+
const structuredDataBlocks = Array.isArray(structuredData) ? structuredData : [structuredData];
818
---
919

1020
<!DOCTYPE html>
@@ -14,7 +24,27 @@ const {
1424
<meta name="viewport" content="width=device-width, initial-scale=1" />
1525
<title>{title}</title>
1626
<meta name="description" content={description} />
27+
<meta name="robots" content={robots} />
28+
<meta name="author" content={SITE_NAME} />
29+
<meta name="generator" content="Astro" />
30+
<meta name="theme-color" content="#f8fafc" />
31+
{keywords.length > 0 && <meta name="keywords" content={keywords.join(", ")} />}
32+
<link rel="canonical" href={canonicalUrl} />
1733
<link rel="icon" type="image/svg+xml" href="/certprep-logo.svg" />
34+
<meta property="og:type" content="website" />
35+
<meta property="og:site_name" content={SITE_NAME} />
36+
<meta property="og:title" content={title} />
37+
<meta property="og:description" content={description} />
38+
<meta property="og:url" content={canonicalUrl} />
39+
<meta property="og:image" content={imageUrl} />
40+
<meta property="og:locale" content="en_US" />
41+
<meta name="twitter:card" content="summary_large_image" />
42+
<meta name="twitter:title" content={title} />
43+
<meta name="twitter:description" content={description} />
44+
<meta name="twitter:image" content={imageUrl} />
45+
{structuredDataBlocks.filter(Boolean).map((block) => (
46+
<script type="application/ld+json" set:html={JSON.stringify(block)} />
47+
))}
1848
</head>
1949
<body>
2050
<div class="page-shell">
@@ -29,9 +59,9 @@ const {
2959
</div>
3060
</div>
3161
<nav class="nav-links" aria-label="Primary">
32-
<a href="#tracks">Tracks</a>
62+
<a href="/#tracks">Tracks</a>
3363
<div class="nav-dropdown">
34-
<button class="nav-trigger" type="button" aria-haspopup="true" aria-expanded="false">
64+
<button class="nav-trigger" type="button" aria-haspopup="true">
3565
Exam Prep Help
3666
</button>
3767
<div class="nav-menu" role="menu">
@@ -43,8 +73,8 @@ const {
4373
<a role="menuitem" href="/exam-prep">All blueprints</a>
4474
</div>
4575
</div>
46-
<a href="#toolkit">Prep Toolkit</a>
47-
<a href="#contribute">Contribute</a>
76+
<a href="/#toolkit">Prep Toolkit</a>
77+
<a href="/#contribute">Contribute</a>
4878
</nav>
4979
<a class="button" href="https://github.com/certforge" target="_blank" rel="noreferrer">
5080
Explore GitHub

src/lib/site.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { tracks } from "../data/tracks";
2+
3+
export const SITE_URL = "https://certprep.dev";
4+
export const SITE_NAME = "CertPrep";
5+
export const SITE_TAGLINE = "Learn. Build. Certify.";
6+
export const DEFAULT_DESCRIPTION =
7+
"Open certification prep labs with blueprint-aligned guides, runnable demos, mock exams, and community-maintained study resources.";
8+
export const DEFAULT_OG_IMAGE = "/certprep-logo.svg";
9+
10+
export const staticPages = [
11+
{ path: "/", title: "CertPrep | Certification Prep Hub" },
12+
{ path: "/exam-prep", title: "CertPrep | Exam Blueprints" }
13+
];
14+
15+
export function absoluteUrl(pathname: string) {
16+
return new URL(pathname, SITE_URL).toString();
17+
}
18+
19+
export function getTrackById(trackId: string) {
20+
return tracks.find((track) => track.id === trackId);
21+
}
22+
23+
export function getExamPrepPath(trackId: string) {
24+
return `/exam-prep/${trackId}`;
25+
}
26+
27+
export function getAllIndexablePaths() {
28+
return [
29+
...staticPages.map((page) => page.path),
30+
...tracks.map((track) => getExamPrepPath(track.id))
31+
];
32+
}

src/lib/structured-data.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { tracks, type Track } from "../data/tracks";
2+
import {
3+
DEFAULT_DESCRIPTION,
4+
SITE_NAME,
5+
SITE_TAGLINE,
6+
SITE_URL,
7+
absoluteUrl,
8+
getExamPrepPath
9+
} from "./site";
10+
11+
function organizationNode() {
12+
return {
13+
"@type": "Organization",
14+
"@id": `${SITE_URL}#organization`,
15+
name: SITE_NAME,
16+
url: SITE_URL,
17+
logo: absoluteUrl("/certprep-logo.svg"),
18+
sameAs: ["https://github.com/certforge"],
19+
description: DEFAULT_DESCRIPTION
20+
};
21+
}
22+
23+
function websiteNode() {
24+
return {
25+
"@type": "WebSite",
26+
"@id": `${SITE_URL}#website`,
27+
name: SITE_NAME,
28+
url: SITE_URL,
29+
description: DEFAULT_DESCRIPTION,
30+
publisher: { "@id": `${SITE_URL}#organization` },
31+
inLanguage: "en"
32+
};
33+
}
34+
35+
export function homeStructuredData() {
36+
return {
37+
"@context": "https://schema.org",
38+
"@graph": [
39+
organizationNode(),
40+
websiteNode(),
41+
{
42+
"@type": "CollectionPage",
43+
"@id": `${SITE_URL}#home`,
44+
name: `${SITE_NAME} certification prep hub`,
45+
url: SITE_URL,
46+
description: DEFAULT_DESCRIPTION,
47+
isPartOf: { "@id": `${SITE_URL}#website` },
48+
about: tracks.map((track) => `${track.examCode}: ${track.title}`),
49+
mainEntity: {
50+
"@type": "ItemList",
51+
itemListElement: tracks.map((track, index) => ({
52+
"@type": "ListItem",
53+
position: index + 1,
54+
url: absoluteUrl(getExamPrepPath(track.id)),
55+
name: `${track.examCode}: ${track.title}`
56+
}))
57+
}
58+
},
59+
{
60+
"@type": "FAQPage",
61+
"@id": `${SITE_URL}#faq`,
62+
mainEntity: [
63+
{
64+
"@type": "Question",
65+
name: "How should I use CertPrep to study for an exam?",
66+
acceptedAnswer: {
67+
"@type": "Answer",
68+
text: "Start with the exam blueprint page, follow the weighted domain guides, run the matching demos or scripts, and then review using mock exams or quick-reference notes."
69+
}
70+
},
71+
{
72+
"@type": "Question",
73+
name: "Does CertPrep include hands-on labs and mock exams?",
74+
acceptedAnswer: {
75+
"@type": "Answer",
76+
text: "Yes. Tracks include runnable demos, scripts, quick references, and where available mock exams or practice-question sets inside the linked repositories."
77+
}
78+
},
79+
{
80+
"@type": "Question",
81+
name: "Which certification tracks are currently available?",
82+
acceptedAnswer: {
83+
"@type": "Answer",
84+
text: tracks.map((track) => `${track.examCode}: ${track.title}`).join("; ")
85+
}
86+
}
87+
]
88+
}
89+
]
90+
};
91+
}
92+
93+
export function examPrepIndexStructuredData() {
94+
return {
95+
"@context": "https://schema.org",
96+
"@graph": [
97+
organizationNode(),
98+
websiteNode(),
99+
{
100+
"@type": "CollectionPage",
101+
name: `${SITE_NAME} exam blueprints`,
102+
url: absoluteUrl("/exam-prep"),
103+
description:
104+
"Certification exam blueprint pages covering domain weights, focus areas, and direct links into each CertPrep repository.",
105+
isPartOf: { "@id": `${SITE_URL}#website` },
106+
mainEntity: {
107+
"@type": "ItemList",
108+
itemListElement: tracks.map((track, index) => ({
109+
"@type": "ListItem",
110+
position: index + 1,
111+
url: absoluteUrl(getExamPrepPath(track.id)),
112+
name: `${track.examCode}: ${track.title}`
113+
}))
114+
}
115+
}
116+
]
117+
};
118+
}
119+
120+
export function examTrackStructuredData(track: Track) {
121+
return {
122+
"@context": "https://schema.org",
123+
"@graph": [
124+
organizationNode(),
125+
websiteNode(),
126+
{
127+
"@type": "LearningResource",
128+
name: `${track.examCode}: ${track.title} exam blueprint`,
129+
url: absoluteUrl(getExamPrepPath(track.id)),
130+
description: track.summary,
131+
isPartOf: { "@id": `${SITE_URL}#website` },
132+
publisher: { "@id": `${SITE_URL}#organization` },
133+
learningResourceType: "Certification exam blueprint",
134+
educationalLevel: "Professional certification",
135+
about: track.domains.map((domain) => ({
136+
"@type": "DefinedTerm",
137+
name: domain.name,
138+
description: domain.focus.join(", ")
139+
})),
140+
teaches: track.highlights,
141+
keywords: [track.examCode, track.title, SITE_NAME, SITE_TAGLINE]
142+
},
143+
{
144+
"@type": "BreadcrumbList",
145+
itemListElement: [
146+
{
147+
"@type": "ListItem",
148+
position: 1,
149+
name: SITE_NAME,
150+
item: SITE_URL
151+
},
152+
{
153+
"@type": "ListItem",
154+
position: 2,
155+
name: "Exam Prep Help",
156+
item: absoluteUrl("/exam-prep")
157+
},
158+
{
159+
"@type": "ListItem",
160+
position: 3,
161+
name: `${track.examCode}: ${track.title}`,
162+
item: absoluteUrl(getExamPrepPath(track.id))
163+
}
164+
]
165+
}
166+
]
167+
};
168+
}

src/pages/404.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import BaseLayout from "../layouts/BaseLayout.astro";
33
---
44

5-
<BaseLayout title="CertPrep | 404" description="This page fell out of the repo history.">
5+
<BaseLayout title="CertPrep | 404" description="This page fell out of the repo history." noindex={true}>
66
<section class="hero">
77
<div class="hero-grid">
88
<div>

src/pages/exam-prep/[slug].astro

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
import BaseLayout from "../../layouts/BaseLayout.astro";
3+
import DomainDetails from "../../components/DomainDetails.astro";
4+
import { tracks } from "../../data/tracks";
5+
import { examTrackStructuredData } from "../../lib/structured-data";
6+
7+
export function getStaticPaths() {
8+
return tracks.map((track) => ({
9+
params: { slug: track.id },
10+
props: { track }
11+
}));
12+
}
13+
14+
const { track } = Astro.props;
15+
---
16+
17+
<BaseLayout
18+
title={`CertPrep | ${track.examCode} Exam Blueprint`}
19+
description={`${track.examCode}: ${track.title} certification blueprint with domain weights, study focus areas, and direct links into the repo.`}
20+
keywords={[track.examCode, track.title, "exam blueprint", "certification prep", "study guide"]}
21+
structuredData={examTrackStructuredData(track)}
22+
>
23+
<section class="hero">
24+
<div>
25+
<span class="tag">{track.examCode}</span>
26+
<h1>{track.title} blueprint</h1>
27+
<p>{track.summary}</p>
28+
</div>
29+
</section>
30+
31+
<DomainDetails track={track} />
32+
</BaseLayout>

0 commit comments

Comments
 (0)