diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 30abc42..a103c5e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,17 +22,17 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Gleam - uses: erlef/setup-beam@v1 + - name: Setup Haskell + uses: haskell-actions/setup@v2 with: - otp-version: "27.1.2" - gleam-version: "1.11.0" + ghc-version: '9.12.2' + cabal-version: 'latest' - - name: Download dependencies - run: gleam deps download + - name: Build site generator + run: cabal update && cabal build - - name: Build site - run: gleam run + - name: Generate site + run: cabal run site-generator - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/.gitignore b/.gitignore index f49c504..8515223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ -*.beam -*.ez -build -erl_crash.dump +# Haskell build artifacts +dist-newstyle/ +*.hi +*.o +*.chi +*.chs.h +.hpc +.hsenv +cabal.project.local +cabal.project.local~ + +# Generated site /dist/ -/build/ + +# Environment .env diff --git a/README.md b/README.md index 90fc4d4..cadd4ab 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ -# Minimal CV site in Gleam -![Gleam](https://img.shields.io/badge/Gleam-1.0.0-blue.svg) +# Minimal CV site in Haskell +![Haskell](https://img.shields.io/badge/Haskell-9.12.2-purple.svg) -Basic profile site made needlessly complicated by writing it with Gleam. - -Completely stole the set up from https://github.com/gleam-lang/website, kind of just made this as a mini way to learn Gleam. +Basic profile site made needlessly complicated by writing it with Haskell. ## Tech Stack -- **Gleam** - Static site generation +- **Haskell** - Static site generation - **CSS** - Animations and styling - **GitHub Actions** - CI/CD - **GitHub Pages** - Hosting @@ -16,22 +14,25 @@ Completely stole the set up from https://github.com/gleam-lang/website, kind of ### Prerequisites -- [Gleam](https://gleam.run/getting-started/installing/) >= 1.0.0 -- [Erlang/OTP](https://www.erlang.org/downloads) >= 26.0 +- [GHC](https://www.haskell.org/ghc/) >= 9.12.2 +- [Cabal](https://www.haskell.org/cabal/) >= 3.0 ### Commands ```bash -# Install dependencies -gleam deps download +# Update package index +cabal update + +# Build the site generator +cabal build -# Build the site -gleam run +# Generate the site +cabal run site-generator # Just open dist/index.html in your browser to view the site # Clean build files -rm -rf dist build +rm -rf dist dist-newstyle ``` ## Deployment diff --git a/app/Main.hs b/app/Main.hs new file mode 100644 index 0000000..b606dd9 --- /dev/null +++ b/app/Main.hs @@ -0,0 +1,292 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main where + +import Control.Exception (catch, IOException) +import Data.Char (isSpace) +import Data.Maybe (fromMaybe) +import System.Directory (createDirectoryIfMissing) + +main :: IO () +main = buildSite `catch` handleError + where + handleError :: IOException -> IO () + handleError err = do + putStrLn "❌ Error building site:" + print err + +buildSite :: IO () +buildSite = do + createDirectoryIfMissing True "dist" + writeIndexPage + writeCSS + putStrLn "✨ Site built successfully!" + putStrLn "📁 Files written to: dist/" + +writeIndexPage :: IO () +writeIndexPage = do + content <- readFile "content/index.djot" + let config = parseConfig content + html = wrapWithTemplate config "Max Harris - Software Engineer" + writeFile "dist/index.html" html + +parseConfig :: String -> [(String, String)] +parseConfig content = + let processLine line = case break (== ':') line of + (key, ':':rest) -> Just (trim key, trim rest) + _ -> Nothing + in foldr (\line acc -> maybe acc (:acc) (processLine line)) [] (lines content) + +trim :: String -> String +trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace + +getConfigValue :: [(String, String)] -> String -> String +getConfigValue config key = fromMaybe "" (lookup key config) + +wrapWithTemplate :: [(String, String)] -> String -> String +wrapWithTemplate config title = + let name = getConfigValue config "name" + jobTitle = getConfigValue config "title" + about = getConfigValue config "about" + linkedinUrl = getConfigValue config "linkedin_url" + githubUrl = getConfigValue config "github_url" + email = getConfigValue config "email" + phone = getConfigValue config "phone" + in unlines + [ "" + , "" + , "" + , " " + , " " + , " " ++ title ++ "" + , " " + , " " + , "" + , "" + , "
" + , "
" + , "
" + , "

" ++ name ++ "

" + , "

" ++ jobTitle ++ "

" + , "
" + , " " + , "
" + , "
" + , "
" + , "

About

" + , "

" ++ about ++ "

" + , "
" + , "
" + , " " + , "
" + , " " + , "
" + , "
" + , "
" + , "
" + , "" + , "" + ] + +writeCSS :: IO () +writeCSS = writeFile "dist/style.css" cssContent + where + cssContent = unlines + [ ":root {" + , " --faff-pink: #ffaff3;" + , " --unnamed-blue: #a6f0fc;" + , " --aged-plastic-yellow: #fffbe8;" + , " --underwater-blue: #292d3e;" + , " --charcoal: #2f2f2f;" + , " --white: #fefefc;" + , " --light-gray: #f5f5f5;" + , "}" + , "" + , "* {" + , " margin: 0;" + , " padding: 0;" + , " box-sizing: border-box;" + , "}" + , "" + , "body {" + , " font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;" + , " font-size: 16px;" + , " line-height: 1.6;" + , " color: var(--charcoal);" + , " min-height: 100vh;" + , "}" + , "" + , ".gradient-bg {" + , " background: linear-gradient(135deg, var(--faff-pink) 0%, var(--unnamed-blue) 50%, var(--aged-plastic-yellow) 100%);" + , " background-size: 200% 200%;" + , " animation: gradient-shift 15s ease infinite;" + , " min-height: 100vh;" + , " display: flex;" + , " align-items: center;" + , " padding: 2rem 0;" + , "}" + , "" + , "@keyframes gradient-shift {" + , " 0% { background-position: 0% 50%; }" + , " 50% { background-position: 100% 50%; }" + , " 100% { background-position: 0% 50%; }" + , "}" + , "" + , ".container {" + , " max-width: 800px;" + , " margin: 0 auto;" + , " padding: 0 2rem;" + , " width: 100%;" + , "}" + , "" + , ".hero {" + , " text-align: center;" + , " margin-bottom: 4rem;" + , "}" + , "" + , ".gradient-text {" + , " background: linear-gradient(45deg, var(--underwater-blue), var(--charcoal));" + , " -webkit-background-clip: text;" + , " background-clip: text;" + , " -webkit-text-fill-color: transparent;" + , " font-size: clamp(3rem, 8vw, 5rem);" + , " font-weight: 700;" + , " margin-bottom: 1rem;" + , "}" + , "" + , ".lead {" + , " font-size: clamp(1.5rem, 4vw, 2.5rem);" + , " font-weight: 300;" + , " color: var(--underwater-blue);" + , " margin-bottom: 0.5rem;" + , "}" + , "" + , ".subtitle {" + , " font-size: 1.25rem;" + , " color: var(--charcoal);" + , " opacity: 0.8;" + , "}" + , "" + , "" + , ".contact-grid {" + , " display: grid;" + , " grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));" + , " gap: 2rem;" + , " margin-bottom: 2rem;" + , "}" + , "" + , ".contact-card {" + , " background: rgba(255, 255, 255, 0.9);" + , " padding: 2rem;" + , " border-radius: 16px;" + , " text-decoration: none;" + , " color: inherit;" + , " transition: all 0.3s ease;" + , " text-align: center;" + , " backdrop-filter: blur(10px);" + , " border: 1px solid rgba(255, 255, 255, 0.2);" + , "}" + , "" + , ".contact-card:hover {" + , " transform: translateY(-4px);" + , " box-shadow: " + , " 0 20px 40px rgba(0, 0, 0, 0.1)," + , " 0 0 40px rgba(255, 175, 243, 0.2);" + , " background: rgba(255, 255, 255, 0.95);" + , "}" + , "" + , ".contact-icon {" + , " font-size: 3rem;" + , " margin-bottom: 1rem;" + , " display: block;" + , "}" + , "" + , ".contact-card h3 {" + , " font-size: 1.5rem;" + , " margin-bottom: 0.5rem;" + , " color: var(--underwater-blue);" + , "}" + , "" + , ".contact-card p {" + , " color: var(--charcoal);" + , " opacity: 0.8;" + , "}" + , "" + , ".about-section {" + , " display: flex;" + , " justify-content: center;" + , " margin-bottom: 3rem;" + , "}" + , "" + , ".about-card {" + , " background: rgba(255, 255, 255, 0.9);" + , " padding: 2.5rem;" + , " border-radius: 16px;" + , " backdrop-filter: blur(10px);" + , " border: 1px solid rgba(255, 255, 255, 0.2);" + , " max-width: 600px;" + , " text-align: center;" + , "}" + , "" + , ".about-card h2 {" + , " color: var(--underwater-blue);" + , " margin-bottom: 1rem;" + , " font-size: 2rem;" + , "}" + , "" + , ".about-card p {" + , " font-size: 1.1rem;" + , " line-height: 1.7;" + , "}" + , "" + , "@media (max-width: 768px) {" + , " .container {" + , " padding: 0 1rem;" + , " }" + , " " + , " .contact-grid {" + , " grid-template-columns: 1fr;" + , " gap: 1.5rem;" + , " }" + , " " + , " .contact-card, .about-card {" + , " padding: 1.5rem;" + , " }" + , "}" + , "" + , "@media (prefers-reduced-motion: reduce) {" + , " .gradient-bg {" + , " animation: none;" + , " }" + , " " + , " .contact-card {" + , " transition: none;" + , " }" + , " " + , " .contact-card:hover {" + , " transform: none;" + , " }" + , "}" + ] diff --git a/cabal.project.freeze b/cabal.project.freeze new file mode 100644 index 0000000..4972dd1 --- /dev/null +++ b/cabal.project.freeze @@ -0,0 +1,23 @@ +active-repositories: hackage.haskell.org:merge +constraints: any.array ==0.5.8.0, + any.base ==4.21.0.0, + any.bytestring ==0.12.2.0, + any.deepseq ==1.5.1.0, + any.directory ==1.3.9.0, + any.exceptions ==0.10.9, + any.file-io ==0.1.5, + any.filepath ==1.5.4.0, + any.ghc-bignum ==1.3, + any.ghc-boot-th ==9.12.2, + any.ghc-internal ==9.1202.0, + any.ghc-prim ==0.13.0, + any.mtl ==2.3.1, + any.os-string ==2.0.7, + any.pretty ==1.1.3.6, + any.rts ==1.0.2, + any.stm ==2.5.3.1, + any.template-haskell ==2.23.0.0, + any.time ==1.14, + any.transformers ==0.6.1.2, + any.unix ==2.8.6.0 +index-state: hackage.haskell.org 2025-12-12T17:39:27Z diff --git a/gleam.toml b/gleam.toml deleted file mode 100644 index 3433376..0000000 --- a/gleam.toml +++ /dev/null @@ -1,13 +0,0 @@ -name = "maxh213_github_io" -version = "1.0.0" -gleam = ">= 1.0.0" -target = "erlang" - -[dependencies] -gleam_stdlib = ">= 0.38.0 and < 2.0.0" -simplifile = ">= 2.0.0 and < 3.0.0" -filepath = ">= 1.0.0 and < 2.0.0" -jot = ">= 4.0.0 and < 5.0.0" - -[dev-dependencies] -gleeunit = ">= 1.0.0 and < 2.0.0" \ No newline at end of file diff --git a/manifest.toml b/manifest.toml deleted file mode 100644 index 852e761..0000000 --- a/manifest.toml +++ /dev/null @@ -1,19 +0,0 @@ -# This file was generated by Gleam -# You typically do not need to edit this file - -packages = [ - { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, - { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" }, - { name = "gleeunit", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D33B7736CF0766ED3065F64A1EBB351E72B2E8DE39BAFC8ADA0E35E92A6A934F" }, - { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, - { name = "jot", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "splitter"], otp_app = "jot", source = "hex", outer_checksum = "E9E266D2768EA1238283D2CF125AA68095F17BAA4DDF3598360FD19F38593C59" }, - { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, - { name = "splitter", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "128FC521EE33B0012E3E64D5B55168586BC1B9C8D7B0D0CA223B68B0D770A547" }, -] - -[requirements] -filepath = { version = ">= 1.0.0 and < 2.0.0" } -gleam_stdlib = { version = ">= 0.38.0 and < 2.0.0" } -gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -jot = { version = ">= 4.0.0 and < 5.0.0" } -simplifile = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/site-generator.cabal b/site-generator.cabal new file mode 100644 index 0000000..1d8b049 --- /dev/null +++ b/site-generator.cabal @@ -0,0 +1,28 @@ +cabal-version: 3.0 +name: site-generator +version: 1.0.0 +synopsis: Minimal CV site generator +description: Static site generator for personal profile page +license: NONE +author: Max Harris +maintainer: max.o.harris@outlook.com +category: Web +build-type: Simple + +executable site-generator + main-is: Main.hs + build-depends: base >=4.16 && <5, + directory >=1.3 && <1.4, + filepath >=1.4 && <1.6 + hs-source-dirs: app + default-language: Haskell2010 + ghc-options: -Wall -threaded + +test-suite site-generator-test + type: exitcode-stdio-1.0 + main-is: Test.hs + build-depends: base >=4.16 && <5, + directory >=1.3 && <1.4 + hs-source-dirs: test + default-language: Haskell2010 + ghc-options: -Wall -threaded diff --git a/src/maxh213_github_io.gleam b/src/maxh213_github_io.gleam deleted file mode 100644 index 289a2fe..0000000 --- a/src/maxh213_github_io.gleam +++ /dev/null @@ -1,301 +0,0 @@ -import gleam/io -import gleam/list -import gleam/result -import gleam/string -import simplifile - -pub fn main() { - case build_site() { - Ok(_) -> { - io.println("✨ Site built successfully!") - io.println("📁 Files written to: dist/") - } - Error(error) -> { - io.println("❌ Error building site:") - io.println(string.inspect(error)) - Nil - } - } -} - -fn build_site() -> Result(Nil, simplifile.FileError) { - use _ <- result.try(simplifile.create_directory_all("dist")) - use _ <- result.try(write_index_page()) - use _ <- result.try(write_css()) - - Ok(Nil) -} - -fn write_index_page() -> Result(Nil, simplifile.FileError) { - use content <- result.try(simplifile.read("content/index.djot")) - let config = parse_config(content) - - let html = wrap_with_template(config, "Max Harris - Software Engineer") - - simplifile.write(html, to: "dist/index.html") -} - -fn parse_config(content: String) -> List(#(String, String)) { - content - |> string.split("\n") - |> list.filter_map(fn(line) { - case string.split_once(line, ": ") { - Ok(#(key, value)) -> Ok(#(string.trim(key), string.trim(value))) - Error(_) -> Error(Nil) - } - }) -} - -fn get_config_value(config: List(#(String, String)), key: String) -> String { - config - |> list.find(fn(item) { item.0 == key }) - |> result.map(fn(item) { item.1 }) - |> result.unwrap("") -} - -fn wrap_with_template(config: List(#(String, String)), title: String) -> String { - let name = get_config_value(config, "name") - let job_title = get_config_value(config, "title") - let about = get_config_value(config, "about") - let linkedin_url = get_config_value(config, "linkedin_url") - let github_url = get_config_value(config, "github_url") - let email = get_config_value(config, "email") - let phone = get_config_value(config, "phone") - - " - - - - - " <> title <> " - - - - -
-
-
-

" <> name <> "

-

" <> job_title <> "

-
- -
-
-
-

About

-

" <> about <> "

-
-
- -
- -
-
-
-
- -" -} - -fn write_css() -> Result(Nil, simplifile.FileError) { - let css = - ":root { - --faff-pink: #ffaff3; - --unnamed-blue: #a6f0fc; - --aged-plastic-yellow: #fffbe8; - --underwater-blue: #292d3e; - --charcoal: #2f2f2f; - --white: #fefefc; - --light-gray: #f5f5f5; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - font-size: 16px; - line-height: 1.6; - color: var(--charcoal); - min-height: 100vh; -} - -.gradient-bg { - background: linear-gradient(135deg, var(--faff-pink) 0%, var(--unnamed-blue) 50%, var(--aged-plastic-yellow) 100%); - background-size: 200% 200%; - animation: gradient-shift 15s ease infinite; - min-height: 100vh; - display: flex; - align-items: center; - padding: 2rem 0; -} - -@keyframes gradient-shift { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -.container { - max-width: 800px; - margin: 0 auto; - padding: 0 2rem; - width: 100%; -} - -.hero { - text-align: center; - margin-bottom: 4rem; -} - -.gradient-text { - background: linear-gradient(45deg, var(--underwater-blue), var(--charcoal)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - font-size: clamp(3rem, 8vw, 5rem); - font-weight: 700; - margin-bottom: 1rem; -} - -.lead { - font-size: clamp(1.5rem, 4vw, 2.5rem); - font-weight: 300; - color: var(--underwater-blue); - margin-bottom: 0.5rem; -} - -.subtitle { - font-size: 1.25rem; - color: var(--charcoal); - opacity: 0.8; -} - - -.contact-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 2rem; - margin-bottom: 2rem; -} - -.contact-card { - background: rgba(255, 255, 255, 0.9); - padding: 2rem; - border-radius: 16px; - text-decoration: none; - color: inherit; - transition: all 0.3s ease; - text-align: center; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); -} - -.contact-card:hover { - transform: translateY(-4px); - box-shadow: - 0 20px 40px rgba(0, 0, 0, 0.1), - 0 0 40px rgba(255, 175, 243, 0.2); - background: rgba(255, 255, 255, 0.95); -} - -.contact-icon { - font-size: 3rem; - margin-bottom: 1rem; - display: block; -} - -.contact-card h3 { - font-size: 1.5rem; - margin-bottom: 0.5rem; - color: var(--underwater-blue); -} - -.contact-card p { - color: var(--charcoal); - opacity: 0.8; -} - -.about-section { - display: flex; - justify-content: center; - margin-bottom: 3rem; -} - -.about-card { - background: rgba(255, 255, 255, 0.9); - padding: 2.5rem; - border-radius: 16px; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - max-width: 600px; - text-align: center; -} - -.about-card h2 { - color: var(--underwater-blue); - margin-bottom: 1rem; - font-size: 2rem; -} - -.about-card p { - font-size: 1.1rem; - line-height: 1.7; -} - -@media (max-width: 768px) { - .container { - padding: 0 1rem; - } - - .contact-grid { - grid-template-columns: 1fr; - gap: 1.5rem; - } - - .contact-card, .about-card { - padding: 1.5rem; - } -} - -@media (prefers-reduced-motion: reduce) { - .gradient-bg { - animation: none; - } - - .contact-card { - transition: none; - } - - .contact-card:hover { - transform: none; - } -}" - - simplifile.write(css, to: "dist/style.css") -} diff --git a/test/Test.hs b/test/Test.hs new file mode 100644 index 0000000..f1e29b9 --- /dev/null +++ b/test/Test.hs @@ -0,0 +1,21 @@ +module Main where + +import System.Directory (doesFileExist) +import System.Exit (exitFailure, exitSuccess) + +main :: IO () +main = do + indexExists <- doesFileExist "dist/index.html" + cssExists <- doesFileExist "dist/style.css" + + if indexExists && cssExists + then do + putStrLn "✅ All tests passed!" + putStrLn " - dist/index.html exists" + putStrLn " - dist/style.css exists" + exitSuccess + else do + putStrLn "❌ Tests failed!" + if not indexExists then putStrLn " - dist/index.html is missing" else pure () + if not cssExists then putStrLn " - dist/style.css is missing" else pure () + exitFailure diff --git a/test/maxh213_github_io_test.gleam b/test/maxh213_github_io_test.gleam deleted file mode 100644 index 67c2bd5..0000000 --- a/test/maxh213_github_io_test.gleam +++ /dev/null @@ -1,11 +0,0 @@ -import gleeunit -import gleeunit/should - -pub fn main() { - gleeunit.main() -} - -pub fn hello_world_test() { - 1 - |> should.equal(1) -}