A personal portfolio site built with Angular 22, styled as a code-editor workspace. The entire application is statically generated (SSG) and served from a hardened NGINX container on Kubernetes.
Live: faisalkhan.dpdns.org
| Layer | Technology |
|---|---|
| Framework | Angular 22 (standalone components, signals, zoneless, OnPush) |
| Rendering | SSG via @angular/ssr — the entire route tree is prerendered at build time |
| Styling | Scoped component CSS + global design tokens (src/styles.css) |
| Blog | Markdown files rendered via marked + highlight.js |
| Container | Multi-stage Docker — node:24.17-alpine builder, nginxinc/nginx-unprivileged:1.30-alpine runtime |
| Web Server | NGINX with gzip, granular cache policies, and full security headers |
| Language | TypeScript 6.0 (strict mode, no any) |
- Glass Workspace UI — Glassmorphism design system with
backdrop-filter, custom design tokens, dark/light mode - Static Site Generation — All routes prerendered at build time for instant load and SEO
- Blog Engine — Markdown posts with anchor IDs, copy-button code blocks, reading progress bar, prev/next + breadcrumb navigation, share row, "Edit on GitHub" deep link, and a
/blog/tag/:tagarchive - GitHub-sourced Project Showcase —
/projectsis driven by aportfolioGitHub topic filter + a thin local override manifest. Each card +/projects/:slugdetail page renders live stars/forks/language/last-push/license pills, a language-distribution bar, optional release tag, a README excerpt, a 52-week commit sparkline, and related blog posts. Adding a new project is a single click on github.com - Auto-generated assets — Per-post Open Graph card PNGs, Atom feed (
/feed.xml), sitemap (/sitemap.xml), and image dimensions for CLS-safe markdown images - Structured data — Site-wide
Person+WebSiteJSON-LD; per-postBlogPosting+BreadcrumbList - Adaptive Favicon — SVG favicon with dark/light mode support, multi-size ICO, apple-touch-icon, PWA manifest icons
- Full SEO — Per-route
<title>,og:*,twitter:*meta tags, canonical URLs - Accessibility — Skip-to-content + skip-to-nav links, WCAG AA focus styles, ARIA attributes on all interactive elements, focus trap on the mobile menu
- Cmd+K command palette — Site-wide search over routes + blog posts, no third-party deps
- Incremental hydration — Below-fold blocks (hero "latest posts") deferred via
@defer (hydrate on viewport)to slim TBT - Service Worker —
@angular/service-workerprecaches the app shell and lazy-caches blog posts + images for offline reading - Web Vitals telemetry —
web-vitalslib, lazy-loaded; logs CLS / INP / LCP / FCP / TTFB to console in dev and beacons to a self-hosted endpoint in prod when configured - Modern CSS — OKLCH accent palette, container queries on the content shell, scroll-driven reading-progress animation (zero-JS where supported)
- Print stylesheet — Resume / blog post print-friendly view (chrome hidden, black-on-white, prose expanded)
- Security Headers — CSP (script-src hashed at build time, no
'unsafe-inline'for scripts), HSTS preload, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy - Comprehensive Test Suite — 900+ Vitest specs (with
posts.jsonand nav-routes contract tests), Playwright a11y + behaviour suite, opt-in visual regression, Lighthouse CI in GitHub Actions - Automated CI/CD — single-check CI gate, CodeQL SAST, Release Please semver, multi-arch Docker publish, GitOps PR to Homelab, Trivy gate plus weekly drift re-scan, scheduled smoke probe, weekly GHCR retention
git clone https://github.com/faisalkhan91/Fluxus.git
cd Fluxus
npm install
ng serve --port 4300Open http://localhost:4300/ in your browser.
- Node.js 24 (LTS) —
package.jsondeclaresengines.node: ">=24.15.0 <25" - npm
- Angular CLI 22+ (
npm install -g @angular/cli@latest) - Docker (for containerized builds)
npm run build:prodThis runs the full build pipeline in order:
node scripts/sync-reading-times.mjs— recomputesreadingTimeon everyposts.jsonentry from the matching markdown body so manifest and prose never disagree.node scripts/build-image-dims.mjs— sweepssrc/assets/images/withsharpand writes intrinsic width/height for every image intosrc/app/core/services/image-dims.generated.ts. The MarkdownService renderer reads this map so blog<img>tags ship withwidth/height/loading="lazy"/decoding="async"(no CLS).node scripts/build-image-variants.mjs— generates responsive WebP width-variants for the raster images (project screenshots + blog covers/figures) and writessrc/app/core/image/image-variants.generated.ts, which the runtimeIMAGE_LOADERconsumes soNgOptimizedImageemits a real width-descriptorsrcset.node scripts/build-version-stamp.mjs— writes the build's commit SHA + timestamp intoapp-version.generated.tsso the sidebar footer can surface it.ng build --configuration production— Angular SSG build with the service worker (ngsw-config.json) included. Prerenders every known/projects/:slug,/projects/tag/:tag,/blog/:slug, and/blog/tag/:tag.node scripts/inject-meta.mjs— rewrites per-route<title>, description, canonical, and Open Graph / Twitter tags. Blog posts get JSON-LDBlogPosting+BreadcrumbList; project-tag archives getCollectionPage+BreadcrumbList; project detail pages get per-project<head>tags.node scripts/build-sitemap.mjs— regeneratesdist/fluxus/browser/sitemap.xmlfromposts.json+ the static route list + the generated project list (no manual sitemap maintenance).node scripts/build-feed.mjs— emitsdist/fluxus/browser/feed.xml(Atom 1.0).node scripts/build-og-cards.mjs— renders a 1200×630 PNG OG card per blog post that lacks an explicitcoverfield.node scripts/build-csp.mjs— hashes every inline<script>in the prerendered HTML and writesdist/fluxus/security-headers.conf. The Docker image consumes that file so the production CSP can stay strict (no'unsafe-inline'for scripts).node scripts/audit-csp.mjs— walks the prerendered tree and fails the build if any inline script / event handler lacks a matching CSP hash.node scripts/audit-prerender.mjs— structural audit of the prerendered tree; fails on SSR regressions (empty<h1>s, missing/mismatched canonical + OG/Twitter meta,<svg>withoutaria-hidden, missing pre-paint theme script, invalid tab buttons) and sweeps every page for dead internal links / broken image assets.node scripts/audit-structured-data.mjs— structured-data + social-metadata regression check against the realdistoutput: JSON-LD parses and carries@context/@type, everyog:imagepage also hasog:image:alt, andfeed.xmlis well-formed Atom (RFC 4287).
Build output goes to dist/fluxus/browser/ — one directory per prerendered route (index.html inside each), plus feed.xml, sitemap.xml, og/<slug>.png, and the SW manifests. A representative recent run emitted 117 prerendered routes: 8 top-level + 13 blog posts + 23 blog-tag archives + 64 project-tag archives + 9 project detail pages. The counts move with the repo's topic tags and blog manifest; the pipeline enumerates them automatically.
Project metadata is fetched out of band. projects.generated.{ts,json} + scripts/cache/projects-github.json are committed artefacts consumed directly by the build. The actual GitHub fetch (scripts/fetch-projects-github.mjs, via Octokit GraphQL — one request for every repo in scope) runs on a scheduled refresh-projects.yml workflow (daily cron + workflow_dispatch) and opens a PR with any updated files. That keeps every PR build deterministic, network-free, and immune to GitHub API flake. Locally, npm run fetch:projects runs the same script — set GITHUB_TOKEN in your env for a fresh fetch; unset falls back to the committed cache.
docker build -t fluxus .docker run --rm -p 8080:8080 fluxusOpen http://localhost:8080/ in your browser.
The container runs as non-root (UID 101) on port 8080, ready for Kubernetes deployment with readOnlyRootFilesystem: true and all capabilities dropped.
The image includes a HEALTHCHECK that polls /healthz every 30 seconds, so Docker reports container health without relying on an external orchestrator.
Multi-architecture builds (linux/amd64, linux/arm64) are produced automatically on every tagged release.
Unit tests use Vitest with jsdom:
npm test # watch mode
npm test -- --watch=false # single run (CI)A handful of project-specific contracts that aren't obvious from the file tree:
Single source of truth for site identity. Three fields drive five build scripts (inject-meta, build-sitemap, build-feed, build-og-cards, audit-prerender):
siteUrl— production origin, used for canonicals, OG URLs, sitemap entries, atom feed self-links.siteName— page title suffix, OG site name, JSON-LD publisher.twitterHandle— optional, emitted astwitter:site/twitter:creatorwhen set.
A fork updates this file once and the script suite re-targets automatically.
Three files have to change in lockstep, or a fresh build will fail the CSP audit:
src/app/core/services/theme.registry.ts— append to theThemeIdunion and theTHEME_REGISTRYarray.src/styles.css— append a[data-theme='<id>']token block mirroring[data-theme='crimson-light'].src/index.html— append the same id to the inline pre-paint script'sTHEME_IDSallowlist.
After all three, run npm run build:prod so audit-csp.mjs regenerates the script-src hash for the updated pre-paint script. The contract is enforced by theme.registry.spec.ts — drift fails unit tests before it reaches CSP.
tsconfig.json has "verbatimModuleSyntax": true. Pure-interface imports must use import type { X } (or import { type X, y } for mixed). Violating produces error TS1484 at compile time. Apply this to every Project, BlogPost, Skill, etc. import — the compiler enforces it.
- Top-level chrome (sidebar, body, hero) uses
100dvhwith a100vhfallback so the iOS Safari viewport tracks dynamic chrome instead of leaving an ~80 px phantom scroll zone. viewport-fit=coveris set soenv(safe-area-inset-*)reports non-zero on iPhone with a home indicator. The mobile nav pill, toast region, and reading-progress bar all consume the relevant inset.- Mobile menu modal applies
inerton the rest of the shell so VoiceOver / keyboard can't escape the dialog.
Six layered checks guard the build. The CI workflow runs lint, typecheck, audit, unit tests, build, Playwright, and Lighthouse on every PR; the CSP, prerender, and structured-data audits run inside build:prod, so they execute in CI on every PR as well as locally.
| Gate | Command | What it catches |
|---|---|---|
| Unit tests | npm test -- --watch=false |
900+ Vitest specs (components, services, blog content + nav-routes contract tests) |
| Static analysis | npm run lint · npm run typecheck |
ESLint, Angular template rules, strict TypeScript |
| Prerender audit (in build:prod) | runs automatically in npm run build:prod (also npm run audit:prerender standalone) |
SSR regressions: empty <h1>s, missing OG/canonical/twitter meta, broken tab buttons, FOUC script |
| Live a11y / behaviour | npm run e2e (after npm run build:prod) |
axe (WCAG AA), focus trap, theme pre-paint, View Transitions, prefers-reduced-motion |
| Visual regression (opt-in) | npm run e2e:visual (after npm run build:prod) |
Per-route × theme × viewport screenshot baselines under tests/e2e/visual.spec.ts-snapshots/ |
| Lighthouse CI | lighthouserc.json runs in GitHub Actions on every PR |
Performance / a11y / best-practices / SEO category thresholds + LCP / CLS / TBT budgets |
The e2e pass uses Playwright + @axe-core/playwright. On a fresh checkout, install the chromium browser binary once:
npm run e2e:installThen build the site and run the pass:
npm run build:prod
npm run e2e # headless behaviour suite (default)
npm run e2e:ui # interactive UI mode for debugging
npm run e2e:visual # opt-in visual regression run
npm run e2e:visual:update # refresh visual baselines after intended changesTests live in tests/e2e/ and run against the prerendered output served via http-server on :4300.
Spot-check what's in the JS chunks (mostly used to catch regressions like a heavy lib leaking into the home route):
npm run analyzeThat builds with source maps and opens source-map-explorer on the main + chunk bundles in your default browser.
| Workflow | Trigger | What it does |
|---|---|---|
| CI | PR + push to main |
Lint, typecheck, audit, unit tests + coverage, production build, Playwright, Lighthouse, plus a CI success fan-in check |
| CodeQL | PR + push to main, weekly schedule |
SAST on app code (javascript-typescript) and workflow YAML (actions) |
| Release Please | Push to main |
Opens / updates a release PR; only feat: / fix: / breaking changes bump the version |
| Publish image | Tag v*, push to main touching src/assets/blog/**, or workflow_dispatch |
Multi-arch Docker build, single-pass Trivy gate, GitOps PR to Homelab (rolling per-lineage branches). Three shapes: tag pushes ship versioned releases; blog pushes ship content-<sha> images without a SemVer bump; scheduled publishes (dispatched by scheduled-publish.yml) ship scheduled-<yyyymmdd> images |
| Trivy re-scan | Weekly schedule | Scans the published latest image without .trivyignore to surface CVE drift |
| Smoke | Manual + daily schedule | Polls /healthz on the deployed site, then probes the live production CSP (Playwright/Chromium) |
| GHCR retention | Weekly schedule + manual | Prunes old content-<sha> and scheduled-<yyyymmdd> images (keeps the 10 most recent in each rolling lineage); untagged versions are deliberately left intact (they back the live multi-arch manifests / SBOM / provenance / attestation); release tags (vX.Y.Z, X.Y, latest) are kept forever |
| Commit type | Release Please | Publish image | Result |
|---|---|---|---|
feat: ... / fix: ... |
Opens release PR | Runs on the vX.Y.Z tag after merge |
New SemVer release, GitOps PR labelled release |
docs(blog): ... |
Silent | Runs on the main push if blog files changed | New content-<sha> image, GitOps PR labelled content |
chore: / refactor: |
Silent (changelog only) | No | No deploy until the next feat: / fix: |
Image tags in GHCR:
vX.Y.Z,X.Y,latest— produced by release publishes (kept forever)content-<short-sha>— produced by blog content publishes (auto-pruned by the GHCR retention workflow)scheduled-<yyyymmdd>— produced by scheduled blog publishes (auto-pruned by the GHCR retention workflow)
Dependabot is configured for weekly updates across npm, Docker, and GitHub Actions dependencies, with grouping for Angular and ESLint packages.
All base images in the Dockerfile are pinned by SHA256 digest for reproducible builds. The CI workflows pin every action by commit SHA.
Project Structure
src/
├── app/
│ ├── core/
│ │ ├── services/ # BlogService, SeoService, ThemeService, etc.
│ │ └── shell/ # Main layout (sidebar + tab bar + content + mobile nav)
│ ├── features/
│ │ ├── hero/ # Landing page
│ │ ├── about/ # About section
│ │ ├── experience/ # Work timeline
│ │ ├── skills/ # Technical skills grid
│ │ ├── projects/ # Project showcase
│ │ ├── certifications/ # Certification cards
│ │ ├── contact/ # Contact form
│ │ ├── blog/ # Blog index + post viewer
│ │ └── not-found/ # Glitch terminal 404 page
│ ├── ui/ # Reusable components (glass-card, glow-button, sidebar, etc.)
│ └── shared/ # Shared utilities
├── assets/
│ ├── blog/
│ │ ├── posts.json # Blog post manifest
│ │ └── posts/ # Markdown blog posts
│ ├── icons/ # PWA icons, skill icons
│ └── images/ # Profile, portfolio, certification images
├── styles.css # Global design tokens
└── index.html # App shell with meta tags, font loading, favicon links
Routes
| Route | Component | SSG |
|---|---|---|
/ |
HeroComponent | Prerendered |
/about |
AboutComponent | Prerendered |
/experience |
ExperienceComponent | Prerendered |
/skills |
SkillsComponent | Prerendered |
/projects |
ProjectsComponent | Prerendered |
/projects/:slug |
ProjectDetailComponent | Prerendered (per project) |
/projects/tag/:tag |
ProjectsTagComponent | Prerendered (per tag) |
/certifications |
CertificationsComponent | Prerendered |
/contact |
ContactComponent | Prerendered |
/blog |
BlogComponent | Prerendered |
/blog/:slug |
BlogPostComponent | Prerendered (dynamic) |
/blog/tag/:tag |
BlogTagComponent | Prerendered (per tag) |
** |
NotFoundComponent | Client-side (CSR fallback) |
Blog Authoring
Posts are Markdown files in src/assets/blog/posts/. Metadata lives in src/assets/blog/posts.json:
{
"slug": "my-post",
"title": "My Post Title",
"date": "2026-04-01",
"excerpt": "A short description.",
"tags": ["angular", "typescript"],
"readingTime": "5 min"
}The post-build script scripts/inject-meta.mjs injects per-route and post-specific OG/Twitter meta into each prerendered HTML file.
Image assets dropped into src/assets/images/blog/<post>/ can be optimized in-place to WebP via:
npm run optimize:images # write .webp siblings, keep originals
npm run optimize:images -- --replace # replace .jpg/.png originals with .webpThe script is idempotent — re-running skips already-optimized files. Remember to update markdown image extensions to .webp after using --replace.
Project Showcase Authoring
/projects is GitHub-driven. To add a project:
1. Tag the repo on github.com. Open the repo page, click the ⚙ on the "About" sidebar, and add portfolio to the Topics field. That's the one mandatory step — a user-scoped topic search (user:<you> topic:portfolio fork:false archived:false) is run out of band by scripts/fetch-projects-github.mjs on the scheduled refresh-projects.yml workflow.
2. (Optional) Override editorial fields locally. src/app/core/data/projects.overrides.json is the curation layer — each repos[] entry is keyed by owner/name and can override any of:
{
"repo": "faisalkhan91/Bookstore",
"title": "Bookstore", // defaults to repo name
"description": "Hand-written blurb.", // falls back to README excerpt, then repo.description
"image": "assets/images/portfolio/BookStore.png",
"tags": ["Angular", "TypeScript"], // merged with repo topics
"featured": true, // glow on the card + homepage strip
"order": 1 // display order; unset entries sort by pushedAt desc
}Drop the screenshot under src/assets/images/portfolio/. Overrides are union'd with the topic search — a repo that's both topic-tagged and in repos[] gets both the selection AND the overrides.
3. Refresh the cache. Run npm run fetch:projects locally to hit the GitHub API and write:
src/app/core/data/projects.generated.ts— the fullProject[]consumed byProjectsDataService.src/app/core/data/projects.generated.json— a JSON twin for.mjsscripts (sitemap, inject-meta).scripts/cache/projects-github.json— normalised raw GitHub payload; commit this so offline / rate-limited builds keep working.
The fetch runs out of band in refresh-projects.yml (daily cron at 06:00 UTC + workflow_dispatch) with secrets.GITHUB_TOKEN wired in (5000 req/hr), committing the regenerated data via a PR; build:prod itself is network-free and consumes the committed projects.generated.{ts,json} + cache. Locally the script works unauthenticated (60 req/hr) and falls back to the committed cache if the network is unreachable.
4. Non-GitHub entries. projects.overrides.json's manual[] array accepts fully hand-authored entries (company work, talks, gists). Same rendering path as topic-sourced repos; no github block means no meta pills.
Removing a project: untag it on github.com and drop its repos[] entry (if any). Next build, it's gone — no route prerenders, sitemap drops it, detail page 404s.
Faisal Khan
- GitHub: @faisalkhan91
- LinkedIn: Faisal Khan
MIT — see LICENSE for details.
See CHANGELOG.md for a detailed history of releases.