Skip to content

hellogunawan99/personal-portfolio

Repository files navigation

Alex Morgan — Portfolio

A fast, accessible, content-driven portfolio built with Astro 5 and Tailwind CSS v4. Static-first, zero client-side JavaScript by default, dark mode that respects system preference, and a content collection for projects you can edit in plain Markdown.

Placeholder identity — see the TODO section to swap in your real name, photos, and projects.

Astro Tailwind CSS TypeScript Markdown License


Lighthouse scores

Performance Accessibility Best Practices SEO
92 96 100 100

Measured on the production build at dist/ with lighthouse --quiet --only-categories=performance,accessibility,best-practices,seo. See INTEGRATION_REPORT.md for the full audit.

Preview

Desktop (1280×800) Tablet (768×1024) Mobile (375×812)
Desktop Tablet Mobile

More: mobile menu · project filtering · form validation


Features

  • Six pages — Home, About, Projects, Services, Contact, plus a custom 404.
  • Content collections — projects are typed Markdown files in src/content/projects/; no CMS, no DB.
  • Dark mode with FOUC-free inline bootstrap, system-preference aware, and a manual toggle that persists in localStorage.
  • A11y out of the box — skip link, semantic landmarks, visible focus rings, WCAG AA contrast, prefers-reduced-motion guards, form aria-live regions.
  • SEO — per-page <title> and <meta>, Open Graph, Twitter Card, JSON-LD Person schema, canonical URLs, sitemap.xml, robots.txt, /.well-known/security.txt.
  • Performance — fully static output, zero runtime JS by default, AVIF/WebP responsive images via astro:assets, inlined critical CSS, font display=swap, total bundle 3.4 MB (CSS 34 KB, JS 0 KB shipped).
  • Contact form with client-side validation, loading state, success screen, and a swap-in path to a real backend (Formspree, Netlify Forms, or your own).
  • Project filtering on /projects — animated category chips, no client framework.
  • Animations — scroll-reveal via IntersectionObserver, gated by prefers-reduced-motion.

Tech stack

Layer Choice Notes
Framework Astro 5.x Static-site generation, islands architecture, zero JS by default.
Styling Tailwind CSS 4.x Loaded via @tailwindcss/vite; design tokens defined in @theme.
Language TypeScript extends: astro/tsconfigs/strict.
Sitemap @astrojs/sitemap Emits sitemap-index.xml and sitemap-0.xml at build time.
Content Markdown + frontmatter No MDX — projects are typed via Astro content collections.
Client JS None (by design) Theme toggle, mobile menu, contact form, scroll-reveal are vanilla TS.
Hosting Any static host Outputs to dist/ — Netlify, Vercel, Cloudflare Pages, GitHub Pages.

Project structure

portfolio/
├── astro.config.mjs            # Astro config (site URL, integrations, vite plugins)
├── package.json                # Scripts and dependencies
├── tsconfig.json               # Extends astro/tsconfigs/strict
├── public/                     # Static assets served as-is
│   ├── favicon.svg             # Brand mark
│   ├── og-image.png            # 1200×630 Open Graph image
│   ├── robots.txt              # Crawler directives
│   ├── .well-known/
│   │   └── security.txt        # RFC 9116
│   └── images/icons/           # 16 hand-rolled SVG icons
├── src/
│   ├── _raw-assets/            # Source raster images (NOT copied to dist/)
│   ├── components/             # 14 reusable .astro components
│   │   ├── BaseLayout.astro    # → src/layouts/BaseLayout.astro
│   │   ├── Navbar.astro        # Top nav + mobile menu trigger
│   │   ├── Footer.astro
│   │   ├── Hero.astro
│   │   ├── ProjectCard.astro   # Used in /projects and home featured
│   │   ├── ServiceCard.astro
│   │   ├── SkillBar.astro
│   │   ├── TestimonialCard.astro
│   │   ├── SectionHeading.astro  # Accepts level="h1" | "h2" | "h3"
│   │   ├── ContactForm.astro   # Self-contained form with inline script
│   │   ├── ThemeToggle.astro
│   │   ├── MobileMenu.astro
│   │   ├── ScrollReveal.astro
│   │   ├── SeoHead.astro       # Centralized title/canonical/OG/Twitter/JSON-LD
│   │   └── SkipLink.astro
│   ├── content/
│   │   └── projects/           # 6 Markdown project entries
│   │       ├── lattice-orm.md
│   │       ├── multi-tenant-billing-platform.md
│   │       ├── offline-first-notes.md
│   │       ├── polaris-component-library.md
│   │       ├── realtime-analytics-dashboard.md
│   │       └── shipline-cli.md
│   ├── data/
│   │   └── content.ts          # Typed source of truth for all site copy + SEO
│   ├── layouts/
│   │   └── BaseLayout.astro    # Shared <html>, <head>, Navbar, Footer
│   ├── lib/
│   │   └── seo.ts              # Typed title/canonical/image URL builders
│   ├── pages/                  # File-based routes
│   │   ├── index.astro         # Home
│   │   ├── about.astro
│   │   ├── projects.astro
│   │   ├── services.astro
│   │   ├── contact.astro
│   │   └── 404.astro
│   ├── scripts/
│   │   └── theme.ts            # Client-side theme helper (mirrors inline bootstrap)
│   └── styles/
│       ├── global.css          # Tailwind import + @theme tokens + components
│       └── a11y.css            # skip-link, sr-only, focus rings, reduced-motion guard
├── docs/
│   ├── design-and-content.md   # Design system rationale and final copy
│   ├── screenshots/            # Cross-device renders used in this README
│   ├── BUILD_REPORT.md         # What shipped + known follow-ups
│   └── INTEGRATION_REPORT.md   # Final Lighthouse + audit details
├── .gitignore
├── LICENSE                     # MIT
└── README.md                   # You are here

Getting started

Prerequisites

  • Node.js 18.17+ (developed against Node 20+; project.json engines is loose).
  • npm (lockfile is package-lock.json).

Install

npm install

Run the dev server

npm run dev

Open http://localhost:4321. Hot-reloads on edits to src/.

Build for production

npm run build

Outputs static HTML/CSS/JS to dist/. Images are run through astro:assets and emitted as AVIF/WebP variants.

Preview the production build

npm run preview

Serves the dist/ output locally on http://localhost:4321.

Run with Docker

A multi-stage Dockerfile is included (Node 20-alpine for the build, nginx 1.27-alpine for serving). The final image is ~82 MB.

# Build — pass your real site URL so canonical + OG + sitemap use it
docker build -t portfolio --build-arg SITE_URL=https://your-domain .

# Run — port 8080 in the container; map to whatever you want
docker run -d --name portfolio -p 8080:8080 portfolio

# Open http://localhost:8080

The container binds to 8080 (not 80) so it runs as non-root out of the box — works on Railway, Fly, Cloud Run, Render, and any other platform that expects non-privileged containers. The bundled nginx.conf adds security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy), serves a 1-year immutable cache on hashed /_astro/* assets, and handles Astro's trailingSlash: 'never' URLs without redirect loops.

.dockerignore keeps the build context small (no node_modules/, no dist/, no VCS, no dev tooling).


Customization guide

The two files you'll touch most: src/data/content.ts (copy + identity + SEO) and src/styles/global.css (design tokens).

1. Swap the placeholder identity

All identity fields live in src/data/content.ts under the site export. Edit these and the rest of the site updates automatically.

// src/data/content.ts
export const site = {
  name: 'Alex Morgan',                    // → your full name
  shortName: 'AM',                        // → initials or short form
  tagline: 'Full-Stack Engineer • …',
  description: 'Senior full-stack engineer …',
  url: 'https://alexmorgan.dev',          // → your real URL
  email: 'hello@alexmorgan.dev',          // → your real email
  social: {
    github:   'https://github.com/alex-morgan',
    linkedin: 'https://www.linkedin.com/in/alex-morgan',
    twitter:  'https://twitter.com/alex_morgan',
  },
} as const;

Long-form copy (about bio, services, testimonials, project descriptions) is in the same file — search for about, services, testimonials, etc.

2. Add or replace a project

Projects are plain Markdown files in src/content/projects/. Create a new file (or edit an existing one) with frontmatter:

---
title: "Lattice ORM"
description: "TypeScript ORM focused on type-safe queries without runtime weight."
year: 2023
category: "Open Source"
stack: ["TypeScript", "PostgreSQL", "MySQL", "SQLite", "Vitest"]
---

A TypeScript ORM that focuses on type-safe queries without the runtime
weight of a query builder. The whole package is 11 KB minified, gzipped.

**Outcome:** Adopted by 3 YC companies and several indie SaaS tools.

The list of valid category values matches the filter chips on /projects. The first file in the collection becomes the featured card on the home page.

3. Change the color palette

Two files, kept in sync:

src/styles/global.css — design tokens inside @theme { … }:

@theme {
  --color-bg:           #F8FAFC;   /* light bg */
  --color-surface:      #FFFFFF;   /* cards */
  --color-text:         #0F172A;
  --color-text-muted:   #475569;
  --color-accent:       #D97706;   /* UI accent (borders, focus, icons) */
  --color-accent-text:  #B45309;   /* accent body text (passes AA on bg) */
  --color-border:       #E2E8F0;
  --color-border-strong:#CBD5E1;
}

The dark-mode overrides sit inside .dark { … } further down in the same file.

Tip: keep --color-accent for decoration (borders, icons, focus rings) and --color-accent-text for any accent-colored body text. That's what keeps WCAG AA contrast.

4. Update SEO metadata and social links

Per-page SEO lives in the seo export at the bottom of src/data/content.ts:

export const seo = {
  '/':         { title: 'Alex Morgan — Full-Stack Engineer', description: '…' },
  '/about':    { title: 'About — Alex Morgan',                description: '…' },
  '/projects': { title: 'Projects — Alex Morgan',             description: '…' },
  '/services': { title: 'Services — Alex Morgan',             description: '…' },
  '/contact':  { title: 'Contact — Alex Morgan',              description: '…' },
} as const;

Social links (used in JSON-LD, Open Graph site_name, and the footer) are in the site.social object shown above.

5. Wire the contact form to a real backend

The current ContactForm.astro simulates a 1.5 s network round-trip and shows a success state. Replace the await new Promise(...) line in the inline <script> with a real fetch. Three options, smallest diff first.

A. Formspree (zero backend)

// inside the form's <script> submit handler
const res = await fetch('https://formspree.io/f/YOUR_FORM_ID', {
  method: 'POST',
  headers: { 'Accept': 'application/json' },
  body: new FormData(form),
});
if (!res.ok) throw new Error('Submission failed');

Set the form's action and method for progressive enhancement (works without JS):

<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST" id="contact-form" ...>

B. Netlify Forms (if you deploy to Netlify)

<form
  name="contact"
  method="POST"
  data-netlify="true"
  netlify-honeypot="bot-field"
  id="contact-form"
>
  <input type="hidden" name="form-name" value="contact" />
  <p class="hidden"><label>Don't fill: <input name="bot-field" /></label></p>
  <!-- … fields … -->
</form>

No JS change needed — Netlify intercepts the POST. The honeypot field is a spam trap.

C. Custom backend (your own API)

const res = await fetch('/api/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name:    data.get('name'),
    email:   data.get('email'),
    subject: data.get('subject'),
    message: data.get('message'),
  }),
});

Then in src/pages/api/contact.ts (Astro endpoint, requires SSR adapter) or a serverless function on your host.

6. Change the site URL

astro.config.mjs — the site field is used for canonical URLs, sitemap entries, and Open Graph absolute paths:

export default defineConfig({
  site: 'https://alexmorgan.dev',   // ← change this
  trailingSlash: 'never',
  // …
});

Also update site.url in src/data/content.ts — the JSON-LD Person schema reads from there.


Deployment

The build outputs a fully static site to dist/. Pick any host.

Netlify

Drag-and-drop:

  1. npm run build
  2. Open https://app.netlify.com/drop
  3. Drag the dist/ folder onto the page. Done.

CLI:

npm install -g netlify-cli
npm run build
netlify deploy --prod --dir=dist

Netlify auto-detects Astro and runs npm run build with dist as the publish directory — no netlify.toml needed.

Vercel

Dashboard:

  1. Push the repo to GitHub/GitLab/Bitbucket.
  2. Import it at https://vercel.com/new.
  3. Vercel auto-detects Astro. Click Deploy.

CLI:

npm install -g vercel
vercel --prod

Cloudflare Pages

  1. Push the repo to GitHub.
  2. In the Cloudflare dashboard → Workers & PagesCreatePagesConnect to Git.
  3. Pick the repo.
  4. Set:
    • Build command: npm run build
    • Build output directory: dist
    • Node version: 20 (set under Environment variablesNODE_VERSION=20)
  5. Click Save and Deploy.

Subsequent pushes to the main branch redeploy automatically.

GitHub Pages

gh-pages npm package:

npm install --save-dev gh-pages
npm run build
npx gh-pages -d dist

Then in your repo's Settings → Pages, set the source to the gh-pages branch, / (root).

GitHub Actions (CI on every push to main):

Create .github/workflows/deploy.yml:

name: Deploy to GitHub Pages
on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: withastro/action@v3
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist
  deploy:
    needs: build
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/deploy-pages@v4

Custom domain? Add a CNAME file to public/ with your domain. After the first deploy, configure DNS and HTTPS in repo Settings → Pages.


Performance

The build is tuned for a fast, lean default experience. Targets and how they're achieved:

Metric Target How it's achieved
PageSpeed Insights 90+ Static HTML, inlined critical CSS, AVIF/WebP images, no render-blocking JS. Actual: 92.
LCP (4G mobile) < 2.0 s Preconnect to Google Fonts, font-display: swap, hero image is astro:assets AVIF with width hints.
Total client JS 0 KB shipped No client framework. Theme/menu/form/scroll-reveal are vanilla TS.
Cumulative Layout Shift < 0.05 All images have width/height, web font fallback metrics, no late-injected content.
Time to First Byte < 600 ms Static HTML — any CDN edge will hit this on a warm cache.
Total dist/ size < 5 MB Actual: 3.4 MB (1.9 MB of that is the OG image; everything else is sub-MB).

Verify locally

# Lighthouse CLI against your running preview server
npx lighthouse http://localhost:4321 --view

# Or against a deployed URL
npx lighthouse https://your-domain --view

Or paste the URL into https://pagespeed.web.dev/ for the PageSpeed Insights API report (lab + field data).


Accessibility

The build covers WCAG 2.1 AA out of the box:

  • Color contrast — every text/background pair clears AA (verified in docs/design-and-content.md). Accent body text uses --color-accent-text (not the lighter --color-accent).
  • Keyboard navigation — every interactive element is reachable via Tab, with a visible 2 px focus ring.
  • Skip link<a href="#main" class="skip-link"> is the first focusable element on every page.
  • Semantic landmarks<header>, <nav>, <main id="main">, <footer> are used throughout.
  • Form labels + aria-live — every input has a <label>, error messages use aria-live="polite", and the form's status container is role="status".
  • Reduced motion@media (prefers-reduced-motion: reduce) zeros out animations and disables smooth scroll.
  • Theme toggle — uses aria-pressed and is keyboard-operable.
  • One <h1> per page — every page has exactly one top-level heading; SectionHeading.astro accepts level="h1" | "h2" | "h3" to keep the hierarchy explicit.

License & credits

  • Code: MIT. Fork it, ship it, attribution appreciated but not required.
  • Framework: Astro — MIT.
  • Styling: Tailwind CSS — MIT.
  • Fonts (via Google Fonts):
  • Icons: Hand-rolled inline SVG components in src/components/ and public/images/icons/.
  • Project images: Generated for the demo, not stock — replace with your own.

TODO

This portfolio was scaffolded with placeholder identity and content. Before going live, replace the following:

  • Replace "Alex Morgan" everywhere. Search the repo for alexmorgan, Alex Morgan, and AM (initials). The two main sources are src/data/content.ts (site, seo) and docs/design-and-content.md.
  • Replace the profile and about photos. Drop your real photos into src/_raw-assets/ (e.g. profile.jpg, about-photo.jpg) — the build runs them through astro:assets automatically.
  • Replace the 6 project mockups in src/_raw-assets/ with real screenshots, and update the image field in each src/content/projects/*.md frontmatter.
  • Wire the contact form to a real backend. See the Customization guide §5 — Formspree, Netlify Forms, or a custom endpoint.
  • Update social links in the site.social object in src/data/content.ts (used by the footer, JSON-LD, and the /contact page).
  • Set the real site URL in astro.config.mjs (site: 'https://your-domain') and in src/data/content.ts (site.url). The sitemap and canonical URLs are generated from these.
  • (Optional) Swap the Open Graph image at public/og-image.png with one that has your name/photo and the new brand color.
  • (Optional) Remove the "Lighthouse scores" section once your real build's scores are confirmed, or update them with your real numbers.
  • (Optional) Set up auto-deploy: this repo includes a .github/workflows/deploy.yml that builds and deploys to GitHub Pages on every push to main. To enable: go to repo Settings → Pages, set Source to GitHub Actions. The build reads SITE_URL from the repo's Variables (Settings → Secrets and variables → Variables) so canonical/OG/sitemap use your real domain; if not set, it defaults to https://<owner>.github.io/<repo>.

About

Fast, accessible, content-driven portfolio built with Astro 5 + Tailwind 4. Lighthouse 92/96/100/100, zero JS by default, WCAG AA.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors