Thanks for considering a contribution. CloudCertPrep is a free, open-source tool. Anything that makes the questions more accurate, the experience smoother, or the code cleaner is welcome.
In rough priority order:
- Question fixes: incorrect answers, ambiguous wording, outdated AWS service references. Use the Report a question error issue template.
- New certifications: extra question banks for SAA-C03, SAP-C02, DOP-C02 (6 domains), SCS-C02 (6 domains), or any other AWS exam. See "Adding a new certification" below. The codebase supports certs with any number of domains.
- Accessibility audits: keyboard navigation, ARIA labels, screen reader testing, colour contrast.
- Performance and SEO improvements: smaller bundles, faster TTFB, better Core Web Vitals.
- Bug fixes for anything you find while using the app.
I am not looking for:
- New external dependencies (every dep is a maintenance liability for a free side project).
- Major refactors of working code.
- Style or formatting-only PRs.
- Adding tracking, ads, or upsell flows.
See the Quick Start in README.md for clone, install, and .env setup.
You will need your own Supabase project to test authenticated flows. The anon key and project URL are public by design (protected by Row Level Security on the server). The service role key is private and is never used in client code.
Every question lives in src/data/<cert-code>/domain<N>.json as an array entry:
Some certs (AIF-C01) also use ordering (arrange steps into a sequence) and matching (pair each left item with a right item). These add an explicit type discriminator and their own correct-answer fields; answer is unused (set it to ""). isMultiAnswer stays false.
// ORDERING: options are the steps (keys A-E); correctOrder is the right sequence of keys.
{
"id": "aif-q457",
"type": "ordering",
"question": "Arrange the ML lifecycle stages from first to last.",
"options": { "A": "Train", "B": "Prepare data", "C": "Deploy", "D": "Define the problem" },
"correctOrder": ["D", "B", "A", "C"], // A permutation of EVERY option key.
"answer": "",
"isMultiAnswer": false,
"explanation": "..."
}
// MATCHING: options are the left column (A-E); targets are the right column (1-5);
// correctMatches pairs every option key to a target key.
{
"id": "aif-q460",
"type": "matching",
"question": "Match each service to its purpose.",
"options": { "A": "Amazon Bedrock", "B": "Amazon Q Business" },
"targets": { "1": "Generative AI assistant over enterprise data", "2": "Foundation-model API access" },
"correctMatches": { "A": "2", "B": "1" },
"answer": "",
"isMultiAnswer": false,
"explanation": "..."
}- Every entry must have:
id,question,options,explanation(plus the correct-answer field for itstype). - IDs must be unique across the entire cert (across all domain files for that cert).
optionsmust have at least 2 non-empty keys, and keys must be inAtoE.- For single-answer:
answeris a string matching one of the option keys. - For multi-answer:
isMultiAnswer: trueandansweris an array of 2+ keys, all matching options. - For
type: "ordering":correctOrderis an array that is a permutation of every non-empty option key. - For
type: "matching":targetshas keys1-5, andcorrectMatchesmaps every option key to a valid target key. explanationshould be non-empty (warning, not error). For live banks,npm run validateenforces exactly two paragraphs separated by one\n: correct-answer rationale first, then distractor rationales.- No em dashes (use ASCII punctuation:
,,.,:,;,-).
Run validation locally before opening a PR:
npm run validateCI runs the same check on every PR.
Five steps:
- Open an issue using the Add a certification template. Include cert code, domains, and exam format. This keeps me in the loop and prevents duplicate effort.
- Add cert config in
src/data/certifications.ts:The{ code: 'saa-c03', name: 'AWS Certified Solutions Architect - Associate', shortName: 'SAA-C03', provider: 'aws', level: 'associate', status: 'active', examQuestionCount: 65, examTimeSeconds: 130 * 60, passingScore: 720, domains: [ { id: 1, name: 'Design Secure Architectures', examProportion: 0.30, questionCount: 0 }, // ... etc ], }
providerfield groups certs by cloud provider (currently only'aws'). Thelevelfield is one of'foundational','associate','professional', or'specialty'and determines sort order and grouping in the UI. - Add one question file per domain:
src/data/<cert-code>/domain1.json,domain2.json, ... one file per domain entry you listed in step 2. Follow the schema above. Most AWS certs have 4 domains; some (DOP-C02, SCS-C02) have 5 or 6. The codebase supports any number of domains. - Wire the loader in
src/data/questions.ts. One entry per domain file you added:const CERT_LOADERS: Record<string, Record<number, DomainLoader>> = { // ... existing entries 'saa-c03': { 1: () => import('./saa-c03/domain1.json'), 2: () => import('./saa-c03/domain2.json'), 3: () => import('./saa-c03/domain3.json'), 4: () => import('./saa-c03/domain4.json'), // Add more entries if the cert has more domains. }, }
- Run
npm run validateandnpm run buildto confirm everything works. Open a PR.
The cert switcher, mock exam, domain practice, history, and stats pages all pick up the new cert automatically once the cert config, question files, and loaders are wired. The cert switcher is hidden while only one cert has status: 'active' and re-appears automatically once a second cert is flipped to active. No frontend or backend code changes are needed regardless of how many domains the cert has; the exam_attempts.domain_scores JSONB column scales per-cert. The sitemap (public/sitemap.xml), per-cert question counts (src/lib/generated/question-counts.ts), public/llms.txt, public/manifest.json, and public/_redirects are all regenerated from certifications.ts via scripts/generate-seo-assets.mjs (wired as prebuild) and will include the new cert's routes automatically.
Blog posts live in src/content/blog/ as Markdown with frontmatter validated at build time (scripts/validate-blog-frontmatter.mjs, wired into prebuild):
- Create
src/content/blog/<slug>.md. Required frontmatter:title,slug(must match the filename),description,date(YYYY-MM-DD, not in the future),tags,author(Alex Santonastaso; posts are anchored to the site Person entity),draft. Optional:ogImage(root-relative path),canonical(only for cross-posts),updated. draft: truekeeps the post out of the production build, sitemap, and RSS feed while you iterate.- Internal links must point at real routes;
scripts/validate-internal-links.mjsfails the build on broken ones. Images need alt text. npm run buildregenerates the sitemap and RSS; nothing else to wire. The post appears on/blog, in/blog/rss.xml, and (when tagged with a domain slug) in the "Latest from the blog" block on matching domain landing pages.
The per-domain pages (/aws/<cert>/<domain-slug>) are generated from the registry; there is no per-page file to edit:
- Copy (intro, FAQ, hero bullets) is templated in
src/pages/aws/[cert]/[domain].astrofromsrc/data/certifications.ts(domainname,weight,taskRange,taskStatements,services) andsrc/data/keyword-clusters.ts. - To improve a specific domain page, enrich its registry entry (e.g. add
taskStatements[]orservices[]) rather than forking the template. - The sitemap, OG image, and
llms.txtentries for the page regenerate automatically atprebuild.
The project uses Vitest (Vite's native test runner) for unit tests. Pure functions in src/lib/ and src/hooks/ are the primary target. CI runs the full suite on every PR via .github/workflows/ci.yml.
npm run test # one-shot run (what CI uses)
npm run test:watch # interactive watch mode while developing
npm run test:ui # Vitest UI in the browserWhere tests live
- Co-located with source:
src/lib/scoring.test.tssits next tosrc/lib/scoring.ts. - Naming:
<source>.test.ts(the runner picks up*.test.tsand*.test.tsxautomatically).
Existing coverage (representative, not exhaustive)
src/lib/scoring.test.ts:isAnswerCorrect,calculateScaledScore,getDomainScore,getExamDomainTargets,selectExamQuestions.src/lib/validation.test.ts:validatePassword,scorePassword,isPasswordStrongEnough.src/lib/utils.test.ts:toOriginalAnswer,toggleMultiAnswer,shuffleAndMapQuestionsround-trip.src/lib/spacedRepetition.test.ts: unseen / weighted / backfill question selection.- Also covered:
formatting,faqLinks,examGuard,domainStats,navigation,seo-data,jsonld,authErrors,supabaseUtils, plus thecertificationsandquestionTypesintegration checks.
Suggested next targets (by impact; all currently untested):
src/lib/pendingAttempt.ts:storePendingAttempt/hasPendingAttempt/consumePendingAttemptSavedNoticeround-trip (the guest-to-login attempt-restore flow). StublocalStorage; skip the asyncflushPendingAttempt, which calls Supabase.src/lib/analytics.ts: theKNOWN_EVENTSallow-list guard intrackEvent(reserved-name rejection) and thetrackPageViewpath handling. Stub the global tracker.src/lib/buttonStyles.ts: the pure class-builders (buttonClass,inputClass,alertClass, and siblings) map variant / size / tone and error states to the expected classes.
What not to test (yet)
- React component rendering. We don't currently use
@testing-library/react. If you want to add it, open an issue first so we can discuss the dep cost. - Supabase calls. Network-bound integration tests don't pay for themselves at this scale.
New PRs should include tests for any new pure function. Tests are required to merge.
- One concern per PR. A PR that fixes 5 questions and adds a new cert is two PRs.
- Conventional commits in commit messages:
feat:,fix:,chore:,docs:,refactor:,style:,test:.- Good:
fix(clf-c02): correct answer for q142 - Good:
feat(saa-c03): add domain 1 question bank (50 questions) - Bad:
Update questions.json
- Good:
- Run all checks locally before opening:
npm run lint npm run validate npm run test npm run build - No new dependencies unless explicitly justified in the PR description.
- No em dashes anywhere in code, comments, docs, or UI strings.
- No emojis or decorative pictographs in UI for navigation, actions, or feature illustration. Use Lucide icons for those (already a dependency).
- Common typographic glyphs are allowed when used semantically:
©(copyright),·(separator),−(minus),▼/▶(disclosure indicator),•(input placeholder dot). Don't add new ones without a clear reason. - UI changes must use the design tokens, not raw Tailwind grays or hex values. See "Design tokens" below.
UI styling is driven by a small set of CSS variables defined in src/index.css and exposed through Tailwind classes in tailwind.config.js. Use these tokens; do not hand-pick Tailwind grays or hex values.
Colours
| Token | Tailwind class | Use for |
|---|---|---|
| Brand (AWS orange) | brand, on-brand |
Primary CTAs, active state, brand accents (text-brand, bg-brand, text-on-brand) |
| Header chrome (navy) | header-bg, on-header |
The site header bar and text on it |
| Background, page | bg-bg-dark |
Page background (adapts to light/dark theme) |
| Background, card | bg-bg-card, bg-bg-card-hover |
Cards, panels, raised surfaces |
| Text, primary | text-text-primary |
Headings, body copy |
| Text, muted | text-text-muted |
Secondary copy, captions, labels |
| Success | bg-success, text-success, border-success |
Correct answers, pass states |
| Danger | bg-danger, text-danger, border-danger |
Incorrect answers, fail states, destructive actions |
| Warning | bg-warning, text-warning, border-warning |
Flag-for-review, caution banners |
Domain pills, scores, and badges use the brand token (text-brand, bg-brand/20, text-on-brand, etc.) defined in tailwind.config.js. The brand is a single value (#FF9900) in both themes; do not introduce per-domain colours or hand-pick hex values.
Spacing, radius, shadow
- Card padding:
p-4 md:p-6(small) orp-6 md:p-8(large). - Card radius:
rounded-xl. Text fields:rounded-lg. Buttons/pills:rounded-full. - Card shadow:
shadow-card(defined intailwind.config.js). Do not use rawshadow-md/shadow-lg.
Typography
- Headings:
font-semibold(notfont-boldunless you have a specific reason). - Body: default weight,
text-sm md:text-base. - Mono:
font-monofor timer and question IDs only.
Buttons
Always use the Button component from src/components/Button.tsx for any clickable action. It exposes variant (primary | secondary | danger | ghost), size (sm | md | lg), fullWidth, loading, loadingText, and leftIcon. Do not roll your own <button> with inline classes unless you're matching a specific custom layout already in the codebase (e.g. the question-grid cells in MockExam.tsx).
Icons
lucide-react only for action / navigation / feature icons. No emoji and no inline SVG. See the Unicode glyph rule above for typographic exceptions such as © or ·.
src/pages/ contains two kinds of files:
*.astrofiles: Astro routes. Each one is a prerendered page (home, cert landings, blog, legal, etc.) or a thin shell for an interactive island._*.tsxfiles (underscore prefix): React island bodies (MockExam, DomainPractice, History, Login, ResetPassword, Stats, NotFound). The_prefix prevents Astro's file-based router from treating them as URL routes. Do not rename these without the underscore, or they would become/MockExametc.
src/components/ follows the same pattern: *.astro for static markup, *.tsx for React islands, *Island.tsx for thin wrappers that mount a React component as an Astro island.
I aim to give every new issue and PR a first triage response within 24 hours. I review every PR personally. Small, focused PRs typically get a response within a week. I may ask for changes, especially around UX consistency. Don't take it personally; the goal is keeping the app coherent for everyone.
By contributing, you agree your work is licensed under the same MIT licence as the rest of the project.
The app ships three sign-in options (Google OAuth, GitHub OAuth, and email/password) plus Cloudflare Turnstile bot protection. All of it is brokered by Supabase, so no provider secret ever lives in this repo. None of this is required to develop the guest-mode experience; the sign-in buttons simply error until the providers are configured in your own Supabase project.
The OAuth buttons call supabase.auth.signInWithOAuth({ provider }), so each provider is configured entirely in the Supabase dashboard, with no app code or env changes required.
- Google Cloud console → create (or pick) a project → APIs & Services → Credentials → Create credentials → OAuth client ID (type: Web application).
- Authorized JavaScript origins:
https://www.cloudcertprep.ioandhttp://localhost:4321(local dev). - Authorized redirect URIs:
https://<your-project-ref>.supabase.co/auth/v1/callback. - Configure the OAuth consent screen (app name, support email, scopes:
email,profile,openid).
- Authorized JavaScript origins:
- Copy the Client ID and Client secret.
- Supabase Dashboard → Authentication → Providers → Google → enable and paste the Client ID + secret → save.
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Homepage URL:
https://www.cloudcertprep.io. - Authorization callback URL:
https://<your-project-ref>.supabase.co/auth/v1/callback.
- Homepage URL:
- Copy the Client ID and generate a Client secret.
- Supabase Dashboard → Authentication → Providers → GitHub → enable and paste the Client ID + secret → save.
For local dev, run npm run dev and use the "Continue with Google" / "Continue with GitHub" buttons on /login. If a provider isn't configured, the other flows still work.
The auth forms (sign in, sign up, password reset) are protected by Cloudflare Turnstile. Verification is handled natively by Supabase, so the secret never lives in this repo:
- Cloudflare dashboard → Turnstile → Add widget. Add your domains (
cloudcertprep.ioandlocalhostfor dev). Cloudflare gives you a Site key (public) and a Secret key (private). - Client: put the Site key in
VITE_TURNSTILE_SITE_KEY(a Cloudflare Pages environment variable for prod,.envfor local). This is public by design. - Supabase Dashboard → Authentication → Settings → Bot and Abuse Protection → enable Turnstile → paste the Secret key. Supabase validates the
captchaTokenthe client sends on every auth call. - Leaving
VITE_TURNSTILE_SITE_KEYblank disables the widget locally (the form stays usable); enable the captcha in Supabase only once the site key is set, or auth requests will be rejected for a missing token.
The Turnstile Secret key and the OAuth Client secrets must only ever be entered in the provider/Supabase dashboards. Never commit them or place them in client env. If a secret is ever exposed, rotate it at its source.
Contributor PRs target main directly. Cut a short-lived feature branch
(fix/*, feat/*, test/*, docs/*) from main, push it, and open a PR back
into main. Squash-merge is the default. Each PR gets its own Cloudflare Pages
preview deployment (a noindex build, described below), so UI changes can be
reviewed before they merge.
mainis the production branch and the integration trunk. Cloudflare Pages auto-deploys it; it is the only indexable surface. All contributions land here.devis an optional, maintainer-only staging branch. It is not a required hop, and it is not where contributor PRs go. When used, it is reset frommainfor ad-hoc integration previews.
Non-production Cloudflare Pages preview deployments set PUBLIC_NOINDEX=true
(via the Preview environment variables), which bakes
<meta name="robots" content="noindex"> into every prerendered page. That meta
tag is the noindex gate. The production build never emits noindex on any
indexable route.
Each production release is marked with an annotated semver tag (e.g. v2.0.0) and a
GitHub Release published from that tag, so the GitHub Releases page stays a durable,
factual public changelog. See CHANGELOG.md for the tracked release notes.
{ "id": "q142", // Unique within the cert. CLF-C02 uses `q<NNN>`; AIF-C01 uses `aif-q<NNN>`. Match the existing prefix in the cert's files. "question": "Which AWS service is...?", // Plain text. No HTML, no Markdown. "options": { "A": "Amazon EC2", "B": "Amazon S3", "C": "Amazon RDS", "D": "Amazon DynamoDB" }, "answer": "B", // Single key from options. Or array for multi-answer. "isMultiAnswer": false, // If true, answer must be an array of 2+ keys. "explanation": "Amazon S3 is..." // Plain text. Exactly two paragraphs separated by one \\n: correct-answer rationale, then distractor rationales. }