A fast, static portfolio site for AI/ML & MLOps work. Built with plain HTML/CSS/JS, deployed as a Static Site on Render with pretty URLs, SEO basics, and a no-backend contact form via Formspree. Helped to setup your own website? You can buy me a coffee to support my work ☕️.
- © 2025–2026 Mukharbek Organokov
- 🌐 Website: www.organokov.com
- 📜 License: GNU General Public License v3.0
Deeper developer docs live in docs/:
- docs/ARCHITECTURE.md — how the site works: the dual-page model, the homepage aggregate loader, and the two-axis theming system.
- docs/CONTRIBUTING.md — local dev, the branch/PR workflow, and step-by-step guides (add a page, blog post, or palette).
├── README.md # Project overview, setup, deploy, and domain setup
├── .github/workflows/ # GitHub Actions CI
│ └── release.yml # Creates a GitHub Release + zip on every v*.*.* tag
├── docs/ # Developer documentation
│ ├── README.md # Docs index
│ ├── ARCHITECTURE.md # How the site works (dual-page model, loader, theming)
│ └── CONTRIBUTING.md # Local dev, PR workflow, how-to guides, checklist
├── package-lock.json # Exact, locked dependency versions for reproducible installs
├── package.json # Project metadata + dev scripts (e.g., "dev" with `serve`)
├── public/ # All files served to the browser (site root)
│ ├── assets/ # Images, icons, downloadable files
│ │ ├── cv.pdf # Downloadable CV
│ │ ├── favicon.svg # Site favicon
│ │ ├── og-card.png # Open Graph / social share card (1200×630; referenced by every page)
│ │ ├── og-card.svg # Older vector version of the share card (not referenced)
│ │ ├── logos/ # Company and university logos, 192px copies used on Experience/Education
│ │ └── projects/ # Project logos used on the Projects page
│ ├── about.html # About page (also reachable at /about via Render rewrites)
│ ├── experience.html # Experience/Resume page
│ ├── education.html # Education page
│ ├── projects.html # Projects page: logo, summary, links (and DOI where one exists)
│ ├── achievements.html # Achievements: headline figures, patents, publications, Kaggle results
│ ├── blog.html # Blog landing page: cards that link to the post pages
│ ├── blog/ # One page per post (/blog/<slug>), with newer/older links
│ ├── contact.html # Contact page (Formspree form lives here)
│ ├── privacy.html # Privacy notice (linked from the contact form)
│ ├── index.html # Current homepage (also used as “shell” for aggregate loader)
│ ├── 404.html # Custom not-found page (shown on broken routes)
│ ├── robots.txt # Crawler rules; points to sitemap.xml
│ ├── sitemap.xml # SEO sitemap of canonical “pretty” URLs
│ └── src/ # Versioned static assets (cache-busted by path)
│ ├── css/ # Stylesheets
│ │ ├── style.css # Site-wide layout + component styles (imports theme.css)
│ │ └── theme.css # Colour palettes (single source of truth for colours)
│ └── js/ # JavaScript for UX and dynamic assembly
│ ├── aggregate.js # Fetches each page, extracts main sections, and mounts them
│ ├── contact-form.js # Formspree submit handler (loaded by contact.html and the homepage)
│ ├── main.js # Global UI on standalone pages (nav, theme, animations)
│ └── theme-config.js # Theme behaviour: default palette, toggle on/off, palette list
├── assets-src/logos/ # Full-size logo originals (not deployed; make 192px copies for public/)
├── render.yml # Render Static Site blueprint (routes/rewrites + headers/caching)
└── serve.json # Local dev config for `npx serve` (headers/spa/caching; optional)
- Static, zero-backend (served from
/public) - Pretty URLs (
/about,/experience,/blogetc.) via Render rewrites - Dynamic “aggregate” loader that assembles sections into a single page
- Dark mode with system-preference detection and a navbar toggle (persisted to
localStorage, applied before first paint to avoid a flash) - Config-driven colour palettes — switch between Castleton Green and Indigo from the navbar; palettes and behaviour are defined in
theme.css+theme-config.js - Contact form wired to Formspree (no server code by decision)
- Resilient UX: custom
404.html, smooth scrolling, mobile nav - Accessibility: semantic landmarks,
aria-labels on controls, keyboard-friendly nav - SEO essentials:
sitemap.xml,robots.txt, canonical tags, Open Graph/Twitter cards - Security & perf headers via
render.yml(HSTS, caching)
Requirements: Node 18+ (any static server works)
# install deps (if needed)
npm ci
# serve /public locally at http://localhost:3000
npx serve public -l 3000Any static server is fine (python -m http.server, VS Code Live Server, etc.).
The site is entirely static — no build step required.
Used Formspree for simplicity - no server code.
- Obtain your own
https://formspree.io/f/YOUR-KEYfrom Formspree; - Target Email: set in the form’s Settings → Email Notifications;
- (Optional) Project → Settings → Restrict to Domain:
organokov.com(set your domain); - DevTools → Network to verify:
POST https://formspree.io/f/YOUR-KEYreturns200 {"ok":true}; - Check Submissions if emails don’t arrive; also inspect spam/junk.
Form action in public/contact.html:
<form id="contactForm" action="https://formspree.io/f/YOUR-KEY" method="POST">
<!-- name / email / message fields ... -->
</form>
<p id="formStatus" aria-live="polite"></p>Delegated JS handler is bound globally in public/src/js/main.js, so it works even when the form is injected by the section loader:
if (!window.__contactDelegatedBound) {
window.__contactDelegatedBound = true;
document.addEventListener('submit', async (e) => {
const form = e.target;
if (!form.matches('#contactForm')) return;
e.preventDefault();
const statusEl = document.getElementById('formStatus');
if (statusEl) statusEl.textContent = 'Sending...';
try {
const res = await fetch(form.action, {
method: form.method || 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
});
const txt = await res.text();
console.log('Formspree status:', res.status, txt);
if (res.ok) {
form.reset();
statusEl && (statusEl.textContent = 'Thanks! I’ll get back to you shortly.');
} else {
let msg = txt; try { msg = JSON.parse(txt)?.errors?.[0]?.message || txt; } catch {}
statusEl && (statusEl.textContent = 'Error: ' + msg);
}
} catch (err) {
console.error(err);
statusEl && (statusEl.textContent = 'Network error. Please try again.');
}
});
}Pages live as separate HTML files:
about.html,experience.html,education.html,projects.html,achievements.htmlblog.htmland one page per post inblog/(served at/blog/<slug>)contact.html,privacy.html
but are served at clean paths (/about, /blog/<slug>, etc.) using Render rewrites (see render.yml).
Every new page needs its own rewrite and, if public, a sitemap.xml entry.
Use root-relative links in HTML:
<a href="/about">About</a>
<a href="/experience">Experience</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>Two independent axes control appearance, both set on the root <html> element before first paint (an inline IIFE + theme-config.js in every <head>), so there's no colour flash on load:
- Light / dark →
data-theme="dark"(toggled by the 🌙 button; defaults to the OS preference) - Colour palette →
data-palette="…"(toggled by the navbar colour swatch)
Colours and behaviour live in two files:
| File | Responsibility |
|---|---|
public/src/css/theme.css |
All colour values — one CSS block per palette (:root = Indigo, [data-palette="alt"] = Castleton Green, plus a few commented-out experimental greens). style.css imports it first. |
public/src/js/theme-config.js |
All colour behaviour — which palettes exist, the default, and whether the switch is shown. |
Edit the window.SITE_THEME object in theme-config.js:
window.SITE_THEME = {
palettes: [
{ key: 'green', label: 'Castleton Green', attr: 'alt' },
{ key: 'indigo', label: 'Indigo', attr: null }, // null = :root default
],
defaultPalette: 'green', // shown to first-time visitors
enablePaletteToggle: true, // false hides the navbar swatch entirely
};To add a palette: copy a block in theme.css (e.g. [data-palette="alt"]), rename the selector, tweak the tokens, then add a matching { key, label, attr } entry to the list above. To lock the site to one colour, set enablePaletteToggle: false.
Deployed on Render (Static Site) using render.yml. Render is configured
to watch the main branch — every merge to main triggers an automatic deploy
with no manual steps needed.
Use npm run dev for local development. Be aware that start is only used if you run a Node server; Render static sites don't need it but it’s handy for testing.
{
"scripts": {
"dev": "npx serve public -l 3000",
"start": "npx serve -s public -l $PORT"
}
}Deployment file render.yml sets security + caching headers:
Security:
X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originStrict-Transport-Security: max-age=31536000; includeSubDomains
Caching:
- Short cache for
*.html - Long cache for
/src/**and/assets/**
Custom Domain:
- For example,
www.organokov.com→ CNAME to your Render subdomain organokov.com→ A →YOUR_IP(or ALIAS/ANAME to the Render subdomain)- TLS certificates are issued automatically by Render.
Basic SEO setup, please extend by your wish.
- Each page includes a
<link rel="canonical" …>tag (e.g.,/about). public/sitemap.xmllists canonical, pretty URLs.robots.txtpoints to the sitemap.404.htmlshould include<meta name="robots" content="noindex">.