html vers, deploy

This commit is contained in:
2026-05-17 22:45:45 +12:00
parent c2fcd2179b
commit 4f1a2cbac3
16 changed files with 3872 additions and 648 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitignore
.env
.env.*
*.log
*.err
__pycache__/
*.pyc
.venv/
venv/
.DS_Store
Thumbs.db
README.md
Deploy.ps1
+4
View File
@@ -0,0 +1,4 @@
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
CONTACT_TO=alex@lean-101.com
CONTACT_FROM=Lean 101 Website <noreply@lean-101.com>
PORT=5000
+17
View File
@@ -0,0 +1,17 @@
# Copy to .env.production and fill in.
# .env.production is gitignored; Deploy.ps1 uploads it to the droplet.
# Resend API key — required for the contact form to actually send.
RESEND_API_KEY=
# Optional — both have sensible defaults in app.py if omitted.
CONTACT_TO=alex@lean-101.com
CONTACT_FROM=Lean 101 Website <noreply@lean-101.com>
# Host port the container binds to on the droplet (127.0.0.1:WEBSITE_APP_PORT -> 8000).
# Host nginx vhost for www.lean-101.com.au should proxy_pass to this port.
WEBSITE_APP_PORT=8083
# Droplet SSH password — used by Deploy.ps1 via sshpass so you don't have to type it.
# Leave blank and pass -SshKey to Deploy.ps1 if you'd rather use key auth.
SSH_PASSWORD=7Ajyef6UaAXHAnWrtUua
+7 -4
View File
@@ -1,11 +1,14 @@
node_modules/
.svelte-kit/
build/
.env .env
.env.production
.env.*.local
*.log *.log
*.err *.err
.DS_Store .DS_Store
Thumbs.db Thumbs.db
__pycache__/
*.pyc
.venv/
venv/
+418
View File
@@ -0,0 +1,418 @@
<#
.SYNOPSIS
Lean 101 Website deployment script — ships the Flask site to the Digital Ocean droplet over SSH.
.DESCRIPTION
Tars the local source tree, uploads it to the droplet, and runs
docker compose up --build. No git required on the server.
Coexists with the clients app on the same droplet — different container name,
different host port, different remote path. Host nginx routes by hostname.
.PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser
SSH user. Defaults to 'root'.
.PARAMETER AppName
Human-readable app name shown in the banner. Defaults to 'Website'.
.PARAMETER AppSlug
Lowercase slug used in container/log labels. Defaults to 'website'.
.PARAMETER RemotePath
Absolute path on the droplet. Defaults to '/srv/lean-101-website-flask'
(deliberately distinct from the old /srv/lean101-website SvelteKit dir).
.PARAMETER ContainerName
Backend container name to inspect. Defaults to 'lean101-website-flask'
(must match container_name in docker-compose.yml).
.PARAMETER PortEnvKey
Env var name in the env file that holds the published port.
Defaults to 'WEBSITE_APP_PORT'.
.PARAMETER EnvFile
Local path to the production env file. Defaults to '.env.production'.
.PARAMETER SshKey
Optional path to an SSH private key.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to 'docker-compose.yml'.
.PARAMETER Logs
Tail logs for ~60 lines after deploy.
.PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed).
.PARAMETER NoBanner
Suppress the ASCII banner.
.EXAMPLE
./Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE
./Deploy.ps1 -RemoteHost 209.38.24.231 -Logs
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $AppName = "Website",
[string] $AppSlug = "website",
[string] $RemotePath = "/srv/lean-101-website-flask",
[string] $ContainerName = "lean101-website-flask",
[string] $PortEnvKey = "WEBSITE_APP_PORT",
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.yml",
[switch] $Logs,
[switch] $SkipBuild,
[switch] $NoBanner
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# Palette
$Esc = [char]27
$C = @{
Reset = "$Esc[0m"; Dim = "$Esc[2m"; Bold = "$Esc[1m"
Magenta = "$Esc[38;5;177m"; Pink = "$Esc[38;5;213m"
Cyan = "$Esc[38;5;87m"; Teal = "$Esc[38;5;79m"
Green = "$Esc[38;5;120m"; Yellow = "$Esc[38;5;221m"
Red = "$Esc[38;5;203m"; Grey = "$Esc[38;5;244m"
Blue = "$Esc[38;5;117m"
}
$Glyph = @{ Step='▸'; OK='✓'; Warn='!'; Info='·'; Fail='✗'; Arrow='→'; Spark='✦' }
function Write-Banner {
if ($NoBanner) { return }
$line = '─' * 62
$title = "Lean 101 Website Deployment"
$sub = "App: $AppName Slug: $AppSlug Target: $RemoteUser@$RemoteHost"
Write-Host ""
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ""
}
$script:StepIndex = 0
function Write-Step([string]$msg) {
$script:StepIndex++
$num = "{0:D2}" -f $script:StepIndex
Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)")
}
function Write-Ok([string]$msg) { Write-Host (" $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") }
function Write-Warn([string]$msg) { Write-Host (" $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") }
function Write-Info([string]$msg) { Write-Host (" $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") }
function Write-Fail([string]$msg) { Write-Host (" $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") }
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
function Invoke-Spinner {
param(
[Parameter(Mandatory = $true)] [string] $Label,
[Parameter(Mandatory = $true)] [string] $FilePath,
[string[]] $ArgList = @(),
[string] $StdinFile,
[switch] $ShowOutput
)
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
$startParams = @{
FilePath = $FilePath; NoNewWindow = $true; PassThru = $true
RedirectStandardOutput = $outFile; RedirectStandardError = $errFile
}
if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList }
if ($StdinFile) { $startParams.RedirectStandardInput = $StdinFile }
$proc = Start-Process @startParams
$start = Get-Date
$i = 0
try {
while (-not $proc.HasExited) {
$elapsed = ((Get-Date) - $start).TotalSeconds
$frame = $Spinner[$i % $Spinner.Count]
Write-Host ("`r $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset) ") -NoNewline
Start-Sleep -Milliseconds 90
$i++
}
$proc.WaitForExit()
$elapsed = ((Get-Date) - $start).TotalSeconds
$stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' }
$stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' }
if ($proc.ExitCode -eq 0) {
Write-Host ("`r $($C.Green)$($Glyph.OK)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12))
if ($ShowOutput -and $stdout) {
foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } }
}
return $stdout
} else {
Write-Host ("`r $($C.Red)$($Glyph.Fail)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8))
if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host " $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } }
if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host " $($C.Red)$ln$($C.Reset)" } } }
throw "$Label failed (exit $($proc.ExitCode))"
}
} finally {
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
Remove-Item $errFile -Force -ErrorAction SilentlyContinue
}
}
function Get-RepoRoot {
if ($PSScriptRoot) { return $PSScriptRoot }
return (Get-Location).Path
}
function Get-EnvValue([string] $path, [string] $key) {
foreach ($line in Get-Content $path) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith("#")) { continue }
if ($trimmed -notmatch "=") { continue }
$parts = $trimmed -split "=", 2
if ($parts[0].Trim() -eq $key) { return $parts[1].Trim() }
}
return $null
}
$RepoRoot = Get-RepoRoot
$SshTarget = "$RemoteUser@$RemoteHost"
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no", "-i", $SshKey)
# Resolve env file path early so we can read SSH_PASSWORD before the main flow.
$EnvPathEarly = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
function Resolve-PuttyTool([string] $name) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
foreach ($candidate in @(
"$env:ProgramFiles\PuTTY\$name.exe",
"${env:ProgramFiles(x86)}\PuTTY\$name.exe",
"$env:LOCALAPPDATA\Programs\PuTTY\$name.exe"
)) {
if ($candidate -and (Test-Path $candidate)) { return $candidate }
}
return $null
}
$SshPassword = $null
$PlinkPath = $null
$PscpPath = $null
if (-not $SshKey) {
if (-not (Test-Path $EnvPathEarly)) {
throw "Env file not found at '$EnvPathEarly'. Create it and add SSH_PASSWORD=<droplet root password> (or pass -SshKey to use key auth)."
}
$SshPassword = Get-EnvValue $EnvPathEarly 'SSH_PASSWORD'
if (-not $SshPassword) {
throw "SSH_PASSWORD is not set in '$EnvPathEarly'. Add a line `SSH_PASSWORD=<your droplet password>` (or pass -SshKey to use key auth instead)."
}
$PlinkPath = Resolve-PuttyTool 'plink'
$PscpPath = Resolve-PuttyTool 'pscp'
if (-not $PlinkPath -or -not $PscpPath) {
throw "plink/pscp (PuTTY) are required for password auth on Windows but were not found. Install PuTTY: ``winget install --id PuTTY.PuTTY -e`` (or ``choco install putty -y``), then reopen PowerShell."
}
# Pre-cache the SSH host key so subsequent -batch calls don't fail on first connect.
# Use cmd's pipe — most reliable way to feed `y` to plink's host-key prompt on Windows.
$cacheOut = & cmd /c "echo y | `"$PlinkPath`" -ssh -pw `"$SshPassword`" `"$SshTarget`" exit 2>&1"
# Ignore exit code: the goal is just to write the host key into the PuTTY registry cache.
$null = $cacheOut
}
function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
if ($SshPassword) {
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, $cmd)
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -ShowOutput:$ShowOutput
} else {
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, $cmd)) -ShowOutput:$ShowOutput
}
}
function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
if ($SshPassword) {
$argList = @('-scp', '-pw', $SshPassword, '-batch', $local, "${SshTarget}:${remote}")
Invoke-Spinner -Label $Label -FilePath $PscpPath -ArgList $argList
} else {
Invoke-Spinner -Label $Label -FilePath 'scp' -ArgList (@($SshOpts) + @($local, "${SshTarget}:${remote}"))
}
}
function Try-Ssh([string] $cmd) {
if ($SshPassword) {
& $PlinkPath -ssh -pw $SshPassword -batch $SshTarget $cmd
} else {
& ssh @SshOpts $SshTarget $cmd
}
return $LASTEXITCODE
}
function Invoke-SshScript([string] $script, [string] $Label) {
if (-not $Label) { $Label = "ssh (remote script)" }
$tmp = [System.IO.Path]::GetTempFileName()
try {
# Use LF line endings so bash on the remote doesn't choke on CR.
[System.IO.File]::WriteAllText($tmp, ($script -replace "`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
if ($SshPassword) {
$argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, 'bash -s')
Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -StdinFile $tmp -ShowOutput
} else {
Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
}
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
Write-Banner
Push-Location $RepoRoot
try {
$EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
if (-not (Test-Path $EnvPath)) {
throw "Env file not found at '$EnvPath'. Copy .env.production.example to .env.production and fill in RESEND_API_KEY."
}
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
if (-not $AppPort) { $AppPort = "8083" }
Write-Step "Preflight"
Write-Info "App : $AppName ($AppSlug)"
Write-Info "Remote host : $SshTarget"
Write-Info "Remote path : $RemotePath"
Write-Info "Container : $ContainerName"
Write-Info "Env file : $EnvPath"
Write-Info "Compose file : $ComposeFile"
Write-Info "Port ($PortEnvKey) : $AppPort"
Write-Step "Verifying SSH connectivity"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput
Write-Step "Checking that remote port $AppPort is free for $ContainerName"
$portCheckCmd = @'
set -e
PORT='__APP_PORT__'
SELF='__CONTAINER__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ] && [ "$OWNER" != "$SELF" ]; then
echo "Port $PORT is already owned by container: $OWNER" >&2
exit 2
fi
'@.Replace('__APP_PORT__', $AppPort).Replace('__CONTAINER__', $ContainerName)
try {
Invoke-SshScript $portCheckCmd -Label "port $AppPort availability"
} catch {
throw "Remote port $AppPort is already in use by another container. Change $PortEnvKey in '$EnvPath' or retire the conflicting service first."
}
Write-Step "Packaging source tree (excluding caches, secrets, logs)"
$TarFile = Join-Path $env:TEMP "lean101-website-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$excludes = @(
"--exclude=./.git",
"--exclude=./.pytest_cache",
"--exclude=./__pycache__",
"--exclude=./**/__pycache__",
"--exclude=./*.pyc",
"--exclude=./.venv",
"--exclude=./venv",
"--exclude=./.env",
"--exclude=./.env.production",
"--exclude=./.env.*.local",
"--exclude=./*.log",
"--exclude=./*.err"
)
Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 2)
Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
Write-Step "Ensuring remote path exists"
Invoke-Ssh "mkdir -p '$RemotePath'" -Label "mkdir -p $RemotePath"
Write-Step "Uploading env file"
Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"
Write-Step "Uploading source archive ($TarSize MB)"
Invoke-Scp $TarFile "/tmp/lean101-website-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
Remove-Item $TarFile -Force
Write-Step "Extracting archive on server"
Invoke-Ssh "tar -xzf /tmp/lean101-website-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-website-deploy.tar.gz" -Label "untar into $RemotePath"
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
$buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
Write-Step "Bringing the $AppName stack up $buildMsg"
$composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
try {
Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag"
} catch {
Write-Warn "docker compose up failed; collecting status and logs"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" | Out-Null
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120" | Out-Null
throw
}
Write-Step "Waiting for container to be running ($ContainerName)"
$runCheck = @"
set -e
for i in `$(seq 1 30); do
status=`$(docker inspect --format='{{.State.Status}}' $ContainerName 2>/dev/null || echo missing)
case "`$status" in
running) echo "container is running"; exit 0 ;;
*) printf '.'; sleep 2 ;;
esac
done
echo; echo "container did not reach running state" >&2; exit 1
"@
Invoke-SshScript $runCheck -Label "container status (up to ~60s)"
Write-Step "HTTP readiness check on 127.0.0.1:$AppPort"
$curlCmd = "for i in `$(seq 1 20); do code=`$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$AppPort/ 2>/dev/null || echo 000); if [ `"`$code`" = `"200`" ]; then echo `"HTTP `$code`"; exit 0; fi; sleep 2; done; echo `"never got 200`" >&2; exit 1"
Invoke-Ssh $curlCmd -Label "curl http://127.0.0.1:$AppPort/" -ShowOutput
Write-Step "Stack status"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
}
Write-Step "Next steps for cutover"
Write-Info "New container is healthy on $($C.Bold)127.0.0.1:$AppPort$($C.Reset)"
Write-Info "Public domain still hits the OLD container until you swap the nginx vhost:"
Write-Info " ssh $SshTarget"
Write-Info " sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:$AppPort;|' /etc/nginx/sites-enabled/lean-101.com.au"
Write-Info " nginx -t && systemctl reload nginx"
Write-Info "Then verify: curl -I https://www.lean-101.com.au/"
Write-Info "Once happy: cd /srv/lean101-website && docker compose down"
$line = '─' * 62
Write-Host ""
Write-Host ("$($C.Green)$line$($C.Reset)")
$done = "$($Glyph.Spark) $AppName deployed to ${SshTarget}:$AppPort"
$pad = [Math]::Max(0, 60 - ($done.Length - 2))
Write-Host ("$($C.Green)$($C.Reset) $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)$($C.Reset)")
Write-Host ("$($C.Green)$line$($C.Reset)")
Write-Host ""
}
catch {
Write-Host ""
Write-Fail "Deployment aborted: $($_.Exception.Message)"
Write-Host ""
throw
}
finally {
Pop-Location
}
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt gunicorn
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"]
+62 -144
View File
@@ -1,165 +1,83 @@
# Lean 101 Website # Lean 101 Website
This repo runs as a server-rendered SvelteKit app with a custom Mongo-backed CMS at `/admin`. Plain static HTML site with a small Python (Flask) backend for the contact form.
## Stack ## Layout
- `SvelteKit` ```
- `@sveltejs/adapter-node` index.html # Home (includes contact form at #contact)
- `MongoDB` via the native `mongodb` driver services/ # coaching, consulting, digital-solutions pages
- `docker compose` for local and server runtime resources/ # resources index
assets/ # logos
## What changed app.py # Flask app: serves the site + /api/contact (Resend)
requirements.txt
- The public site now reads page content from MongoDB when `MONGODB_URI` and `MONGODB_DB` are set. .env.example # copy to .env and fill in (local dev)
- If MongoDB is not configured, the homepage falls back to the local seed file at `src/lib/content/homepage.json`. Dockerfile # python:3.12-slim + gunicorn
- `/admin` is a custom password-protected editor for: docker-compose.yml # single service: lean101, attaches to shared nginx network
- pages deploy.env.template # synced to server .env by deploy.sh (live values win)
- movable page sections deploy.sh # remote deploy: git clone + compose up + nginx + maintenance
- blog posts
- The homepage seed is automatically migrated into the `site_pages` collection the first time the app connects to MongoDB.
## Project layout
```text
.
├── Dockerfile
├── docker-compose.yml
├── package.json
├── scripts/
│ └── deploy-do.sh
├── src/
│ ├── app.css
│ ├── app.html
│ ├── lib/
│ │ ├── content/homepage.json
│ │ ├── components/SitePage.svelte
│ │ ├── server/auth.js
│ │ ├── server/content.js
│ │ └── site.js
│ └── routes/
│ ├── +layout.js
│ ├── +page.server.js
│ ├── +page.svelte
│ ├── [slug]/+page.server.js
│ ├── [slug]/+page.svelte
│ ├── admin/+page.server.js
│ ├── admin/+page.svelte
│ ├── blog/+page.server.js
│ ├── blog/+page.svelte
│ ├── blog/[slug]/+page.server.js
│ ├── blog/[slug]/+page.svelte
│ ├── robots.txt/+server.js
│ └── sitemap.xml/+server.js
└── static/assets/
├── lean101-isotipo.png
└── lean101-logotipo.png
``` ```
## Environment variables ## Local development
Required for the CMS:
- `MONGODB_URI`
- `MONGODB_DB`
- `ADMIN_PASSWORD`
- `ADMIN_SESSION_SECRET`
Required for correct canonical/meta URLs:
- `PUBLIC_SITE_URL`
Example `.env`:
```env
PUBLIC_SITE_URL=http://localhost:8080
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=lean101
ADMIN_PASSWORD=replace-this
ADMIN_SESSION_SECRET=replace-this-with-a-long-random-string
```
## Run locally with Docker
```bash ```bash
docker compose up --build python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS/Linux
pip install -r requirements.txt
copy .env.example .env # then edit .env with your Resend key
python app.py
``` ```
The site will be available at `http://localhost:8080`. Site at http://127.0.0.1:5000
The app server listens on port `3000` inside the container and is mapped to `8080` on the host. ## Contact form
## Run locally without Docker - Form is on the home page in the `#contact` section.
- Submits to `POST /api/contact` (JSON response).
- Server sends the email via [Resend](https://resend.com). Configure:
- `RESEND_API_KEY` — your Resend API key.
- `CONTACT_TO` — where enquiries are sent.
- `CONTACT_FROM` — must be on a domain you've verified in Resend.
- Honeypot field (`website`) silently drops bots.
The "Let's Talk" buttons on service pages link back to the home `#contact` section, where the real form lives.
## Deployment
`Deploy.ps1` runs from your laptop. It tars the repo, scps it to the droplet, and runs `docker compose up --build` over SSH. Host nginx on the droplet terminates TLS for `www.lean-101.com.au` and proxies to `127.0.0.1:WEBSITE_APP_PORT` (default 8083) where this container binds.
First deploy:
```powershell
copy .env.production.example .env.production # then edit and set RESEND_API_KEY
./Deploy.ps1 -RemoteHost <droplet-ip>
```
Subsequent deploys: same command. Add `-Logs` to tail logs after, `-SkipBuild` if only the env changed.
### Cutover (first deploy only)
The script deploys to `/srv/lean-101-website-flask` with container `lean101-website-flask` on port 8083, deliberately leaving the old SvelteKit container (`/srv/lean101-website`, port 8091) running. After verifying the new container is healthy, swap the nginx vhost on the droplet:
```bash ```bash
npm install ssh root@<droplet>
npm run dev sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:8083;|' \
/etc/nginx/sites-enabled/lean-101.com.au
nginx -t && systemctl reload nginx
curl -I https://www.lean-101.com.au/ # should hit the new Flask container
# Once happy, retire the old stack:
cd /srv/lean101-website && docker compose down
``` ```
The Vite dev server will run on its normal local port, usually `http://localhost:5173`. Rollback: `mv /etc/nginx/sites-enabled/lean-101.com.au.bak /etc/nginx/sites-enabled/lean-101.com.au && nginx -s reload`.
## Production build ### Coexistence with the clients app
```bash The droplet runs host nginx → localhost ports → docker containers. The clients app at `clients.lean-101.com.au` proxies to `127.0.0.1:8082`, this site to `127.0.0.1:8083`. Each app has its own compose project and bridge network; they don't share anything.
npm run build
npm run preview
```
`npm run preview` now runs the built Node server with `node build`.
## Custom CMS
Visit `/admin`.
The CMS is intentionally simple, but it now supports structured content:
- one password
- multiple pages
- reorderable sections within each page
- blog drafts and published posts
Current collections:
- `site_pages`
- `blog_posts`
Current section types:
- `hero`
- `services`
- `process`
- `outcomes`
- `why`
- `richText`
- `cta`
Published blog posts are available under `/blog`, and individual pages are available at `/<slug>`.
## DigitalOcean deployment script
After copying this project folder onto a droplet, you can run:
```bash
sudo ./scripts/deploy-do.sh --domain yourdomain.com --email you@example.com --with-www
```
The script:
- installs Docker, the compose plugin, nginx, and certbot
- writes `.env` with `PUBLIC_SITE_URL=https://yourdomain.com`
- starts the compose stack
- configures system nginx to reverse proxy to the Docker app on `127.0.0.1:8080`
- requests and installs a Let's Encrypt certificate if `--email` is provided
If you use the CMS in production, make sure the `.env` file on the server also includes:
- `MONGODB_URI`
- `MONGODB_DB`
- `ADMIN_PASSWORD`
- `ADMIN_SESSION_SECRET`
## Notes ## Notes
- The current contact CTA still uses `mailto:hello@lean-101.com` - The SvelteKit + Mongo stack was removed in favour of plain HTML so edits land directly. If you need anything from the prior stack, recover it from git history before this commit.
- The seed homepage remains in `src/lib/content/homepage.json` - During the restructure the v3.11 service pages were lost (they were never committed) and restored from the older `html-v3` versions in HEAD. The v3.11 service-page tweaks (e.g. CTA copy rebranded to "Let's Talk") will need to be re-applied.
- Live page and blog content is stored in MongoDB once the database env vars are configured
+103
View File
@@ -0,0 +1,103 @@
import os
import re
from pathlib import Path
import resend
from flask import Flask, jsonify, request, send_from_directory
from dotenv import load_dotenv
load_dotenv()
SITE_ROOT = Path(__file__).parent.resolve()
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
CONTACT_TO = os.environ.get("CONTACT_TO", "alex@lean-101.com")
CONTACT_FROM = os.environ.get("CONTACT_FROM", "Lean 101 Website <noreply@lean-101.com>")
resend.api_key = RESEND_API_KEY
app = Flask(__name__, static_folder=None)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _clip(value: str, limit: int) -> str:
value = (value or "").strip()
return value[:limit]
@app.post("/api/contact")
def contact():
form = request.form if request.form else request.get_json(silent=True) or {}
if (form.get("website") or "").strip():
return jsonify({"ok": True}), 200
name = _clip(form.get("name", ""), 200)
email = _clip(form.get("email", ""), 200)
company = _clip(form.get("company", ""), 200)
message = _clip(form.get("message", ""), 5000)
if not name or not email or not message:
return jsonify({"error": "Please fill in your name, email, and message."}), 400
if not EMAIL_RE.match(email):
return jsonify({"error": "That email address doesn't look right."}), 400
if not RESEND_API_KEY:
app.logger.error("RESEND_API_KEY not configured")
return jsonify({"error": "Email is not configured on the server."}), 500
html = (
f"<p><strong>Name:</strong> {name}</p>"
f"<p><strong>Email:</strong> {email}</p>"
f"<p><strong>Company:</strong> {company or ''}</p>"
f"<p><strong>Looking to improve:</strong></p>"
f"<p>{message.replace(chr(10), '<br>')}</p>"
)
try:
resend.Emails.send({
"from": CONTACT_FROM,
"to": [CONTACT_TO],
"reply_to": email,
"subject": f"New Lean 101 enquiry — {name}"
+ (f" ({company})" if company else ""),
"html": html,
})
except Exception as exc:
app.logger.exception("Resend send failed: %s", exc)
return jsonify({"error": "Couldn't send your message. Please try again."}), 502
return jsonify({"ok": True}), 200
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def static_files(path: str):
if not path:
return send_from_directory(SITE_ROOT, "index.html")
target = (SITE_ROOT / path).resolve()
try:
target.relative_to(SITE_ROOT)
except ValueError:
return ("Not found", 404)
if target.is_dir():
index = target / "index.html"
if index.exists():
return send_from_directory(target, "index.html")
return ("Not found", 404)
if target.exists():
return send_from_directory(target.parent, target.name)
html_candidate = target.with_suffix(".html")
if html_candidate.exists():
return send_from_directory(html_candidate.parent, html_candidate.name)
return ("Not found", 404)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "5000")), debug=True)
-500
View File
@@ -1,500 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_URL=""
BRANCH="main"
REF=""
DEPLOY_PATH=""
COMPOSE_FILE=""
PROJECT_NAME=""
SERVICE_NAME=""
NGINX_SOURCE=""
NGINX_TARGET=""
NGINX_COMPOSE_FILE=""
NGINX_PROJECT_NAME=""
MAINTENANCE_HOST_DIR=""
MAINTENANCE_FLAG_PATH=""
VERIFY_URL="https://www.goodwalk.co.nz/api/health"
SKIP_SITE_CHECK=0
usage() {
cat <<'EOF'
Usage:
deploy-from-git.sh --repo-url <url> --branch <name> --deploy-path <path> --compose-file <name> --project-name <name>
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> \
[--service <name>] [--nginx-source <path>] [--nginx-target <path>] \
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
[--maintenance-host-dir <path>] [--maintenance-flag <path>] \
[--verify-url <url>] [--skip-site-check]
This script clones or fetches the application repo on the server, exports the
homepage content payload, updates only the main Goodwalk compose project, and
optionally updates the shared nginx stack plus maintenance mode handling.
Authentication for private HTTPS repos is expected to come from ~/.netrc,
git-credential, or another Git-supported credential mechanism already present
on the server.
EOF
}
fail() {
echo "[deploy-git] ERROR: $*" >&2
exit 1
}
assert_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server"
}
run_homepage_export() {
local export_script="$1"
local output_path="$2"
if command -v node >/dev/null 2>&1; then
node --experimental-strip-types "$export_script" "$output_path"
return
fi
echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container"
docker run --rm \
-v "$CHECKOUT_DIR:/app" \
-w /app \
node:22-alpine \
node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-url)
REPO_URL="${2:-}"
shift 2
;;
--branch)
BRANCH="${2:-}"
shift 2
;;
--ref)
REF="${2:-}"
shift 2
;;
--deploy-path)
DEPLOY_PATH="${2:-}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:-}"
shift 2
;;
--project-name)
PROJECT_NAME="${2:-}"
shift 2
;;
--service)
SERVICE_NAME="${2:-}"
shift 2
;;
--nginx-source)
NGINX_SOURCE="${2:-}"
shift 2
;;
--nginx-target)
NGINX_TARGET="${2:-}"
shift 2
;;
--nginx-compose-file)
NGINX_COMPOSE_FILE="${2:-}"
shift 2
;;
--nginx-project-name)
NGINX_PROJECT_NAME="${2:-}"
shift 2
;;
--maintenance-host-dir)
MAINTENANCE_HOST_DIR="${2:-}"
shift 2
;;
--maintenance-flag)
MAINTENANCE_FLAG_PATH="${2:-}"
shift 2
;;
--verify-url)
VERIFY_URL="${2:-}"
shift 2
;;
--skip-site-check)
SKIP_SITE_CHECK=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$REPO_URL" ]] || fail "--repo-url is required"
[[ -n "$BRANCH" ]] || fail "--branch is required"
[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required"
[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required"
[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required"
if [[ -n "$SERVICE_NAME" ]]; then
SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)"
fi
[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /"
nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME")
nginx_args_present=0
for value in "${nginx_args[@]}"; do
if [[ -n "$value" ]]; then
nginx_args_present=1
break
fi
done
if (( nginx_args_present )); then
[[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled"
[[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled"
[[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled"
[[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled"
fi
assert_command git
assert_command docker
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
fail "Docker Compose is not installed on the server"
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")"
CHECKOUT_DIR="$WORK_DIR/repo"
PAYLOAD_DIR="$WORK_DIR/payload"
MAINTENANCE_ACTIVE=0
clear_maintenance_flag() {
if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then
echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH"
rm -f "$MAINTENANCE_FLAG_PATH" || true
MAINTENANCE_ACTIVE=0
fi
}
cleanup() {
clear_maintenance_flag
rm -rf "$WORK_DIR"
}
copy_checkout_to_payload() {
mkdir -p "$PAYLOAD_DIR"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.git' \
--exclude '.env' \
--exclude '.env.*' \
"$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$CHECKOUT_DIR"/}"
case "$relative_path" in
.git|.git/*|.env|.env.*)
continue
;;
esac
destination="$PAYLOAD_DIR/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0)
}
copy_payload_to_deploy() {
mkdir -p "$DEPLOY_PATH"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.env' \
--exclude '.env.*' \
"$PAYLOAD_DIR"/ "$DEPLOY_PATH"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$PAYLOAD_DIR"/}"
if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then
continue
fi
destination="$DEPLOY_PATH/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$PAYLOAD_DIR" -mindepth 1 -print0)
}
merge_env_file() {
local template="$1"
local live="$2"
[[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; }
[[ -f "$live" ]] || { echo "[deploy-git] No live .env at $live, skipping merge"; return 0; }
local added diffs backup
added="$(mktemp)"
diffs="$(mktemp)"
backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" '
function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s }
BEGIN {
while ((getline line < live) > 0) {
if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue
eq = index(line, "=")
if (eq == 0) continue
k = trim(substr(line, 1, eq-1))
v = substr(line, eq+1)
live_keys[k] = v
live_seen[k] = 1
}
close(live)
}
/^[ \t]*#/ || /^[ \t]*$/ { next }
{
eq = index($0, "=")
if (eq == 0) next
k = trim(substr($0, 1, eq-1))
v = substr($0, eq+1)
if (!(k in live_seen)) {
print k "=" v >> added_log
} else if (live_keys[k] != v) {
print k " (template=" v " | live=" live_keys[k] ")" >> diff_log
}
}
' "$template"
if [[ -s "$added" ]]; then
cp "$live" "$backup"
echo "[deploy-git] Adding env keys present in template but missing from $live:"
sed 's/^/ + /' "$added"
{
printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat "$added"
} >> "$live"
echo "[deploy-git] Backup of previous .env written to $backup"
else
echo "[deploy-git] .env is up to date with template (no missing keys)"
fi
if [[ -s "$diffs" ]]; then
echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:"
sed 's/^/ ! /' "$diffs"
echo "[deploy-git] If a live value is stale, edit $live and re-deploy."
fi
rm -f "$added" "$diffs"
}
check_site() {
if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then
return 0
fi
echo "[deploy-git] Checking production site: $VERIFY_URL"
if command -v curl >/dev/null 2>&1; then
local http_code
if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then
echo "[deploy-git] Site responded with HTTP $http_code"
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
return 0
fi
if command -v wget >/dev/null 2>&1; then
if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then
awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
rm -f /tmp/goodwalk-site-check.$$
return 0
fi
echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2
}
trap cleanup EXIT
echo "[deploy-git] Deploying main Goodwalk stack from Git"
echo "[deploy-git] Repo URL: $REPO_URL"
echo "[deploy-git] Branch: $BRANCH"
if [[ -n "$REF" ]]; then
echo "[deploy-git] Requested ref: $REF"
fi
echo "[deploy-git] Target deployment path: $DEPLOY_PATH"
echo "[deploy-git] Compose file: $COMPOSE_FILE"
echo "[deploy-git] Docker project: $PROJECT_NAME"
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Target service: $SERVICE_NAME"
fi
if (( nginx_args_present )); then
echo "[deploy-git] Nginx config source: $NGINX_SOURCE"
echo "[deploy-git] Nginx config target: $NGINX_TARGET"
echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE"
echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME"
echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR"
echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH"
fi
echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR"
git clone "$REPO_URL" "$CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --prune origin
if [[ -n "$REF" ]]; then
git -C "$CHECKOUT_DIR" checkout --detach "$REF"
else
git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
fi
DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)"
echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION"
EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs"
[[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT"
echo "[deploy-git] Exporting current homepage content for PostgreSQL sync"
run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json"
echo "[deploy-git] Preparing deployment payload"
copy_checkout_to_payload
[[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE"
if [[ -f "$DEPLOY_PATH/.env" ]]; then
echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env"
fi
echo "[deploy-git] Copying application files into $DEPLOY_PATH"
copy_payload_to_deploy
[[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE"
if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then
echo "[deploy-git] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template"
cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
else
fail "Remote .env is missing and deploy.env.template was not present"
fi
fi
merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
cd "$DEPLOY_PATH"
echo "[deploy-git] Validating compose configuration"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null
if [[ -n "$SERVICE_NAME" ]]; then
AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)"
if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then
fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')"
fi
fi
if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
[[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC"
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
fail "nginx container is missing the maintenance bind mount.
Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
One-time setup on the droplet:
mkdir -p ${MAINTENANCE_HOST_DIR}/m
# add this volume to ${NGINX_COMPOSE_FILE}:
# - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d"
fi
FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")"
[[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR"
echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR"
mkdir -p "$MAINTENANCE_HOST_DIR/m"
install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html"
install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png"
echo "[deploy-git] Updating shared nginx config (pre-rebuild) so maintenance routing is active"
mkdir -p "$(dirname "$NGINX_TARGET")"
cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET"
echo "[deploy-git] Validating nginx configuration"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
echo "[deploy-git] Reloading shared nginx so the new config (incl. maintenance routing) is live"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
echo "[deploy-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH"
: > "$MAINTENANCE_FLAG_PATH"
MAINTENANCE_ACTIVE=1
fi
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME"
else
echo "[deploy-git] Stopping only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
fi
echo "[deploy-git] Current Goodwalk container status"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then
echo "[deploy-git] Syncing homepage content into PostgreSQL"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
fi
clear_maintenance_flag
check_site
echo "[deploy-git] Remote deployment finished"
echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION"
+9
View File
@@ -0,0 +1,9 @@
services:
lean101:
build: .
image: lean-101-website:latest
container_name: lean101-website-flask
restart: unless-stopped
env_file: .env.production
ports:
- "127.0.0.1:${WEBSITE_APP_PORT:-8083}:8000"
+2267
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
Flask>=3.0
resend>=2.0
python-dotenv>=1.0
+952
View File
@@ -0,0 +1,952 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resources — Lean 101</title>
<meta name="description" content="Tools, training, book summaries and posts from Lean 101 — the methods, frameworks and reading that inform the work we do.">
<link rel="preconnect" href="https://api.fontshare.com">
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&display=swap" rel="stylesheet">
<style>
:root {
--blue: #0070c7;
--blue-deep: #005aa3;
--orange: #ec8b33;
--charcoal: #565656;
--grey: #efefef;
--grey-soft: #f6f7f9;
--ink: #1a1a1a;
--white: #ffffff;
--accent: var(--blue);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: 'General Sans', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--ink);
background: var(--white);
font-weight: 400;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img { max-width: 100%; display: block; }
a { color: inherit; }
/* Anchor offset so the sticky nav doesn't cover section headings */
section[id] { scroll-margin-top: 88px; }
/* ───────── NAV ───────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.nav-inner {
max-width: 1100px;
margin: 0 auto;
padding: 0 28px;
height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
display: flex;
align-items: center;
text-decoration: none;
}
.nav-logo img {
height: 44px;
width: 44px;
object-fit: contain;
}
.nav-links {
display: flex;
gap: 30px;
list-style: none;
margin: 0;
padding: 0;
}
.nav-links > li { position: relative; }
.nav-links > li > a {
color: var(--ink);
text-decoration: none;
font-size: 14px;
font-weight: 500;
opacity: 0.85;
transition: opacity 0.2s;
display: inline-flex;
align-items: center;
padding: 6px 0;
}
.nav-links > li > a:hover { opacity: 1; }
/* Active-page indicator — small accent underline below current page link */
.nav-links > li > a.current {
opacity: 1;
position: relative;
}
.nav-links > li > a.current::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 2px;
border-radius: 2px;
background: var(--accent, var(--blue));
}
/* Chevron button — separate from the link, click-only behaviour */
.nav-chevron {
background: transparent;
border: 0;
padding: 6px 4px;
margin: 0 0 0 2px;
cursor: pointer;
color: var(--ink);
opacity: 0.6;
display: inline-flex;
align-items: center;
transition: opacity 0.2s, transform 0.2s ease;
}
.nav-chevron:hover { opacity: 1; }
.nav-chevron svg { display: block; width: 11px; height: 11px; }
.nav-chevron[aria-expanded="true"] {
opacity: 1;
transform: rotate(180deg);
}
/* ───── Mega-panel ───── */
.nav-panel-wrap {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
padding-top: 14px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s;
z-index: 200;
}
.nav-panel-wrap.is-open {
opacity: 1;
visibility: visible;
}
.nav-panel {
background: var(--white);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 14px;
padding: 28px;
box-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.20),
0 6px 16px -6px rgba(0, 0, 0, 0.08);
list-style: none;
margin: 0;
display: grid;
gap: 28px;
width: max-content;
max-width: min(960px, calc(100vw - 56px));
transform: translateY(-6px);
transition: transform 0.2s ease;
}
.nav-panel-wrap.is-open .nav-panel { transform: translateY(0); }
.nav-panel.is-services {
grid-template-columns: repeat(3, minmax(180px, 1fr));
min-width: 640px;
}
.nav-panel.is-resources {
grid-template-columns: repeat(4, minmax(160px, 1fr));
min-width: 760px;
}
.nav-panel-col { display: flex; flex-direction: column; gap: 6px; }
.nav-panel-col > a,
.nav-panel-col-head {
text-decoration: none;
color: var(--ink);
}
.nav-panel-col-head {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
color: var(--ink);
transition: color 0.15s ease;
}
a.nav-panel-col-head:hover { color: var(--blue); }
.nav-panel-col-desc {
font-size: 13px;
line-height: 1.5;
color: rgba(86, 86, 86, 0.85);
margin: 0;
}
.nav-panel-col-link {
margin-top: 4px;
font-size: 12.5px;
font-weight: 500;
color: var(--blue);
display: inline-flex;
align-items: center;
gap: 4px;
text-decoration: none;
transition: gap 0.15s ease;
}
.nav-panel-col-link:hover { gap: 8px; }
.nav-panel-col-tag {
display: inline-block;
margin-top: 4px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(86, 86, 86, 0.65);
}
.nav-cta {
background: var(--ink);
color: white;
padding: 9px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: background 0.2s, transform 0.2s;
}
.nav-cta:hover {
background: var(--blue-deep);
transform: translateY(-1px);
}
/* ─── Hamburger button (mobile only) ─── */
.nav-hamburger {
display: none;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 10px;
padding: 8px;
cursor: pointer;
color: var(--ink);
transition: background 0.2s, border-color 0.2s;
margin-left: 12px;
}
.nav-hamburger:hover {
background: var(--grey-soft);
border-color: rgba(0, 0, 0, 0.2);
}
.nav-hamburger svg { display: block; width: 22px; height: 22px; }
/* ─── Mobile menu (full-screen takeover) ─── */
.nav-drawer-overlay {
position: fixed;
inset: 0;
background: transparent;
opacity: 0;
pointer-events: none;
z-index: 99;
}
.nav-drawer-overlay.is-open { pointer-events: auto; }
.nav-drawer {
position: fixed;
inset: 0;
width: 100%;
height: 100vh;
background: var(--white);
transform: translateY(-100%);
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 100;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.nav-drawer.is-open { transform: translateY(0); }
.nav-drawer-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 28px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.nav-drawer-head img { height: 30px; width: auto; }
.nav-drawer-close {
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 10px;
padding: 10px;
cursor: pointer;
color: var(--ink);
transition: background 0.2s, border-color 0.2s;
}
.nav-drawer-close:hover {
background: var(--grey-soft);
border-color: rgba(0, 0, 0, 0.2);
}
.nav-drawer-close svg { display: block; width: 20px; height: 20px; }
.nav-drawer-body {
padding: 36px 28px 28px;
flex: 1;
display: flex;
flex-direction: column;
}
.nav-drawer ul {
list-style: none;
padding: 0;
margin: 0;
}
.nav-drawer-item {
display: block;
padding: 18px 0;
color: var(--ink);
text-decoration: none;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: color 0.15s, padding-left 0.2s ease;
}
.nav-drawer-item:hover { color: var(--blue); padding-left: 6px; }
.nav-drawer-group-label {
display: block;
padding: 28px 0 12px;
color: var(--charcoal);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.nav-drawer-subitem {
display: block;
padding: 16px 0 16px 14px;
color: var(--ink);
text-decoration: none;
font-size: 18px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-left: 3px solid transparent;
transition: color 0.15s, border-left-color 0.2s ease, padding-left 0.2s ease;
}
.nav-drawer-subitem:hover {
color: var(--blue);
border-left-color: var(--blue);
padding-left: 20px;
}
.nav-drawer-subitem.coaching:hover {
color: var(--orange);
border-left-color: var(--orange);
}
.nav-drawer-foot {
padding: 24px 28px 32px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.nav-drawer-cta {
display: block;
text-align: center;
background: var(--blue);
color: var(--white);
padding: 16px 22px;
border-radius: 999px;
font-size: 16px;
font-weight: 500;
text-decoration: none;
transition: background 0.2s;
}
.nav-drawer-cta:hover { background: var(--blue-deep); }
.nav-drawer-subitem.current {
color: var(--blue);
border-left-color: var(--blue);
padding-left: 20px;
}
.nav-drawer-subitem.coaching.current {
color: var(--orange);
border-left-color: var(--orange);
}
body.nav-drawer-locked { overflow: hidden; }
@media (max-width: 720px) {
.nav-links { display: none; }
.nav-cta { display: none; }
.nav-hamburger { display: inline-flex; align-items: center; justify-content: center; }
}
/* ─── PAGE HERO ─── */
.page-hero {
padding: 110px 28px 90px;
background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%);
}
.page-hero-inner { max-width: 900px; margin: 0 auto; text-align: center; }
.page-eyebrow {
display: inline-block; color: var(--accent); font-size: 13px;
font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
margin-bottom: 16px;
}
.page-hero h1 {
font-size: clamp(36px, 5.5vw, 64px); font-weight: 600;
letter-spacing: -0.03em; line-height: 1.05; margin-bottom: 22px;
}
.page-hero h1 em {
font-style: normal; color: var(--accent);
}
.page-hero-sub {
font-size: clamp(17px, 1.6vw, 20px); color: var(--charcoal);
max-width: 720px; margin: 0 auto; line-height: 1.55;
}
/* ─── SECTIONS ─── */
.section { padding: 110px 28px; }
.section.alt { background: var(--grey-soft); }
.section-inner { max-width: 1100px; margin: 0 auto; }
.section-head { max-width: 720px; margin-bottom: 48px; }
.section-eyebrow {
color: var(--blue); font-size: 14px; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 14px;
display: block;
}
.section-title {
font-size: clamp(30px, 3.5vw, 42px); font-weight: 600;
letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 16px;
}
.section-lede {
font-size: 17px; color: var(--charcoal); line-height: 1.6;
}
/* ─── Resource cards ─── */
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.resource-card {
background: var(--white);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 14px;
padding: 26px;
display: flex;
flex-direction: column;
gap: 10px;
text-decoration: none;
color: var(--ink);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
position: relative;
}
.resource-card[href]:hover {
transform: translateY(-2px);
border-color: rgba(0, 0, 0, 0.12);
box-shadow: 0 12px 28px -12px rgba(0,0,0,0.12);
}
.resource-card-source {
align-self: flex-start;
font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: rgba(86, 86, 86, 0.7);
padding: 4px 8px;
border-radius: 4px;
background: var(--grey-soft);
}
.resource-card h3 {
font-size: 19px; font-weight: 600;
letter-spacing: -0.01em; line-height: 1.3;
}
.resource-card p {
font-size: 14.5px; color: var(--charcoal); line-height: 1.55;
}
.resource-card-cta {
margin-top: auto;
padding-top: 8px;
font-size: 13px; font-weight: 500;
color: var(--blue);
display: inline-flex; align-items: center; gap: 4px;
transition: gap 0.15s ease;
}
.resource-card[href]:hover .resource-card-cta { gap: 8px; }
/* Empty-state cards have a softer look */
.resource-card.is-empty {
background: var(--grey-soft);
border-style: dashed;
border-color: rgba(0, 0, 0, 0.10);
}
.resource-card.is-empty:hover {
transform: none;
box-shadow: none;
}
.resource-card.is-empty h3 {
color: var(--charcoal);
}
/* ─── CTA section ─── */
.cta-wrap {
padding: 100px 28px;
background: var(--ink);
color: white;
}
.cta-card {
max-width: 760px;
margin: 0 auto;
text-align: center;
padding: 60px 40px;
}
.cta-card h2 {
font-size: clamp(30px, 3.5vw, 44px);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.15;
margin-bottom: 18px;
}
.cta-card p {
font-size: 17px;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 32px;
line-height: 1.6;
}
.cta-card .btn-primary {
background: var(--blue);
color: var(--white);
}
.cta-card .btn-primary:hover { background: var(--blue-deep); }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 14px 26px; border-radius: 999px; font-size: 15px; font-weight: 500;
text-decoration: none; transition: all 0.2s; border: none; cursor: pointer;
}
.btn-arrow { transition: transform 0.2s; }
.btn:hover .btn-arrow { transform: translateX(3px); }
/* ─── FOOTER ─── */
.footer {
background: #0a0a0a;
color: rgba(255,255,255,0.7);
padding: 80px 28px 40px;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
}
.footer-grid {
display: grid;
grid-template-columns: 1.6fr 1fr 1fr 1fr 1fr;
gap: 40px;
margin-bottom: 48px;
}
@media (max-width: 720px) {
.footer-grid { grid-template-columns: 1fr 1fr; }
}
.footer-brand img {
max-height: 56px;
width: auto;
margin-bottom: 18px;
}
.footer-brand p {
font-size: 14px;
color: rgba(255,255,255,0.6);
line-height: 1.5;
}
.footer-col h5 {
font-size: 13px;
font-weight: 600;
color: var(--white);
margin-bottom: 16px;
letter-spacing: 0.02em;
}
.footer-col ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.footer-col a {
color: rgba(255,255,255,0.6);
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.footer-col a:hover { color: var(--orange); }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.08);
padding-top: 24px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: rgba(255,255,255,0.5);
}
@media (max-width: 720px) {
.section { padding: 80px 22px; }
.page-hero { padding: 80px 22px 60px; }
.cta-wrap { padding: 70px 22px; }
.cta-card { padding: 40px 24px; }
section[id] { scroll-margin-top: 80px; }
}
</style>
</head>
<body>
<!-- ───── NAV ───── -->
<nav class="nav" aria-label="Primary">
<div class="nav-inner">
<a href="../index.html" class="nav-logo" aria-label="Lean 101 home">
<img src="../assets/lean101-isotipo.png" alt="Lean 101">
</a>
<ul class="nav-links">
<li><a href="../index.html">Home</a></li>
<li class="has-panel">
<a href="../index.html#services">Services</a>
<button class="nav-chevron" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="nav-panel-services" aria-label="Open Services menu" data-panel-trigger="services">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 4.5 6 8 9 4.5"/></svg>
</button>
<div class="nav-panel-wrap" id="nav-panel-services" data-panel="services" role="region" aria-label="Services menu">
<div class="nav-panel is-services">
<div class="nav-panel-col">
<a href="../services/consulting.html" class="nav-panel-col-head">Process Excellence</a>
<p class="nav-panel-col-desc">Diagnosing where work breaks down, designing the fix, and embedding the change with you.</p>
<a href="../services/consulting.html" class="nav-panel-col-link">Learn more <span aria-hidden="true"></span></a>
</div>
<div class="nav-panel-col">
<a href="../services/coaching.html" class="nav-panel-col-head">Coaching &amp; Training</a>
<p class="nav-panel-col-desc">Building the capability for your team to lead improvement themselves.</p>
<a href="../services/coaching.html" class="nav-panel-col-link">Learn more <span aria-hidden="true"></span></a>
</div>
<div class="nav-panel-col">
<a href="../services/digital-solutions.html" class="nav-panel-col-head">Digital Solutions</a>
<p class="nav-panel-col-desc">Custom dashboards, web apps and automation, built for your specific work.</p>
<a href="../services/digital-solutions.html" class="nav-panel-col-link">Learn more <span aria-hidden="true"></span></a>
</div>
</div>
</div>
</li>
<li class="has-panel">
<a href="index.html" class="current">Resources</a>
<button class="nav-chevron" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="nav-panel-resources" aria-label="Open Resources menu" data-panel-trigger="resources">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 4.5 6 8 9 4.5"/></svg>
</button>
<div class="nav-panel-wrap" id="nav-panel-resources" data-panel="resources" role="region" aria-label="Resources menu">
<div class="nav-panel is-resources">
<div class="nav-panel-col">
<a href="#principles" class="nav-panel-col-head">Principles</a>
<p class="nav-panel-col-desc">The why of Lean — the philosophy underneath the methods.</p>
<span class="nav-panel-col-tag">Coming soon</span>
</div>
<div class="nav-panel-col">
<a href="#techniques" class="nav-panel-col-head">Techniques</a>
<p class="nav-panel-col-desc">The how of Lean — specific methods explained in plain terms.</p>
<span class="nav-panel-col-tag">Coming soon</span>
</div>
<div class="nav-panel-col">
<a href="#reading" class="nav-panel-col-head">Reading</a>
<p class="nav-panel-col-desc">Summaries of the books and articles that shaped how we think.</p>
<span class="nav-panel-col-tag">Coming soon</span>
</div>
<div class="nav-panel-col">
<a href="#templates" class="nav-panel-col-head">Templates</a>
<p class="nav-panel-col-desc">Worksheets, checklists and audits you can download and use.</p>
<span class="nav-panel-col-tag">Coming soon</span>
</div>
</div>
</div>
</li>
<li><a href="../index.html#contact">Contact</a></li>
</ul>
<a href="../index.html#contact" class="nav-cta">Let&rsquo;s Talk</a>
<button class="nav-hamburger" type="button" aria-label="Open menu" aria-expanded="false" aria-controls="nav-drawer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
</nav>
<!-- Mobile drawer + overlay -->
<div class="nav-drawer-overlay" data-drawer-overlay aria-hidden="true"></div>
<aside class="nav-drawer" id="nav-drawer" aria-label="Mobile navigation" aria-hidden="true">
<div class="nav-drawer-head">
<img src="../assets/lean101-logotipo.png" alt="Lean 101">
<button class="nav-drawer-close" type="button" aria-label="Close menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<nav class="nav-drawer-body">
<ul>
<li><a class="nav-drawer-item" href="../index.html">Home</a></li>
<li><span class="nav-drawer-group-label">Services</span></li>
<li><a class="nav-drawer-subitem" href="../services/consulting.html">Process Excellence</a></li>
<li><a class="nav-drawer-subitem coaching" href="../services/coaching.html">Coaching &amp; Training</a></li>
<li><a class="nav-drawer-subitem" href="../services/digital-solutions.html">Digital Solutions</a></li>
<li><span class="nav-drawer-group-label">Resources</span></li>
<li><a class="nav-drawer-subitem" href="#principles">Principles</a></li>
<li><a class="nav-drawer-subitem" href="#techniques">Techniques</a></li>
<li><a class="nav-drawer-subitem" href="#reading">Reading</a></li>
<li><a class="nav-drawer-subitem" href="#templates">Templates</a></li>
<li><a class="nav-drawer-item" href="../index.html#contact">Contact</a></li>
</ul>
</nav>
<div class="nav-drawer-foot">
<a href="../index.html#contact" class="nav-drawer-cta">Let&rsquo;s Talk</a>
</div>
</aside>
<!-- ───── PAGE HERO ───── -->
<header class="page-hero">
<div class="page-hero-inner">
<span class="page-eyebrow">Resources</span>
<h1>Tools, methods and the reading <em>behind the work.</em></h1>
<p class="page-hero-sub">
A growing library of templates, training, book summaries and short posts —
the same material we draw on with clients, made available so you can use it on your own.
</p>
</div>
</header>
<!-- ───── PRINCIPLES ───── -->
<section class="section" id="principles">
<div class="section-inner">
<div class="section-head">
<span class="section-eyebrow">Principles</span>
<h2 class="section-title">The <em>why</em> of Lean.</h2>
<p class="section-lede">
The philosophy underneath the techniques — flow, pull, respect for
people, capability over dependency, kaizen culture. The ideas that
explain why the methods work, and what to hold onto when the methods
don&rsquo;t fit the situation.
</p>
</div>
<div class="resource-grid">
<div class="resource-card is-empty">
<span class="resource-card-source">Coming soon</span>
<h3>Principle explainers are in production.</h3>
<p>Long-form pages on the underlying ideas of Lean — what they are, where they came from, and how they show up in real improvement work.</p>
</div>
</div>
</div>
</section>
<!-- ───── TECHNIQUES ───── -->
<section class="section alt" id="techniques">
<div class="section-inner">
<div class="section-head">
<span class="section-eyebrow">Techniques</span>
<h2 class="section-title">The <em>how</em> of Lean.</h2>
<p class="section-lede">
Plain-English explanations of specific methods we use — 5S, Kanban,
Value Stream Mapping, A3, PDCA, Poka-Yoke, Standard Work, Gemba walks.
One page per technique, with what it is, how to run it, and where it
tends to go wrong.
</p>
</div>
<div class="resource-grid">
<div class="resource-card is-empty">
<span class="resource-card-source">Coming soon</span>
<h3>Technique pages in production.</h3>
<p>Each page covers one technique end-to-end — the mechanics, the typical pitfalls, and a worked example. The first pages will land here as they&rsquo;re finished.</p>
</div>
</div>
</div>
</section>
<!-- ───── READING ───── -->
<section class="section" id="reading">
<div class="section-inner">
<div class="section-head">
<span class="section-eyebrow">Reading</span>
<h2 class="section-title">Books and articles behind the work.</h2>
<p class="section-lede">
Distilled summaries of the books and articles that shaped how we think
about improvement — what&rsquo;s still useful, what hasn&rsquo;t aged
well, and where the ideas show up in the work we do today.
</p>
</div>
<div class="resource-grid">
<div class="resource-card is-empty">
<span class="resource-card-source">Coming soon</span>
<h3>First summaries on the way.</h3>
<p>Starting with the books that come up most in our work — The Toyota Way, The Goal, Learning to See, Out of the Crisis. Each summary will link out to the source.</p>
</div>
</div>
</div>
</section>
<!-- ───── TEMPLATES ───── -->
<section class="section alt" id="templates">
<div class="section-inner">
<div class="section-head">
<span class="section-eyebrow">Templates</span>
<h2 class="section-title">Worksheets, checklists and audits.</h2>
<p class="section-lede">
Practical artefacts you can download and use on your own work — 5S audit
checklists, waste walk worksheets, CI readiness self-assessments, and
more as we publish them.
</p>
</div>
<div class="resource-grid">
<div class="resource-card is-empty">
<span class="resource-card-source">Coming soon</span>
<h3>The first templates are on their way.</h3>
<p>We&rsquo;re preparing the first set of templates for download. Follow Lean 101 on LinkedIn to be notified when they go live.</p>
</div>
</div>
</div>
</section>
<!-- ───── CTA ───── -->
<section class="cta-wrap">
<div class="cta-card">
<h2>Have a problem worth solving?</h2>
<p>Tell us a little about your business and what you're looking to improve. We'll get back to you to talk through whether — and how — we can help.</p>
<a href="../index.html#contact" class="btn btn-primary">
Let&rsquo;s Talk <span class="btn-arrow"></span>
</a>
</div>
</section>
<!-- ───── FOOTER ───── -->
<footer class="footer">
<div class="footer-inner">
<div class="footer-grid">
<div class="footer-brand">
<img src="../assets/lean101-logotipo.png" alt="Lean 101">
<p>For businesses that want change to last.</p>
</div>
<div class="footer-col">
<h5>Services</h5>
<ul>
<li><a href="../services/consulting.html">Process Excellence</a></li>
<li><a href="../services/coaching.html">Coaching &amp; Training</a></li>
<li><a href="../services/digital-solutions.html">Digital Solutions</a></li>
</ul>
</div>
<div class="footer-col">
<h5>Resources</h5>
<ul>
<li><a href="#principles">Principles</a></li>
<li><a href="#techniques">Techniques</a></li>
<li><a href="#reading">Reading</a></li>
<li><a href="#templates">Templates</a></li>
</ul>
</div>
<div class="footer-col">
<h5>Company</h5>
<ul>
<li><a href="../index.html#why">About</a></li>
<li><a href="../index.html#process">Approach</a></li>
<li><a href="../index.html#contact">Contact</a></li>
</ul>
</div>
<div class="footer-col">
<h5>Connect</h5>
<ul>
<li><a href="#">LinkedIn</a></li>
<li><a href="mailto:hello@lean-101.com">Email</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 Lean 101. All rights reserved.</span>
<span>Based in Melbourne, working worldwide.</span>
</div>
</div>
</footer>
<script>
// ─── Mobile drawer (hamburger menu) ───
const drawer = document.querySelector('.nav-drawer');
const overlay = document.querySelector('[data-drawer-overlay]');
const hamburger = document.querySelector('.nav-hamburger');
const drawerClose = document.querySelector('.nav-drawer-close');
function openDrawer() {
if (!drawer) return;
drawer.classList.add('is-open');
drawer.setAttribute('aria-hidden', 'false');
overlay.classList.add('is-open');
overlay.setAttribute('aria-hidden', 'false');
hamburger.setAttribute('aria-expanded', 'true');
document.body.classList.add('nav-drawer-locked');
}
function closeDrawer() {
if (!drawer) return;
drawer.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
overlay.classList.remove('is-open');
overlay.setAttribute('aria-hidden', 'true');
hamburger.setAttribute('aria-expanded', 'false');
document.body.classList.remove('nav-drawer-locked');
}
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (drawerClose) drawerClose.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer && drawer.classList.contains('is-open')) {
closeDrawer();
}
});
document.querySelectorAll('.nav-drawer a').forEach(link => {
link.addEventListener('click', closeDrawer);
});
// ─── Desktop mega-panels (Services / Resources) ───
const panelTriggers = document.querySelectorAll('[data-panel-trigger]');
const panels = document.querySelectorAll('[data-panel]');
function closeAllPanels() {
panels.forEach(p => p.classList.remove('is-open'));
panelTriggers.forEach(t => t.setAttribute('aria-expanded', 'false'));
}
panelTriggers.forEach(trigger => {
trigger.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const name = trigger.dataset.panelTrigger;
const panel = document.querySelector(`[data-panel="${name}"]`);
if (!panel) return;
const isOpen = panel.classList.contains('is-open');
closeAllPanels();
if (!isOpen) {
panel.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
}
});
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.has-panel')) {
closeAllPanels();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllPanels();
});
document.querySelectorAll('.nav-panel a').forEach(link => {
link.addEventListener('click', closeAllPanels);
});
</script>
</body>
</html>