diff --git a/favicon.webp b/favicon.webp index fffe226..f7c2732 100644 Binary files a/favicon.webp and b/favicon.webp differ diff --git a/index-min.html b/index-min.html deleted file mode 100644 index bc4ae6f..0000000 --- a/index-min.html +++ /dev/null @@ -1 +0,0 @@ -Portfolio Florian Sylvain - Développeur web fullstack

Compétences et qualifications

Diplômes obtenus

PériodeÉtablissementNiveauDiplôme
2022 - 2024ESD ParisBac +3 / RNCP 6Bachelor Développeur Web
2020 - 2022Université Gustave EiffelBac +2DUT Informatique
2017 - 2020Lycée Polyvalent René CassinBacBaccalauréat STI2D (mention bien)

Expériences professionnelles

CDI Développeur Web Fullstack
EntrepriseDollen
PériodeNov. 2024 - En cours
Missions
  • Conception logiciel (diagramme de classes UML)
  • Développement d'un back office
    • PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, Domain Driven Design
  • Déploiement et intégration continue du back-office en production et pré-production
    • Docker, Debian, Nginx, GitHub Action
  • Migration de base de données
    • SQLite, PostgreSQL, MariaDB
  • Peer reviewing
  • Gestion de projet Agile
    • Trello, GitHub Projects, daily sprints
Apprenti Développeur Fullstack
EntreprisePrésent
PériodeAvr. 2023 - Sep. 2024 (1 an et 5 mois)
Missions
  • Développement d'une
    • application mobile interne
      • Kotlin
    • application mobile B2C
      • Flutter
    • API REST monolithique
      • Typescript ,Fastify ,Prisma, Jest, MySql, Docker, GCP, Domain Driven Design
    • SPA pour Desktop et Mobile
      • React, Typescript, Zustand, Chakra UI
Apprenti Développeur R&D
EntrepriseWATT Earth
PériodeOct. 2021 - Sept. 2022 (1 an)
Missions
  • Conception et développement de fonctionnalités innovantes au cœur des produits WATT Earth :
    • VueJS, Typescript, PHP, Python, MySql, Warp10, Grafana
  • Maquettage et conception d'interfaces utilisateurs (Figma)
  • Réalisation de spécifications et de cahiers de recettes
  • Intégration de technologies dans la solution de l'entreprise, correspondant aux besoins client :
    • Modbus, LoRaWAN, Zigbee, ...

Projets récents

GoCMS
DescriptionGoCMS est un projet de système de gestion de contenu développé en Go. Le but à terme de ce projet en cours de developpement est de me faciliter la création de site web qui nécessitent des fonctionnalités comme celles d'un Wordpress, notamment un système de blog et d'articles, mais sans avoir recours à Wordpress ou équivalent. L'architecture du projet est en DDD, Domain Driven Design, et le front de l'interface d'administration est généré en SSR via le moteur de templating html de Go. J'utilise également Bootstrap pour avoir de belles interface sans devoir fournir trop d'effort de développement front. J'héberge une démo du projet en CI/CD via Jenkins sur mon serveur dédié Debian, contenerisé via Docker et exposé via Nginx.
StackGo, HTML, CSS, JS, Bootstrap, Docker, Nginx, DDD, Jenkins, Debian, SQLite, Git
DépôtGoCMS
SnippetsManager
DescriptionProjet réalisé dans le cadre d'un cours de Framework Backend à l'ESD, inspiré de l'outil open-source massCode dont le principe est de gérer des snippets (bouts de code). Il s'agit d'une API conçu en Typescript, Express, Prisma, MySql, Docker et Jest.
StackTypescript, Express, Prisma, MySql, Docker, Jest, NodeJS, Git, TSX
DépôtSnippetsManager
spotify-playlist-embed
DescriptionPetit projet réalisé dans le cadre d'un cours de Framework Frontend à l'ESD, il s'agit d'un lecteur de playlist spotify réalisé en NextJS et Typescript, avec la spotify-web-apinode ainsi que Tailwindcss.
StackNextJS, Typescript, Tailwindcss, Spotify API, TSX, Git, Vercel
Dépôtspotify-playlist-embed

Contact

Vous pouvez me contacter via les réseaux sociaux suivants :

Autre moyens de contact indisponible sur ce portfolio pour éviter les crawlers malveillants et autre récolte de données, qui résulteront en spam et démarchage incéssants.

\ No newline at end of file diff --git a/index.html b/index.html index c9d88ee..fe0765c 100644 --- a/index.html +++ b/index.html @@ -2,534 +2,243 @@ - - - - - - Portfolio Florian Sylvain - Développeur web fullstack + + + + + + + Portfolio Florian Sylvain - Développeur web fullstack - + + - - + + - +
+ -
-
-

Compétences et qualifications

-
    -
  • Rédaction de spécifications et cahier de recettes.
  • -
  • Maîtrise de PhP, Java, Go, Html5/Css3, Javascript, Typescript, SQL, VueJS, React, Flutter, Docker, - Jenkins, Git, GCP, et de Nginx. Apprendre un nouveau language ou framework n'est absolument pas un - obstacle pour moi.
  • -
  • Maîtrise de l'anglais niveau C1
  • -
  • Maîtrise de la méthodologie agile, avec sprints, daily...
  • -
-
- -
-

Diplômes obtenus

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PériodeÉtablissementNiveauDiplôme
2022 - 2024ESD ParisBac +3 / RNCP 6Bachelor Développeur Web
2020 - 2022Université Gustave EiffelBac +2DUT Informatique
2017 - 2020Lycée Polyvalent René CassinBacBaccalauréat STI2D (mention bien)
-
- -
-

Expériences professionnelles

- - - - - - - - - - - - - - - - - - - - - - - - - -
CDI Développeur Web Fullstack
EntrepriseDollen
PériodeNov. 2024 - En cours
Missions -
    -
  • Conception logiciel (diagramme de classes UML)
  • -
  • - Développement d'un back office -
      -
    • PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, Domain Driven - Design
    • -
    -
  • -
  • - Déploiement et intégration continue du back-office en production et pré-production -
      -
    • Docker, Debian, Nginx, GitHub Action -
    -
  • -
  • - Migration de base de données -
      -
    • SQLite, PostgreSQL, MariaDB
    • -
    -
  • -
  • Peer reviewing
  • -
  • - Gestion de projet Agile -
      -
    • Trello, GitHub Projects, daily sprints
    • -
    -
  • -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Apprenti Développeur Fullstack
EntreprisePrésent
PériodeAvr. 2023 - Sep. 2024 (1 an et 5 mois)
Missions -
    -
  • - Développement d'une -
      -
    • application mobile interne -
        -
      • Kotlin
      • -
      -
    • -
    • application mobile B2C -
        -
      • Flutter
      • -
      -
    • -
    • API REST monolithique -
        -
      • Typescript ,Fastify ,Prisma, Jest, MySql, Docker, GCP, Domain Driven - Design
      • -
      -
    • -
    • SPA pour Desktop et Mobile -
        -
      • React, Typescript, Zustand, Chakra UI
      • -
      -
    • -
    -
  • -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Apprenti Développeur R&D
EntrepriseWATT Earth
PériodeOct. 2021 - Sept. 2022 (1 an)
Missions -
    -
  • - Conception et développement de fonctionnalités innovantes au cœur des produits WATT - Earth : -
      -
    • VueJS, Typescript, PHP, Python, MySql, Warp10, Grafana
    • -
    -
  • -
  • Maquettage et conception d'interfaces utilisateurs (Figma)
  • -
  • Réalisation de spécifications et de cahiers de recettes
  • -
  • - Intégration de technologies dans la solution de l'entreprise, correspondant aux - besoins client : -
      -
    • Modbus, LoRaWAN, Zigbee, ...
    • -
    -
  • -
-
-
- -
-

Projets récents

- - - - - - - - - - - - - - - - - - - - - - - - -
GoCMS
DescriptionGoCMS est un projet de système de gestion de contenu développé en Go. Le but à terme de ce - projet en cours de developpement est de me faciliter la création de site web qui nécessitent - des fonctionnalités comme celles d'un Wordpress, notamment un système de blog et d'articles, - mais sans avoir recours à Wordpress ou équivalent. L'architecture du projet est en DDD, - Domain Driven Design, et le front de l'interface d'administration est généré en SSR via le - moteur de templating html de Go. J'utilise également Bootstrap pour avoir de belles - interface sans devoir fournir trop d'effort de développement front. J'héberge une démo du - projet en CI/CD via Jenkins sur mon serveur dédié Debian, contenerisé via Docker et exposé - via Nginx.
StackGo, HTML, CSS, JS, Bootstrap, Docker, Nginx, DDD, Jenkins, Debian, SQLite, Git
DépôtGoCMS
- - - - - - - - - - - - - - - - - - - - - - - - - -
SnippetsManager
DescriptionProjet réalisé dans le cadre d'un cours de Framework Backend à l'ESD, inspiré de l'outil - open-source massCode dont le principe est de gérer des snippets (bouts de code). Il s'agit - d'une API conçu en Typescript, Express, Prisma, MySql, Docker et Jest.
StackTypescript, Express, Prisma, MySql, Docker, Jest, NodeJS, Git, TSX
DépôtSnippetsManager
- - - - - - - - - - - - - - - - - - - - - - - - - -
spotify-playlist-embed
DescriptionPetit projet réalisé dans le cadre d'un cours de Framework Frontend à l'ESD, il s'agit d'un - lecteur de playlist spotify réalisé en NextJS et Typescript, avec la spotify-web-apinode - ainsi que Tailwindcss.
StackNextJS, Typescript, Tailwindcss, Spotify API, TSX, Git, Vercel
Dépôtspotify-playlist-embed -
-
- -
-

Contact

-

Vous pouvez me contacter via les réseaux sociaux suivants :

- -

Autre moyens de contact indisponible sur ce portfolio pour éviter les crawlers malveillants et autre - récolte de données, qui résulteront en spam et démarchage incéssants.

-
-
- - +
+ Voir mes projets + Me contacter +
+ + + +
+

Compétences et qualifications

+

J'aime les outils simples et le code clair.

+
+
+

Backend

+
+ PHPSymfonyDoctrine + NodeFastifyPrisma + JestGo + JavaC#C++ +
+
+
+

Frontend

+
+ Vue + Nuxt + React + Next + TypeScript + HTML + CSS + Tailwind +
+
+
+

Mobile

+
+ Flutter + Kotlin + React Native +
+
+
+

Base de données

+
+ MySQL + PostgreSQL + SQLite + Supabase + Firebase +
+
+
+

DevOps

+
+ Docker + Nginx + Debian + CI/CD + GitHub Actions + Jenkins + Dockploy + Coolify + GCP + AWS +
+
+
+

Pratiques

+
DDDTestsAgile + Anglais C1 +
+
+
+
+ +
+

Diplômes obtenus

+
+
+
Bachelor Développeur Web — ESD Paris
+
2022 - 2024 • Bac +3 / RNCP 6
+
+
+
DUT Informatique — Université Gustave Eiffel
+
2020 - 2022 • Bac +2
+
+
+
Baccalauréat STI2D (mention bien) Lycée René Cassin
+
2017 - 2020
+
+
+
+ +
+

Expériences professionnelles

+
+
+
CDI — Développeur Web Fullstack
+
Entreprise : Dollen • Période : Nov. 2024 - En cours
+
    +
  • Conception logiciel (diagrammes de classes UML).
  • +
  • Back-office: PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, DDD.
  • +
  • CI/CD et déploiement: Docker, Debian, Nginx, GitHub Actions.
  • +
  • Migrations: SQLite, PostgreSQL, MariaDB.
  • +
  • Peer review et gestion agile (Trello, GitHub Projects).
  • +
+
+ +
+
Apprenti — Développeur Fullstack
+
Entreprise : Présent • Avr. 2023 - Sep. 2024 +
+
    +
  • Apps mobiles: Kotlin (interne), Flutter (B2C).
  • +
  • API REST monolithique: TypeScript, Fastify, Prisma, Jest, MySQL, Docker, GCP, DDD.
  • +
  • SPA desktop/mobile: React, TypeScript, Zustand, Chakra UI.
  • +
+
+ +
+
Apprenti — Développeur R&D
+
Entreprise : WATT Earth • Oct. 2021 - Sept. 2022
+
    +
  • Fonctionnalités cœur produit: VueJS, TS, PHP, Python, MySQL, Warp10, Grafana.
  • +
  • Maquettage et conception UI (Figma).
  • +
  • Spécifications et cahiers de recettes.
  • +
  • Intégration technologies: Modbus, LoRaWAN, Zigbee…
  • +
+
+
+
+ +
+

Projets récents

+ +
+ +
+

Contact

+

Échangeons sur vos besoins, missions ou opportunités.

+

LinkedIn

+

Autres moyens de contact non listés pour éviter la collecte automatisée.

+
+
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..01e516d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "compact-portfolio-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "compact-portfolio-server", + "version": "1.0.0", + "engines": { + "node": ">=16" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ba4741 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "compact-portfolio-server", + "version": "1.0.0", + "private": true, + "description": "Node server to serve minified, compressed portfolio", + "main": "server.js", + "type": "commonjs", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=16" + } +} + diff --git a/profile.webp b/profile.webp deleted file mode 100644 index 4ab47c3..0000000 Binary files a/profile.webp and /dev/null differ diff --git a/script.js b/script.js new file mode 100644 index 0000000..51c3266 --- /dev/null +++ b/script.js @@ -0,0 +1,77 @@ +const prefersReduced = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +const revealables = document.querySelectorAll(".reveal"); +if (prefersReduced) { + revealables.forEach((el) => el.classList.add("revealed")); +} else { + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) + if (e.isIntersecting) { + e.target.classList.add("revealed"); + io.unobserve(e.target); + } + }, + { threshold: 0.08, rootMargin: "0px 0px -10% 0px" } + ); + revealables.forEach((el) => io.observe(el)); +} + +const navLinks = [...document.querySelectorAll(".site-nav a")]; +const sections = navLinks + .map((a) => document.querySelector(a.getAttribute("href"))) + .filter(Boolean); + +let currentSection = null; + +const spy = new IntersectionObserver( + (entries) => { + entries.forEach((e) => { + if (e.isIntersecting) { + const id = "#" + e.target.id; + + navLinks.forEach((a) => a.removeAttribute("aria-current")); + const active = navLinks.find( + (a) => a.getAttribute("href") === id + ); + if (active) active.setAttribute("aria-current", "page"); + + if (currentSection !== id) { + currentSection = id; + history.replaceState(null, null, id); + } + } + }); + }, + { rootMargin: "-40% 0% -50% 0%", threshold: 0.01 } +); +sections.forEach((s) => s && spy.observe(s)); + +const menuBtn = document.getElementById("menuToggle"); +const nav = document.getElementById("primaryNav"); +if (menuBtn && nav) { + const closeMenu = () => { + nav.classList.remove("is-open"); + menuBtn.setAttribute("aria-expanded", "false"); + }; + const openMenu = () => { + nav.classList.add("is-open"); + menuBtn.setAttribute("aria-expanded", "true"); + }; + const toggleMenu = () => { + nav.classList.contains("is-open") ? closeMenu() : openMenu(); + }; + menuBtn.addEventListener("click", toggleMenu); + nav.addEventListener("click", (e) => { + const t = e.target; + if (t && t.matches("a")) closeMenu(); + }); + document.addEventListener("click", (e) => { + if (!nav.contains(e.target) && !menuBtn.contains(e.target)) closeMenu(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeMenu(); + }); +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..96dd973 --- /dev/null +++ b/server.js @@ -0,0 +1,595 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const crypto = require("crypto"); + +const PORT = process.env.PORT || 3000; +const ROOT = __dirname; + +const cspHashes = { + scripts: new Set(), + styles: new Set(), +}; + +function calculateHash(content, algorithm = "sha256") { + return crypto + .createHash(algorithm) + .update(content, "utf8") + .digest("base64"); +} + +function extractInlineContent(html) { + const scriptMatches = + html.match(/]*src=)[^>]*>([\s\S]*?)<\/script>/gi) || []; + const styleMatches = html.match(/]*>([\s\S]*?)<\/style>/gi) || []; + + cspHashes.scripts.clear(); + cspHashes.styles.clear(); + + scriptMatches.forEach((match) => { + const content = match.replace(/]*>|<\/script>/gi, "").trim(); + if (content) { + const hash = calculateHash(content); + cspHashes.scripts.add(`'sha256-${hash}'`); + console.log(`Inline script hash: sha256-${hash}`); + } + }); + + styleMatches.forEach((match) => { + const content = match.replace(/]*>|<\/style>/gi, "").trim(); + if (content) { + const hash = calculateHash(content); + cspHashes.styles.add(`'sha256-${hash}'`); + console.log(`Inline style hash: sha256-${hash}`); + } + }); +} + +function generateCSP() { + const scriptSources = Array.from(cspHashes.scripts); + const styleSources = Array.from(cspHashes.styles); + + let scriptSrc = "'self'"; + if (scriptSources.length > 0) { + scriptSrc += ` ${scriptSources.join(" ")}`; + } + if (scriptSources.length === 0) { + scriptSrc += " 'unsafe-inline'"; + } + + let styleSrc = "'self'"; + if (styleSources.length > 0) { + styleSrc += ` ${styleSources.join(" ")}`; + } + if (styleSources.length === 0) { + styleSrc += " 'unsafe-inline'"; + } + + const csp = [ + "default-src 'self'", + `script-src ${scriptSrc}`, + `style-src ${styleSrc}`, + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "media-src 'self'", + "object-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", + "manifest-src 'self'", + ].join("; "); + + const hashCount = scriptSources.length + styleSources.length; + console.log(`Generated CSP with ${hashCount} inline content hashes:`); + console.log(`${csp}\n`); + return csp; +} + +function minifyCSS(src) { + let out = src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\s+/g, " ") + .replace(/\s*([{}:;,>+~\(\)\[\]'"*\/])\s*/g, "$1") + .replace(/;}/g, "}") + .replace(/\b0+(px|em|rem|vh|vw|%|s|ms)\b/g, "0") + .replace(/\b0+\.(\d+)/g, ".$1") // 0.5 -> .5 + .replace(/#([0-9a-fA-F])\1([0-9a-fA-F])\2([0-9a-fA-F])\3\b/g, "#$1$2$3") + .replace( + /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g, + (m, r, g, b) => { + const toHex = (n) => parseInt(n).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + ) + .replace(/calc\(\s*([^)]+)\s*\)/g, (m, expr) => { + return `calc(${expr.replace(/\s+/g, "")})`; + }) + .replace(/(['"])([a-zA-Z0-9\-_]+)\1/g, (m, q, val) => { + if (/^[a-zA-Z\-_][a-zA-Z0-9\-_]*$/.test(val)) return val; + return m; + }) + .replace( + /([^{}]+)\{([^}]*)\}([^{}]+)\{([^}]*)\}/g, + (m, sel1, rules1, sel2, rules2) => { + if (rules1 === rules2) return `${sel1},${sel2}{${rules1}}`; + return m; + } + ) + .replace(/[^{}]+\{\s*\}/g, "") + .replace(/(margin|padding):([^;]+)/g, (m, prop, vals) => { + const parts = vals.trim().split(/\s+/); + if (parts.length === 4) { + const [t, r, b, l] = parts; + if (t === r && r === b && b === l) return `${prop}:${t}`; + if (t === b && r === l) return `${prop}:${t} ${r}`; + } else if (parts.length === 2) { + const [tb, lr] = parts; + if (tb === lr) return `${prop}:${tb}`; + } + return m; + }) + .replace(/border-radius:([^;]+)/g, (m, vals) => { + const parts = vals.trim().split(/\s+/); + if (parts.length === 4 && parts.every((p) => p === parts[0])) { + return `border-radius:${parts[0]}`; + } + return m; + }) + .replace( + /-webkit-(transform|transition|animation|box-shadow|border-radius):/g, + "$1:" + ) + .replace( + /-moz-(transform|transition|animation|box-shadow|border-radius):/g, + "$1:" + ) + .replace(/-ms-(transform|transition|animation):/g, "$1:") + .replace( + /translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, + "translate($1,$2)" + ) + .replace(/scale\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (m, x, y) => { + return x === y ? `scale(${x})` : `scale(${x},${y})`; + }) + .replace(/@media\s+([^{]+)\s*\{/g, "@media $1{") + .replace(/linear-gradient\(\s*([^)]+)\s*\)/g, (m, args) => { + return `linear-gradient(${args.replace(/\s*,\s*/g, ",")})`; + }) + .replace(/radial-gradient\(\s*([^)]+)\s*\)/g, (m, args) => { + return `radial-gradient(${args.replace(/\s*,\s*/g, ",")})`; + }) + .replace(/,\s+/g, ",") + .replace(/:\s+/g, ":") + .replace(/;\s+/g, ";") + .replace(/\{\s+/g, "{") + .replace(/\s+\}/g, "}") + .replace(/>\s+/g, ">") + .replace(/\+\s+/g, "+") + .replace(/~\s+/g, "~"); + + return out.replace(/\r?\n/g, "").trim(); +} + +function minifyHTML(src) { + let out = src + .replace(//g, "") + .replace(/>\s+<") + .replace(/>\s+([^<]+?)\s+$1<") + .replace(/]*>([\s\S]*?)<\/style>/gi, (m, css) => { + return m.replace(css, minifyCSS(css)); + }) + .replace(/\s+([\w-]+)=["']([^"'\s<>]+)["']/g, (m, attr, val) => { + if (/^[a-zA-Z0-9._:/-]+$/.test(val)) return ` ${attr}=${val}`; + return m; + }) + .replace(/\s*=\s*/g, "=") + .replace(/\s+>/g, ">") + .replace(/\s{2,}/g, " ") + .replace(/<([^>]+)\s+>/g, "<$1>") + .replace(/\s+[\w-]+=["']["']/g, "") + .replace(/\s+type=["']text\/(javascript|css)["']/g, ""); + + return out.replace(/\r?\n/g, "").trim(); +} + +function minifyJS(src) { + let out = ""; + let i = 0, + n = src.length; + let inStr = false, + strQuote = "", + inTemplate = false, + escape = false; + + while (i < n) { + const ch = src[i]; + const next = src[i + 1]; + + if (inStr) { + out += ch; + if (!escape && ch === strQuote) { + inStr = false; + strQuote = ""; + } + escape = !escape && ch === "\\"; + i++; + continue; + } + + if (inTemplate) { + out += ch; + if (!escape && ch === "`") { + inTemplate = false; + } + escape = !escape && ch === "\\"; + i++; + continue; + } + + if (ch === '"' || ch === "'") { + inStr = true; + strQuote = ch; + out += ch; + i++; + continue; + } + + if (ch === "`") { + inTemplate = true; + out += ch; + i++; + continue; + } + if (ch === "/" && next === "*") { + i += 2; + while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; + i += 2; + continue; + } + if (ch === "/" && next === "/") { + i += 2; + while (i < n && src[i] !== "\n") i++; + continue; + } + + out += ch; + i++; + } + + let finalOut = ""; + inStr = false; + strQuote = ""; + inTemplate = false; + escape = false; + + for (let j = 0; j < out.length; j++) { + const ch = out[j]; + const prev = finalOut[finalOut.length - 1]; + + if (inStr) { + finalOut += ch; + if (!escape && ch === strQuote) { + inStr = false; + strQuote = ""; + } + escape = !escape && ch === "\\"; + continue; + } + + if (inTemplate) { + finalOut += ch; + if (!escape && ch === "`") { + inTemplate = false; + } + escape = !escape && ch === "\\"; + continue; + } + + if (ch === '"' || ch === "'") { + inStr = true; + strQuote = ch; + finalOut += ch; + continue; + } + + if (ch === "`") { + inTemplate = true; + finalOut += ch; + continue; + } + + if (/\s/.test(ch)) { + let k = j + 1; + while (k < out.length && /\s/.test(out[k])) k++; + const nxt = out[k]; + + if ( + (/[a-zA-Z0-9_$]/.test(prev) && /[a-zA-Z0-9_$]/.test(nxt)) || + (prev === "return" && /[a-zA-Z0-9_$"]/.test(nxt)) + ) { + finalOut += " "; + } + j = k - 1; + continue; + } + + if (/[=;:,{}()<>+\-*\/\?|&!]/.test(ch) && finalOut.endsWith(" ")) { + finalOut = finalOut.slice(0, -1); + } + + finalOut += ch; + } + + return finalOut + .replace(/\r?\n/g, "") + .replace(/;}/g, "}") + .replace(/===true/g, "") + .replace(/!==false/g, "") + .replace(/==true/g, "") + .replace(/!=false/g, "") + .replace(/\["([a-zA-Z_$][a-zA-Z0-9_$]*)"\]/g, ".$1") + .trim(); +} + +const files = new Map(); + +function contentTypeFor(fp) { + const ext = path.extname(fp).toLowerCase(); + switch (ext) { + case ".html": + return "text/html; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".js": + return "application/javascript; charset=utf-8"; + case ".webp": + return "image/webp"; + case ".xml": + return "application/xml; charset=utf-8"; + case ".txt": + return "text/plain; charset=utf-8"; + default: + return "application/octet-stream"; + } +} + +function cacheControlFor(fp) { + const ext = path.extname(fp).toLowerCase(); + if (ext === ".html") return "no-cache"; + if (ext === ".css" || ext === ".js") + return "public, max-age=31536000, immutable"; + if (ext === ".webp") return "public, max-age=31536000, immutable"; + if (ext === ".xml" || ext === ".txt") return "public, max-age=3600"; + return "public, max-age=3600"; +} + +function etag(buf) { + let h = 2166136261 >>> 0; + for (let i = 0; i < buf.length; i++) { + h ^= buf[i]; + h = Math.imul(h, 16777619); + } + return 'W/"' + (h >>> 0).toString(16) + "-" + buf.length + '"'; +} + +function preCompress(buf) { + return new Promise((resolve) => { + const result = { raw: buf }; + + try { + zlib.brotliCompress( + buf, + { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length, + }, + }, + (err, br) => { + if (!err && br) result.br = br; + + zlib.gzip( + buf, + { + level: 9, + windowBits: 15, + memLevel: 9, + }, + (gzErr, gz) => { + if (!gzErr && gz) result.gz = gz; + resolve(result); + } + ); + } + ); + } catch { + zlib.gzip(buf, { level: 9 }, (gzErr, gz) => { + if (!gzErr && gz) result.gz = gz; + resolve(result); + }); + } + }); +} + +async function loadAndMinify(relPath, minify) { + const abs = path.join(ROOT, relPath); + const src = await fs.promises.readFile(abs); + const originalContent = src.toString("utf8"); + + if (relPath === "index.html") { + extractInlineContent(originalContent); + } + + const body = minify ? Buffer.from(minify(originalContent), "utf8") : src; + const compressed = await preCompress(body); + const stats = await fs.promises.stat(abs); + + const originalSize = src.length; + const minifiedSize = body.length; + const brSize = compressed.br?.length || 0; + const gzSize = compressed.gz?.length || 0; + + console.log(`${relPath}:`); + console.log(` Original: ${originalSize} bytes`); + if (minify) + console.log( + ` Minified: ${minifiedSize} bytes (${( + ((originalSize - minifiedSize) / originalSize) * + 100 + ).toFixed(1)}% reduction)` + ); + if (brSize) + console.log( + ` Brotli: ${brSize} bytes (${( + (brSize / originalSize) * + 100 + ).toFixed(1)}% of original)` + ); + if (gzSize) + console.log( + ` Gzip: ${gzSize} bytes (${((gzSize / originalSize) * 100).toFixed( + 1 + )}% of original)` + ); + console.log(""); + + const entry = { + path: relPath, + mtimeMs: stats.mtimeMs, + type: contentTypeFor(relPath), + cache: cacheControlFor(relPath), + body, + etag: etag(body), + br: compressed.br, + brEtag: compressed.br ? etag(compressed.br) : undefined, + gz: compressed.gz, + gzEtag: compressed.gz ? etag(compressed.gz) : undefined, + }; + + files.set("/" + relPath.replace(/\\/g, "/"), entry); +} + +async function build() { + console.log("Building and compressing files...\n"); + + await loadAndMinify("index.html", minifyHTML); + await loadAndMinify("style.css", minifyCSS); + await loadAndMinify("script.js", minifyJS); + + for (const f of [ + "favicon.webp", + "profile.webp", + "robots.txt", + "sitemap.xml", + ]) { + if (fs.existsSync(path.join(ROOT, f))) { + await loadAndMinify(f, null); + } + } + const cspPolicy = generateCSP(); + files.set("__csp__", cspPolicy); +} + +function setSecurityHeaders(res) { + const cspPolicy = files.get("__csp__"); + + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "1; mode=block"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload" + ); + res.setHeader( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()" + ); + + if (cspPolicy) { + res.setHeader("Content-Security-Policy", cspPolicy); + } +} + +function negotiateEncoding(req) { + const ae = req.headers["accept-encoding"] || ""; + const hasBr = /\bbr\b/.test(ae); + const hasGz = /\bgzip\b/.test(ae); + return { br: hasBr, gz: hasGz }; +} + +function serveEntry(req, res, entry) { + setSecurityHeaders(res); + res.setHeader("Content-Type", entry.type); + res.setHeader("Cache-Control", entry.cache); + res.setHeader("Vary", "Accept-Encoding"); + + const enc = negotiateEncoding(req); + let body = entry.body; + let tag = entry.etag; + + if (enc.br && entry.br) { + body = entry.br; + tag = entry.brEtag; + res.setHeader("Content-Encoding", "br"); + } else if (enc.gz && entry.gz) { + body = entry.gz; + tag = entry.gzEtag; + res.setHeader("Content-Encoding", "gzip"); + } + + res.setHeader("ETag", tag); + if (req.headers["if-none-match"] === tag) { + res.statusCode = 304; + return res.end(); + } + + res.statusCode = 200; + res.end(body); +} + +function notFound(req, res) { + setSecurityHeaders(res); + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("404 Not Found"); +} + +function handler(req, res) { + const url = new URL(req.url, `http://${req.headers.host}`); + let pathname = url.pathname; + if (pathname === "/") pathname = "/index.html"; + const entry = files.get(pathname); + if (entry) return serveEntry(req, res, entry); + + const fallback = path.join(ROOT, pathname); + if (fs.existsSync(fallback) && fs.statSync(fallback).isFile()) { + const raw = fs.readFileSync(fallback); + const tmpEntry = { + type: contentTypeFor(fallback), + cache: cacheControlFor(fallback), + body: raw, + etag: etag(raw), + }; + return serveEntry(req, res, tmpEntry); + } + + return notFound(req, res); +} + +async function start() { + await build(); + const server = http.createServer(handler); + server.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + }); +} + +start().catch((err) => { + console.error("Failed to start server:", err); + process.exit(1); +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..a09db81 --- /dev/null +++ b/style.css @@ -0,0 +1,514 @@ +/* Base */ +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +:root { + --background: #0e120e; + --foreground: #e2f1e2; + --muted: #8aa78a; + --surface: #101710; + --surface-2: #142014; + --border: #182418; + --accent: #7fc27f; + --accent-2: #a7e6a7; + --font-body: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + --font-heading: ui-rounded, system-ui, -apple-system, Segoe UI, Roboto, + Arial, sans-serif; +} + +body { + margin: 0; + color: var(--foreground); + background: radial-gradient( + 80rem 50rem at 110% -10%, + #1a321a10 20%, + transparent 60% + ), + radial-gradient(60rem 40rem at -10% 10%, #2a4a2a14 20%, transparent 60%), + var(--background); + font-family: var(--font-body); + line-height: 1.65; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +/* Top Bar */ +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: saturate(1.2) blur(6px); + border-bottom: 1px solid #1b281b; + background: linear-gradient(180deg, #0f150fdd, #0f150fb0 60%, #0f150f80); + height: 70px; + display: flex; + align-items: center; +} + +.site-header .inner { + display: flex; + align-items: center; + gap: 16px; + justify-content: space-between; + position: relative; + height: 100%; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--foreground); + text-decoration: none; +} + +.logo { + width: 34px; + height: 34px; + border-radius: 10px; + display: grid; + place-items: center; + font-weight: 700; + font-family: var(--font-heading); + color: #0e120e; + background: conic-gradient( + from 120deg, + var(--accent), + var(--accent-2), + var(--accent) 75% + ); + box-shadow: 0 1px 0 #0008, 0 6px 24px #0006; + border: 1px solid #2a392a; +} + +.site-nav { + display: flex; + gap: 16px; + align-items: center; +} + +.site-nav a { + color: var(--muted); + text-decoration: none; + padding: 10px 8px; + border-radius: 8px; + position: relative; +} + +.site-nav a:hover, +.site-nav a[aria-current="page"] { + color: var(--foreground); +} + +.site-nav a::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + bottom: 6px; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transform: scaleX(0.5); + transition: opacity 0.25s, transform 0.25s; +} + +.site-nav a:hover::after, +.site-nav a[aria-current="page"]::after { + opacity: 1; + transform: none; +} + +/* Mobile nav toggle */ +.menu-toggle { + display: none; + align-items: center; + gap: 8px; + border: 1px solid #2a392a; + background: var(--surface); + color: var(--foreground); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; +} + +.menu-toggle .bars { + width: 18px; + height: 12px; + position: relative; + display: inline-block; +} + +.menu-toggle .bars::before, +.menu-toggle .bars::after, +.menu-toggle .bars span { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--foreground); + border-radius: 2px; +} + +.menu-toggle .bars::before { + top: 0; +} + +.menu-toggle .bars::after { + bottom: 0; +} + +.menu-toggle .bars span { + top: 5px; +} + +/* Hero */ +.hero { + display: block; + text-align: center; +} + +.hero-card { + max-width: 800px; + margin: 0 auto; + display: block; +} + +.eyebrow { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.85rem; + margin: 0 0 6px; + text-align: center; +} + +.hero h1 { + font-family: var(--font-heading); + font-size: clamp(1.9rem, 4.2vw, 2.8rem); + line-height: 1.15; + margin: 0 0 10px; + color: var(--accent-2); + margin-bottom: 1.5rem; +} + +.hero p { + margin: 0 0 14px; + margin-bottom: 2rem; +} + +.cta { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +/* Remove any image-specific styles that might still be present */ +.hero img, +.hero-image, +.about-image { + display: none; +} + +/* Sections */ +section { + margin-top: 28px; +} + +.card { + background: color-mix(in srgb, var(--surface) 96%, black 4%); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px; +} + +h2 { + font-family: var(--font-heading); + font-size: clamp(1.2rem, 2.2vw, 1.4rem); + color: var(--accent); + margin: 0 0 10px; +} + +p.lead { + color: var(--muted); + margin-top: 0; +} + +/* Skills (grouped tags) */ +.skill-groups { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.group { + background: linear-gradient(180deg, #111a11, #0d130d); + border: 1px solid #223222; + border-radius: 14px; + padding: 12px; +} + +.group h3 { + margin: 0 0 8px; + font-size: 0.95rem; + color: var(--accent-2); + letter-spacing: 0.02em; + font-weight: 600; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tag { + border: 1px solid #263626; + border-radius: 999px; + padding: 4px 8px; + font-size: 0.85rem; + color: var(--muted); +} + +/* Timeline */ +.timeline { + position: relative; + padding-left: 20px; +} + +.timeline::before { + content: ""; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background: #204020; +} + +.t-item { + position: relative; + margin: 14px 0; + padding-left: 18px; +} + +.t-item::before { + content: ""; + position: absolute; + left: -1px; + top: 0.45rem; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px #0f150f; +} + +.t-role { + font-weight: 600; +} + +.t-org a { + color: var(--accent); +} + +.t-bullets { + margin: 0.3rem 0 0.2rem 1rem; +} + +/* Projects */ +.proj-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.proj { + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(180deg, #131b13, #0f150f); + padding: 14px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.proj:hover { + transform: translateY(-3px); + box-shadow: 0 14px 40px #0006; +} + +.stack { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.badge { + border: 1px solid #263626; + border-radius: 999px; + padding: 4px 8px; + color: var(--muted); + font-size: 0.85rem; +} + +/* Links */ +a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: var(--accent); + text-decoration-thickness: 0.08em; + text-underline-offset: 2px; +} + +a:hover { + color: var(--accent-2); + text-decoration-color: var(--accent-2); +} + +a:visited { + color: var(--accent); +} + +/* Urgent banner */ +.banner { + background: linear-gradient(180deg, #1a231a, #121812); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + margin-top: 18px; +} + +.badge-urgent { + background: #ff5858; + color: #0e120e; + border: 1px solid #ff9a9a; + border-radius: 999px; + padding: 4px 10px; + font-weight: 700; + font-family: var(--font-heading); + letter-spacing: 0.02em; +} + +.banner .btn { + margin-left: auto; +} + +/* Links */ +a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: var(--accent); + text-decoration-thickness: 0.08em; + text-underline-offset: 2px; +} + +a:hover { + color: var(--accent-2); + text-decoration-color: var(--accent-2); +} + +a:visited { + color: var(--accent); +} + +/* Clickable project cards */ +.proj { + text-decoration: none; + color: inherit; + display: block; +} + +.proj:focus-visible { + outline: 2px solid var(--accent-2); + outline-offset: 2px; +} + +/* Footer */ +footer { + border-top: 1px solid #1e2a1e; + margin-top: 38px; + padding: 18px 0; + color: var(--muted); +} + +footer .to-top { + float: right; +} + +/* Reveal + Scrollspy */ +.reveal { + opacity: 0; + transform: translateY(10px); + transition: opacity 0.6s ease, transform 0.6s ease; + will-change: opacity, transform; +} + +.revealed { + opacity: 1; + transform: none; +} + +/* Responsive */ +@media (max-width: 840px) { + .hero { + grid-template-columns: 1fr; + } + + .avatar { + width: 140px; + height: 140px; + } +} + +@media (max-width: 760px) { + .container { + padding: 16px; + } + + .menu-toggle { + display: inline-flex; + } + + .site-nav { + position: fixed; + left: 16px; + right: 16px; + top: 78px; /* 70px header height + 8px gap */ + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 8px; + gap: 0; + display: none; + box-shadow: 0 10px 30px #0006; + z-index: 25; + } + + .site-nav.is-open { + display: flex; + flex-direction: column; + } + + .site-nav a { + display: block; + padding: 12px 10px; + border-radius: 8px; + } +}