Initial commit

This commit is contained in:
2026-05-06 22:38:06 +12:00
commit 0342b5fb9d
54 changed files with 22513 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.svelte-kit
build
npm-debug.log
+11
View File
@@ -0,0 +1,11 @@
node_modules/
.svelte-kit/
build/
.env
*.log
*.err
.DS_Store
Thumbs.db
+34
View File
@@ -0,0 +1,34 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
ARG PUBLIC_SITE_URL=http://localhost:8080
ENV PUBLIC_SITE_URL=${PUBLIC_SITE_URL}
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/build ./build
COPY --from=builder /app/static ./static
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1
CMD ["node", "build"]
+165
View File
@@ -0,0 +1,165 @@
# Lean 101 Website
This repo runs as a server-rendered SvelteKit app with a custom Mongo-backed CMS at `/admin`.
## Stack
- `SvelteKit`
- `@sveltejs/adapter-node`
- `MongoDB` via the native `mongodb` driver
- `docker compose` for local and server runtime
## What changed
- The public site now reads page content from MongoDB when `MONGODB_URI` and `MONGODB_DB` are set.
- If MongoDB is not configured, the homepage falls back to the local seed file at `src/lib/content/homepage.json`.
- `/admin` is a custom password-protected editor for:
- pages
- movable page sections
- 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
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
docker compose up --build
```
The site will be available at `http://localhost:8080`.
The app server listens on port `3000` inside the container and is mapped to `8080` on the host.
## Run locally without Docker
```bash
npm install
npm run dev
```
The Vite dev server will run on its normal local port, usually `http://localhost:5173`.
## Production build
```bash
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
- The current contact CTA still uses `mailto:hello@lean-101.com`
- The seed homepage remains in `src/lib/content/homepage.json`
- Live page and blog content is stored in MongoDB once the database env vars are configured
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+500
View File
@@ -0,0 +1,500 @@
#!/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"
+16
View File
@@ -0,0 +1,16 @@
services:
lean101:
build:
context: .
args:
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://localhost:8080}
container_name: lean101-website
environment:
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://localhost:8080}
MONGODB_URI: ${MONGODB_URI:-}
MONGODB_DB: ${MONGODB_DB:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}
ports:
- "8080:3000"
restart: unless-stopped
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"types": ["node"]
}
}
+152
View File
@@ -0,0 +1,152 @@
# Lean101 — Website source files (v3.5)
Hi Matt — these are the latest HTML/CSS/JS files for the Lean101 website. Drop-in replacement for the current site, restructured into a 4-page setup, with photos and the latest engagement-section restructure.
---
## File structure
```
lean101-website/
├── index.html — Homepage
├── services/
│ ├── consulting.html — Process Excellence & Transformation
│ ├── coaching.html — Coaching & Training
│ └── digital-solutions.html — Digital Solutions
├── assets/
│ ├── lean101-isotipo.png — Hexagon mark only (used in nav)
│ └── lean101-logotipo.png — Full wordmark (used in mobile drawer + footer)
└── README.md — This file
```
Every page is fully self-contained — all CSS is inlined in a `<style>` block, all JS is in a single `<script>` block at the bottom, and there are no external CSS/JS dependencies beyond Google Fonts and Unsplash images.
---
## Conventions worth knowing
**HTML structure**
- All four pages share the same `<nav>`, footer, and CTA card markup
- Each page has its own inlined CSS (currently no shared stylesheet — keeps each page self-contained for easy preview, but happy to refactor if you want a shared `styles.css`)
- Pages are responsive down to ~360px wide — break points are at 1100px, 920px, 880px, 720px, 540px, 460px
**CSS variables**
At the top of each `<style>` block:
```css
--blue: #0070c7
--blue-deep: #005aa3
--orange: #ec8b33
--charcoal: #565656
--grey: #efefef
--ink: #1a1a1a
```
Each service page also has `--accent` set to either blue or orange — that drives the page's accent colouring.
**Typography**
- General Sans (Fontshare) — weights 400/500/600/700
- Loaded from `https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&display=swap`
**JavaScript**
There are a few small interactive elements:
- Hero stats counter animation (homepage) — uses `IntersectionObserver`, ticks from 0 to target on scroll-in
- Mobile hamburger drawer — slides in from right, body scroll lock
- Outcomes carousel (homepage) — horizontal scroll-snap with prev/next buttons + mobile pagination dots
- Two accordions in the engagement section (each service page) — animated max-height expand
- Example outcome reveal animation (each service page) — fade-in-and-slide-up on scroll-in
- All animations respect `prefers-reduced-motion`
---
## Section structure on each service page
```
1. Hero (with photo)
2. Who it's for (with photo + 4 problem cards)
3. The framework (DMAIC / SHU-HA-RI / Build cards)
4. What this looks like in practice (2 example cards with photos)
5. The engagement (timeline + 2 accordions)
6. Page footnote
7. CTA
8. Footer
```
The engagement section now folds together what used to be three separate things — the timeline (sizing), the considerations (what shapes the scope), and the deliverables list (what you get). It now reads as one comprehensive "this is the engagement" moment:
- **Timeline first** — three project tiers as starting points, transition arrow, partnership as destination
- **Then two stacked accordions:**
- "What drives scope and timeline" — the three considerations that shape sizing
- "See what's in a typical engagement" — the deliverables list
Both accordions are closed by default. The deliverables list used to be a standalone "What's included" section between Who-it's-for and the framework — that section is gone now and the content lives here, as reference content for prospects who want to verify scope.
---
## ⚠️ Photos — please verify
The site has **12 photos sourced from Unsplash** (free, commercial-use, no attribution required) — placed across the 3 service pages:
| Page | Section | Photo URL |
|---|---|---|
| Consulting | Hero | `photo-1581091226825-a6a2a5aee158` |
| Consulting | Who it's for (anchor) | `photo-1556761175-5973dc0f32e7` |
| Consulting | Examples — Manufacturing | `photo-1565793298595-6a879b1d9492` |
| Consulting | Examples — Services & admin | `photo-1454165804606-c3d57bc86b40` |
| Coaching | Hero | `photo-1521737711867-e3b97375f902` |
| Coaching | Who it's for (anchor) | `photo-1519389950473-47ba0277781c` |
| Coaching | Examples — Leadership development | `photo-1573497019940-1c28c88b4f3e` |
| Coaching | Examples — Frontline team | `photo-1542744173-8e7e53415bb0` |
| Digital | Hero | `photo-1551288049-bebda4e38f71` |
| Digital | Who it's for (anchor) | `photo-1460925895917-afdab827c52f` |
| Digital | Examples — Dashboard | `photo-1543286386-713bdd548da4` |
| Digital | Examples — Custom app | `photo-1517292987719-0369a794ec0f` |
Photos are referenced as direct Unsplash CDN URLs in the HTML, e.g.
```html
<img src="https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?auto=format&fit=crop&w=1600&q=80" alt="...">
```
**Two action items here:**
1. **Please load each page and verify the photos look right.** They were chosen by content/context but not visually verified before integration — there's a chance one or two don't match the intent. If any look off, swap with another Unsplash search, the URL pattern is:
`https://images.unsplash.com/photo-{ID}?auto=format&fit=crop&w=1600&q=80`
2. **Consider self-hosting them.** Currently they load from Unsplash's CDN. This works, but if you'd prefer self-hosted (no external dependency, faster load on poor connections), download each one once and put them in `assets/photos/` — the HTML references will need updating to local paths.
---
## Things that need real values before launch
The following placeholders need swapping with real URLs before going live:
1. **`href="#contact"` on the dark "Book a call" CTA buttons** — these point to `#contact` for now, but should ultimately link to the real Calendly URL (Alex will provide).
2. **`href="#"` on the LinkedIn footer link** — should point to the Lean101 LinkedIn company page.
3. **No favicon yet** — happy to leave this for you to add via your tooling, or we can generate one from the isotipo if helpful.
4. **No Open Graph / Twitter meta tags yet** — also a "before launch" item for clean link previews on LinkedIn / WhatsApp / Slack.
---
## Hero image — important note
We added a **fourth KPI** to the hero on the homepage and changed the desktop design. The hero photo itself stays the same (the team-around-table image you currently have).
**The four KPIs are now:**
- Efficiency `+35%` (blue)
- Cost savings `27%` (orange)
- Lead time `44%` (orange)
- Engagement `+41%` (blue)
In the current source files, the KPIs sit in a 2×2 card panel **below** the hero photo on both desktop and mobile. **On the live site they should sit on top of the photo on desktop, and only drop below on mobile** — that's how the existing live site already works, we're just keeping that pattern. The fourth KPI was added so the desktop overlay layout doesn't have an awkward blank slot in the 2×2 grid.
In short: keep your current desktop overlay layout, add the new fourth KPI value (Engagement +41%), and the source CSS in this package is fine as the mobile-side fallback.
---
## Quick deploy notes
- All paths in the HTML are relative — `assets/...` from the homepage, `../assets/...` from service pages
- No build step required, no preprocessor needed — these are static HTML files ready to serve
- Service pages use clean URLs (`/services/consulting`) — your nginx config or equivalent will need a `try_files $uri $uri.html $uri/ =404;` rule to support both `.html` and clean URLs
If anything's unclear or you want me to refactor anything to fit your tooling better (shared stylesheet, separate JS file, build pipeline-friendly format), just let me know.
— Alex
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2377
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "lean101-website",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"mongo:demo": "node scripts/mongo-demo.mjs"
},
"devDependencies": {
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^25.6.0",
"svelte": "^5.38.7",
"svelte-check": "^4.3.1",
"vite": "^7.1.4"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"mongodb": "^7.2.0",
"mongodb-memory-server": "^11.0.1"
}
}
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
sudo ./scripts/deploy-do.sh --domain example.com [options]
Required:
--domain DOMAIN Primary domain name, for example lean-101.com
Optional:
--email EMAIL Email for Let's Encrypt. If omitted, TLS is skipped.
--app-dir PATH Project directory on the server.
Default: current repo root
--app-port PORT Local Docker port exposed by compose.
Default: 8080
--with-www Also configure and request TLS for www.DOMAIN
--skip-certbot Skip Let's Encrypt even if --email is provided
--help Show this help
Examples:
sudo ./scripts/deploy-do.sh --domain lean-101.com --email ops@example.com --with-www
sudo ./scripts/deploy-do.sh --domain lean-101.com --skip-certbot
EOF
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
echo "Run this script with sudo or as root."
exit 1
fi
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "Missing required command: ${command_name}"
exit 1
fi
}
pick_compose_package() {
if apt-cache show docker-compose-plugin >/dev/null 2>&1; then
echo "docker-compose-plugin"
return
fi
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
echo "docker-compose-v2"
return
fi
echo ""
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DOMAIN=""
EMAIL=""
APP_DIR="${REPO_ROOT}"
APP_PORT="8080"
WITH_WWW="false"
SKIP_CERTBOT="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--domain)
DOMAIN="${2:-}"
shift 2
;;
--email)
EMAIL="${2:-}"
shift 2
;;
--app-dir)
APP_DIR="${2:-}"
shift 2
;;
--app-port)
APP_PORT="${2:-}"
shift 2
;;
--with-www)
WITH_WWW="true"
shift
;;
--skip-certbot)
SKIP_CERTBOT="true"
shift
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1"
usage
exit 1
;;
esac
done
if [[ -z "${DOMAIN}" ]]; then
echo "--domain is required."
usage
exit 1
fi
require_root
require_command apt-get
if [[ ! -f "${APP_DIR}/docker-compose.yml" ]]; then
echo "Could not find docker-compose.yml in ${APP_DIR}"
exit 1
fi
if [[ ! -f "${APP_DIR}/Dockerfile" ]]; then
echo "Could not find Dockerfile in ${APP_DIR}"
exit 1
fi
APT_PACKAGES=(
software-properties-common
docker.io
nginx
certbot
python3-certbot-nginx
)
echo "Installing server packages..."
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common
add-apt-repository -y universe
apt-get update
COMPOSE_PACKAGE="$(pick_compose_package)"
if [[ -z "${COMPOSE_PACKAGE}" ]]; then
echo "Could not find a Docker Compose package via apt."
echo "Expected either docker-compose-plugin or docker-compose-v2."
exit 1
fi
APT_PACKAGES+=("${COMPOSE_PACKAGE}")
DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_PACKAGES[@]}"
echo "Enabling services..."
systemctl enable --now docker
systemctl enable --now nginx
if command -v ufw >/dev/null 2>&1; then
echo "Configuring UFW rules..."
ufw allow OpenSSH >/dev/null 2>&1 || true
ufw allow 'Nginx Full' >/dev/null 2>&1 || true
fi
PUBLIC_SITE_URL="https://${DOMAIN}"
echo "Writing ${APP_DIR}/.env..."
cat > "${APP_DIR}/.env" <<EOF
PUBLIC_SITE_URL=${PUBLIC_SITE_URL}
EOF
echo "Writing ${APP_DIR}/docker-compose.override.yml..."
cat > "${APP_DIR}/docker-compose.override.yml" <<EOF
services:
lean101:
ports:
- "127.0.0.1:${APP_PORT}:3000"
EOF
echo "Building and starting the Docker stack..."
(
cd "${APP_DIR}"
docker compose up -d --build
)
NGINX_SITE="/etc/nginx/sites-available/${DOMAIN}"
NGINX_LINK="/etc/nginx/sites-enabled/${DOMAIN}"
SERVER_ALIASES="${DOMAIN}"
CERTBOT_DOMAINS=(-d "${DOMAIN}")
if [[ "${WITH_WWW}" == "true" ]]; then
SERVER_ALIASES="${DOMAIN} www.${DOMAIN}"
CERTBOT_DOMAINS+=(-d "www.${DOMAIN}")
fi
echo "Writing nginx site config to ${NGINX_SITE}..."
cat > "${NGINX_SITE}" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${SERVER_ALIASES};
location / {
proxy_pass http://127.0.0.1:${APP_PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
if [[ -L /etc/nginx/sites-enabled/default ]]; then
rm -f /etc/nginx/sites-enabled/default
fi
ln -sf "${NGINX_SITE}" "${NGINX_LINK}"
echo "Validating nginx config..."
nginx -t
systemctl reload nginx
if [[ "${SKIP_CERTBOT}" == "false" && -n "${EMAIL}" ]]; then
echo "Requesting Let's Encrypt certificate..."
certbot --nginx \
--non-interactive \
--agree-tos \
--redirect \
-m "${EMAIL}" \
"${CERTBOT_DOMAINS[@]}"
nginx -t
systemctl reload nginx
else
echo "Skipping certbot. The site is currently configured for HTTP only."
fi
echo
echo "Deployment complete."
echo "Project directory: ${APP_DIR}"
echo "Domain: ${DOMAIN}"
echo "Public URL: ${PUBLIC_SITE_URL}"
echo "Container upstream: http://127.0.0.1:${APP_PORT}"
echo
echo "Useful commands:"
echo " cd ${APP_DIR} && docker compose ps"
echo " cd ${APP_DIR} && docker compose logs -f"
echo " systemctl status nginx"
+20
View File
@@ -0,0 +1,20 @@
import { MongoMemoryServer } from 'mongodb-memory-server';
const mongo = await MongoMemoryServer.create({
instance: {
port: 27017,
dbName: 'lean101',
ip: '127.0.0.1'
}
});
console.log(`MongoDB demo server running at ${mongo.getUri()}`);
console.log('Press Ctrl+C to stop it.');
const shutdown = async () => {
await mongo.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+34
View File
@@ -0,0 +1,34 @@
:root {
color-scheme: light;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
img {
display: block;
max-width: 100%;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<script>
// @ts-nocheck
import { onDestroy, onMount } from 'svelte';
import { absoluteUrl } from '$lib/site';
export let page;
export let styleId;
let container;
let styleElement;
function installStyles() {
if (!page.styles) return;
styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.textContent = page.styles;
document.head.appendChild(styleElement);
}
function executeScripts() {
if (!container || !page.scripts?.length) return;
for (const scriptContent of page.scripts) {
const script = document.createElement('script');
script.textContent = scriptContent;
container.appendChild(script);
}
}
onMount(() => {
const existingStyle = document.getElementById(styleId);
if (existingStyle instanceof HTMLStyleElement) {
styleElement = existingStyle;
} else {
installStyles();
}
executeScripts();
});
onDestroy(() => {
styleElement?.remove();
});
</script>
<svelte:head>
<title>{page.title}</title>
{#if page.description}
<meta name="description" content={page.description} />
{/if}
<meta name="robots" content="index,follow" />
<meta name="keywords" content={page.keywords} />
<meta name="theme-color" content="#0070c7" />
<link rel="canonical" href={absoluteUrl(page.path)} />
<link rel="icon" href="/assets/lean101-isotipo.png" />
<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"
/>
<meta property="og:site_name" content="Lean 101" />
<meta property="og:type" content={page.path === '/' ? 'website' : 'article'} />
<meta property="og:title" content={page.title} />
<meta property="og:description" content={page.socialDescription || page.description} />
<meta property="og:url" content={absoluteUrl(page.path)} />
<meta property="og:image" content={page.image.startsWith('http') ? page.image : absoluteUrl(page.image)} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={page.title} />
<meta name="twitter:description" content={page.socialDescription || page.description} />
<meta name="twitter:image" content={page.image.startsWith('http') ? page.image : absoluteUrl(page.image)} />
{#if page.structuredData}
<script type="application/ld+json">{JSON.stringify(page.structuredData)}</script>
{/if}
</svelte:head>
<div bind:this={container}>
{@html page.body}
</div>
+379
View File
@@ -0,0 +1,379 @@
<script>
import { onMount } from 'svelte';
import { absoluteUrl } from '$lib/site';
export let page;
const externalImage = absoluteUrl('/assets/lean101-logotipo.png');
const structuredData =
page.slug === 'home'
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
name: 'Lean 101',
url: absoluteUrl('/'),
image: externalImage,
logo: absoluteUrl('/assets/lean101-isotipo.png'),
description: page.meta.metaDescription,
email: page.meta.contactEmail,
areaServed: 'Worldwide',
serviceType: [
'Process excellence consulting',
'Coaching and training',
'Digital solutions'
]
})
: null;
/** @param {string} tone */
const serviceTagClass = (tone) => {
if (tone === 'orange') return 'service-tag orange';
if (tone === 'charcoal') return 'service-tag charcoal';
return 'service-tag';
};
/** @param {string} href */
const isExternalLink = (href) => href.startsWith('http');
/** @param {string} body */
const toParagraphs = (body) =>
String(body || '')
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
onMount(() => {
if (!window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('in');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.section, .hero-visual, .cta-wrap').forEach((element) => {
element.classList.add('reveal');
observer.observe(element);
});
return () => observer.disconnect();
});
</script>
<svelte:head>
<title>{page.meta.pageTitle}</title>
<meta name="description" content={page.meta.metaDescription} />
<meta name="robots" content="index,follow" />
<meta name="theme-color" content="#0070c7" />
<link rel="canonical" href={absoluteUrl(page.path)} />
<link rel="icon" href="/assets/lean101-isotipo.png" />
<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"
/>
<meta property="og:type" content="website" />
<meta property="og:title" content={page.meta.pageTitle} />
<meta property="og:description" content={page.meta.socialDescription} />
<meta property="og:url" content={absoluteUrl(page.path)} />
<meta property="og:image" content={externalImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={page.meta.pageTitle} />
<meta name="twitter:description" content={page.meta.socialDescription} />
<meta name="twitter:image" content={externalImage} />
{#if structuredData}
<script type="application/ld+json">{structuredData}</script>
{/if}
</svelte:head>
<nav class="nav">
<div class="nav-inner">
<a href="/" class="nav-logo" aria-label="Lean 101 home">
<img src="/assets/lean101-isotipo.png" alt="Lean 101" />
</a>
<ul class="nav-links">
{#each page.nav.links as link}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul>
<a href={page.nav.ctaHref} class="nav-cta">{page.nav.ctaLabel}</a>
</div>
</nav>
{#each page.sections as section}
{#if section.type === 'hero'}
{@const hero = section.data}
<header class="hero" id={section.anchor || undefined}>
<span class="hero-eyebrow">{hero.eyebrow}</span>
<h1>
{hero.titleLead}
{#if hero.titleEmphasis}
<br />
<em>{hero.titleEmphasis}</em>
{/if}
</h1>
<p class="hero-sub">{hero.subtext}</p>
<div class="hero-ctas">
<a href={hero.primaryCta.href} class="btn btn-primary">
{hero.primaryCta.label} <span class="btn-arrow"></span>
</a>
<a href={hero.secondaryCta.href} class="btn btn-secondary">
{hero.secondaryCta.label}
</a>
</div>
<div class="hero-visual">
<img src={hero.image.src} alt={hero.image.alt} />
{#if hero.stats?.length}
<div class="hero-stats">
{#each hero.stats as stat}
<div class="hero-stat">
<div class="hero-stat-label">{stat.label}</div>
<div class={`hero-stat-num ${stat.tone}`}>{stat.value}</div>
</div>
{/each}
</div>
{/if}
</div>
</header>
{:else if section.type === 'services'}
{@const block = section.data}
<section class="section" id={section.anchor || undefined}>
<div class="section-inner">
<div class="services-head">
<div class="section-eyebrow">{block.eyebrow}</div>
<h2 class="section-title">{block.title}</h2>
<p class="section-lede">{block.lede}</p>
</div>
<div class="services-grid">
{#each block.cards as card}
<article class="service-card">
<div class="service-img">
<span class={serviceTagClass(card.tagTone)}>{card.tag}</span>
<img src={card.image.src} alt={card.image.alt} />
</div>
<div class="service-body">
<h3 class="service-title">{card.title}</h3>
<p class="service-desc">{card.description}</p>
</div>
</article>
{/each}
</div>
</div>
</section>
{:else if section.type === 'process'}
{@const block = section.data}
<section class="section process" id={section.anchor || undefined}>
<div class="section-inner">
<div class="services-head">
<div class="section-eyebrow">{block.eyebrow}</div>
<h2 class="section-title">{block.title}</h2>
<p class="section-lede">{block.lede}</p>
</div>
<div class="flow">
{#each block.steps as step, index}
<div class="flow-step">
<div class="flow-step-label">{step.label}</div>
<h3 class="flow-step-title">{step.title}</h3>
<p class="flow-step-desc">{step.description}</p>
<div class="flow-step-outcome">{step.outcome}</div>
</div>
{#if index < block.steps.length - 1}
<div class="flow-arrow" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
</div>
{/if}
{/each}
</div>
</div>
</section>
{:else if section.type === 'outcomes'}
{@const block = section.data}
<section class="section outcomes" id={section.anchor || undefined}>
<div class="section-inner">
<div class="outcomes-head">
<div class="section-eyebrow">{block.eyebrow}</div>
<h2 class="section-title">{block.title}</h2>
<p class="section-lede">{block.lede}</p>
</div>
<div class="levers-grid">
{#each block.items as item, index}
<div class="lever">
<div class="lever-icon">
{#if index === 0}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
{:else if index === 1}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /><path d="m9 12 2 2 4-4" /></svg>
{:else if index === 2}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg>
{:else if index === 3}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></svg>
{/if}
</div>
<span class="lever-num">{item.number}</span>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
{/each}
</div>
</div>
</section>
{:else if section.type === 'why'}
{@const block = section.data}
<section class="section whyus" id={section.anchor || undefined}>
<div class="section-inner">
<div class="whyus-layout">
<div>
<div class="section-eyebrow">{block.eyebrow}</div>
<h2 class="section-title">{block.title}</h2>
<p class="section-lede">{block.lede}</p>
</div>
<ul class="whyus-list">
{#each block.items as item, index}
<li class="whyus-item">
<div class="whyus-icon">
{#if index === 0}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></svg>
{:else if index === 1}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.4 2.4 0 0 1 0-3.4l2.6-2.6a2.4 2.4 0 0 1 3.4 0Z" /><path d="m14.5 12.5 2-2" /><path d="m11.5 9.5 2-2" /><path d="m8.5 6.5 2-2" /><path d="m17.5 15.5 2-2" /></svg>
{:else if index === 2}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10v6M2 10l10-5 10 5-10 5z" /><path d="M6 12v5c3 3 9 3 12 0v-5" /></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" x2="12" y1="20" y2="10" /><line x1="18" x2="18" y1="20" y2="4" /><line x1="6" x2="6" y1="20" y2="16" /></svg>
{/if}
</div>
<div class="whyus-text">
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
</li>
{/each}
</ul>
</div>
</div>
</section>
{:else if section.type === 'richText'}
{@const block = section.data}
<section class="section generic-copy" id={section.anchor || undefined}>
<div class="section-inner generic-copy-inner">
{#if block.eyebrow}
<div class="section-eyebrow">{block.eyebrow}</div>
{/if}
{#if block.title}
<h2 class="section-title">{block.title}</h2>
{/if}
<div class="generic-copy-body">
{#each toParagraphs(block.body) as paragraph}
<p>{paragraph}</p>
{/each}
</div>
</div>
</section>
{:else if section.type === 'cta'}
{@const block = section.data}
<section class="cta-wrap" id={section.anchor || undefined}>
<div class="cta-card">
<div class="cta-inner">
<h2>
{block.titleLead} <em>{block.titleEmphasis}</em> {block.titleTail}
</h2>
<p>{block.body}</p>
<div class="cta-flow">
{#each block.flowSteps as step, index}
<div class="cta-flow-step"><span class="num">{index + 1}</span> {step}</div>
{#if index < block.flowSteps.length - 1}
<span class="cta-flow-arrow"></span>
{/if}
{/each}
</div>
<div class="hero-ctas">
<a href={block.primaryCta.href} class="btn btn-on-dark">
{block.primaryCta.label} <span class="btn-arrow"></span>
</a>
<a href={block.secondaryCta.href} class="btn btn-ghost-dark">
{block.secondaryCta.label}
</a>
</div>
</div>
</div>
</section>
{/if}
{/each}
<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>{page.footer.brandTagline}</p>
</div>
<div class="footer-col">
<h5>Services</h5>
<ul>
{#each page.footer.servicesLinks as link}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul>
</div>
<div class="footer-col">
<h5>Company</h5>
<ul>
{#each page.footer.companyLinks as link}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul>
</div>
<div class="footer-col">
<h5>Connect</h5>
<ul>
{#each page.footer.connectLinks as link}
<li>
<a
href={link.href}
target={isExternalLink(link.href) ? '_blank' : undefined}
rel={isExternalLink(link.href) ? 'noreferrer' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
</div>
<div class="footer-bottom">
<span>© {page.meta.copyrightYear} Lean 101. All rights reserved.</span>
<span>{page.meta.location}</span>
</div>
</div>
</footer>
+251
View File
@@ -0,0 +1,251 @@
{
"meta": {
"pageTitle": "Lean 101 - Smarter Improvement Solutions",
"metaDescription": "Practical consulting, coaching and analytics to help businesses eliminate waste, improve performance, and build a culture of continuous improvement.",
"socialDescription": "Practical consulting, coaching and analytics to help businesses eliminate waste, improve performance, and build a culture that keeps improving.",
"contactEmail": "hello@lean-101.com",
"linkedinUrl": "https://www.linkedin.com/company/lean101/",
"copyrightYear": "2026",
"location": "Based in Melbourne, working worldwide."
},
"nav": {
"links": [
{
"label": "Services",
"href": "#services"
},
{
"label": "How it works",
"href": "#process"
},
{
"label": "Why Lean 101",
"href": "#why"
},
{
"label": "Contact",
"href": "#contact"
}
],
"ctaLabel": "Book a call",
"ctaHref": "#contact"
},
"hero": {
"eyebrow": "Smarter Improvement Solutions",
"titleLead": "Continuous improvement,",
"titleEmphasis": "made simple.",
"subtext": "Practical consulting, coaching and analytics to help businesses eliminate waste, improve performance, and build a culture that keeps improving - long after we've gone.",
"primaryCta": {
"label": "Book a free intro call",
"href": "#contact"
},
"secondaryCta": {
"label": "See how it works",
"href": "#process"
},
"image": {
"src": "https://images.unsplash.com/photo-1542744173-8e7e53415bb0?auto=format&fit=crop&w=1600&q=80",
"alt": "Team collaborating on a process improvement workshop"
},
"stats": [
{
"label": "Efficiency",
"value": "+35%",
"tone": "up"
},
{
"label": "Cost savings",
"value": "-27%",
"tone": "warn"
},
{
"label": "Engagement",
"value": "+41%",
"tone": "morale"
}
]
},
"services": {
"eyebrow": "What we do",
"title": "Three services. One outcome: improvement that lasts.",
"lede": "Frameworks aren't the goal - results are. Every engagement combines consulting, capability building, and digital solutions - sized to what your team can sustain.",
"cards": [
{
"tag": "Consulting",
"tagTone": "default",
"title": "Process Excellence & Transformation",
"description": "We diagnose where work is breaking down, design optimised processes, and run the change with you - not just for you.",
"image": {
"src": "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=900&q=80",
"alt": "Consultant mapping out a process on a whiteboard"
}
},
{
"tag": "Coaching & Training",
"tagTone": "orange",
"title": "Building capability and culture",
"description": "Tailored coaching and bespoke training that turns Lean theory into everyday practice. Your team owns the improvement.",
"image": {
"src": "https://images.unsplash.com/photo-1543269865-cbf427effbad?auto=format&fit=crop&w=900&q=80",
"alt": "Coach working with a small team in a workshop setting"
}
},
{
"tag": "Digital Solutions",
"tagTone": "charcoal",
"title": "Custom apps, dashboards & automation",
"description": "From Power BI dashboards to custom web apps that replace clunky spreadsheets - we build tailored software that streamlines your operations and makes data work for you.",
"image": {
"src": "https://images.unsplash.com/photo-1551434678-e076c223a692?auto=format&fit=crop&w=900&q=80",
"alt": "Custom software being developed on a laptop"
}
}
]
},
"process": {
"eyebrow": "How it works",
"title": "A clear path from waste to flow.",
"lede": "Every engagement follows globally recognised improvement frameworks - adapted to your scale, industry and team capability.",
"steps": [
{
"label": "Step 01",
"title": "Diagnose",
"description": "Process mapping, audits and a maturity assessment. We walk your operation alongside you, talk to the people doing the work, and quantify where the friction sits.",
"outcome": "By the end: a clear map of where time, quality and cost are leaking - and which fix moves the dial first."
},
{
"label": "Step 02",
"title": "Design & pilot",
"description": "We co-design the future state with your team, then run a focused pilot in one area. Real data, real people, real results - before we touch the rest of the business.",
"outcome": "By the end: a proven future state, validated with hard data on a focused pilot."
},
{
"label": "Step 03",
"title": "Roll out",
"description": "Once the pilot proves the gains, we scale the approach across other teams, sites or business units - adapted to each context, with the same disciplined method.",
"outcome": "By the end: the gains scaled across teams, sites or business units - same method, each context."
},
{
"label": "Step 04",
"title": "Sustain",
"description": "Coaching, dashboards and train-the-trainer programs so the improvement becomes how your team operates - without us in the room.",
"outcome": "By the end: the improvement runs without us - your team owns it, the data proves it."
}
]
},
"outcomes": {
"eyebrow": "What good looks like",
"title": "The five things continuous improvement moves.",
"lede": "Industry benchmarks for organisations that commit to a structured improvement program. Whatever your industry, these are the levers that shift.",
"items": [
{
"number": "01",
"title": "Cycle time",
"description": "Get work through faster - fewer handoffs, less waiting, shorter lead times."
},
{
"number": "02",
"title": "Quality",
"description": "Fewer errors, less rework, happier customers - fixed at the source, not at the end."
},
{
"number": "03",
"title": "Cost",
"description": "Eliminate waste from how work is done - and the savings drop straight to the bottom line."
},
{
"number": "04",
"title": "Morale",
"description": "When people fix the things that frustrate them, engagement and retention follow."
},
{
"number": "05",
"title": "Safety",
"description": "Standard work and visual controls reduce incidents - protecting people and uptime alike."
}
]
},
"why": {
"eyebrow": "Why Lean 101",
"title": "One framework. Customised to your business.",
"lede": "Lean 101 is a one-on-one partnership. We bring globally recognised frameworks - Lean, Six Sigma, continuous improvement best practice - and adapt them to how your business actually runs. Every company, every industry is different, so we work alongside you to solve the problems that matter to you - not deliver a generic playbook.",
"items": [
{
"title": "Globally recognised frameworks, practically applied",
"description": "Lean, Six Sigma, and continuous improvement best practice - adapted to how your business actually runs, not lifted from a generic playbook."
},
{
"title": "Customised to each business, not off the shelf",
"description": "Every business is different. Focused coaching, a single process redesign, or full transformation - we shape the engagement around your priorities, capacity, and budget."
},
{
"title": "We build capability, not dependency",
"description": "Our success metric is the day you no longer need us. Train-the-trainer, embedded dashboards, and clear playbooks make the improvement stick."
},
{
"title": "Data-led from day one",
"description": "Power BI dashboards and automated reporting come standard. You see what's working, what isn't, and what to do next - in real time."
}
]
},
"cta": {
"titleLead": "Let's find",
"titleEmphasis": "your",
"titleTail": "first improvement.",
"body": "Start with a free 30-minute intro call. If it's a fit, we'll schedule a 1-hour site visit to walk your operation together - and from there we'll scope a tailored partnership.",
"flowSteps": [
"30-min intro call",
"1-hour site visit",
"Tailored partnership"
],
"primaryCta": {
"label": "Book a free intro call",
"href": "mailto:hello@lean-101.com"
},
"secondaryCta": {
"label": "hello@lean-101.com",
"href": "mailto:hello@lean-101.com"
}
},
"footer": {
"brandTagline": "For businesses that want change to last.",
"servicesLinks": [
{
"label": "Consulting",
"href": "#services"
},
{
"label": "Coaching & Training",
"href": "#services"
},
{
"label": "Analytics",
"href": "#services"
}
],
"companyLinks": [
{
"label": "About",
"href": "#why"
},
{
"label": "Approach",
"href": "#process"
},
{
"label": "Contact",
"href": "#contact"
}
],
"connectLinks": [
{
"label": "LinkedIn",
"href": "https://www.linkedin.com/company/lean101/"
},
{
"label": "Email",
"href": "mailto:hello@lean-101.com"
}
]
}
}
+109
View File
@@ -0,0 +1,109 @@
import crypto from 'node:crypto';
import { env } from '$env/dynamic/private';
export const ADMIN_COOKIE_NAME = 'lean101_admin_session';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7;
/** @param {string} value */
function base64urlEncode(value) {
return Buffer.from(value).toString('base64url');
}
/** @param {string} value */
function base64urlDecode(value) {
return Buffer.from(value, 'base64url').toString('utf8');
}
function getSessionSecret() {
return env.ADMIN_SESSION_SECRET || env.SESSION_SECRET || '';
}
/** @param {string} payload */
function signPayload(payload) {
return crypto.createHmac('sha256', getSessionSecret()).update(payload).digest('base64url');
}
export function isAdminConfigured() {
return Boolean(env.ADMIN_PASSWORD && getSessionSecret());
}
export function getAdminConfigIssues() {
const issues = [];
if (!env.ADMIN_PASSWORD) {
issues.push('Set ADMIN_PASSWORD to enable login.');
}
if (!getSessionSecret()) {
issues.push('Set ADMIN_SESSION_SECRET or SESSION_SECRET to sign admin sessions.');
}
return issues;
}
export function createAdminSessionToken() {
const payload = JSON.stringify({
exp: Date.now() + SESSION_TTL_MS
});
const encodedPayload = base64urlEncode(payload);
const signature = signPayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
/** @param {string} password */
export function isValidAdminPassword(password) {
if (!env.ADMIN_PASSWORD) {
return false;
}
const expected = Buffer.from(env.ADMIN_PASSWORD);
const received = Buffer.from(password || '');
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
/** @param {string | undefined} token */
export function verifyAdminSessionToken(token) {
if (!token || !isAdminConfigured()) {
return false;
}
const [encodedPayload, signature] = token.split('.');
if (!encodedPayload || !signature) {
return false;
}
const expectedSignature = signPayload(encodedPayload);
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(signature);
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
return false;
}
try {
const payload = JSON.parse(base64urlDecode(encodedPayload));
return typeof payload.exp === 'number' && payload.exp > Date.now();
} catch {
return false;
}
}
/**
* @param {URL} url
* @returns {{ path: string, httpOnly: boolean, sameSite: 'lax', secure: boolean, maxAge: number }}
*/
export function getAdminCookieOptions(url) {
return {
path: '/admin',
httpOnly: true,
sameSite: /** @type {'lax'} */ ('lax'),
secure: url.protocol === 'https:',
maxAge: SESSION_TTL_MS / 1000
};
}
+486
View File
@@ -0,0 +1,486 @@
// @ts-nocheck
import { env } from '$env/dynamic/private';
import { MongoClient } from 'mongodb';
import homepageSeed from '$lib/content/homepage.json';
const PAGE_COLLECTION = 'site_pages';
const BLOG_COLLECTION = 'blog_posts';
const LEGACY_COLLECTION = 'site_content';
const LEGACY_HOMEPAGE_KEY = 'homepage';
/** @type {Promise<import('mongodb').MongoClient> | undefined} */
let mongoClientPromise;
function clone(value) {
return structuredClone(value);
}
/** @param {unknown} value */
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** @param {string | undefined} value */
function slugify(value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function buildDefaultNav() {
return clone(homepageSeed.nav);
}
function buildDefaultFooter() {
return clone(homepageSeed.footer);
}
function buildSection(id, type, name, anchor, data) {
return {
id,
type,
name,
anchor,
data: clone(data)
};
}
function buildHomepagePageSeed() {
return {
slug: 'home',
title: 'Homepage',
path: '/',
meta: clone(homepageSeed.meta),
nav: buildDefaultNav(),
footer: buildDefaultFooter(),
sections: [
buildSection('hero', 'hero', 'Hero', null, homepageSeed.hero),
buildSection('services', 'services', 'Services', 'services', homepageSeed.services),
buildSection('process', 'process', 'How It Works', 'process', homepageSeed.process),
buildSection('outcomes', 'outcomes', 'Outcomes', null, homepageSeed.outcomes),
buildSection('why', 'why', 'Why Lean 101', 'why', homepageSeed.why),
buildSection('cta', 'cta', 'Contact CTA', 'contact', homepageSeed.cta)
]
};
}
function buildGenericPageTemplate(input = {}) {
const slug = slugify(input.slug) || 'new-page';
const title = input.title || 'New page';
return {
slug,
title,
path: slug === 'home' ? '/' : `/${slug}`,
meta: {
pageTitle: title,
metaDescription: '',
socialDescription: '',
contactEmail: homepageSeed.meta.contactEmail,
linkedinUrl: homepageSeed.meta.linkedinUrl,
copyrightYear: homepageSeed.meta.copyrightYear,
location: homepageSeed.meta.location
},
nav: buildDefaultNav(),
footer: buildDefaultFooter(),
sections: [
buildSection('hero', 'hero', 'Hero', null, {
eyebrow: 'Page intro',
titleLead: title,
titleEmphasis: '',
subtext: 'Add the opening message for this page.',
primaryCta: { label: 'Contact us', href: '#contact' },
secondaryCta: { label: 'Learn more', href: '#content' },
image: {
src: homepageSeed.hero.image.src,
alt: homepageSeed.hero.image.alt
},
stats: []
}),
buildSection('content', 'richText', 'Rich Text', 'content', {
eyebrow: 'Overview',
title: title,
body:
'Add your page content here. Separate paragraphs with a blank line.\n\nUse this section for general content pages.'
}),
buildSection('cta', 'cta', 'Contact CTA', 'contact', clone(homepageSeed.cta))
]
};
}
function buildBlogPostTemplate(input = {}) {
const slug = slugify(input.slug || input.title) || 'new-post';
const title = input.title || 'New blog post';
return {
slug,
title,
excerpt: 'Add a short summary for this post.',
coverImage: homepageSeed.hero.image.src,
coverAlt: homepageSeed.hero.image.alt,
author: 'Lean 101',
publishedAt: new Date().toISOString(),
status: 'draft',
body:
'Start writing here.\n\nSeparate paragraphs with a blank line for simple formatting.',
tags: ['insight']
};
}
function createLegacyHomePage(content) {
const seed = buildHomepagePageSeed();
return {
...seed,
meta: { ...seed.meta, ...(content.meta || {}) },
nav: { ...seed.nav, ...(content.nav || {}) },
footer: { ...seed.footer, ...(content.footer || {}) },
sections: seed.sections.map((section) => {
const legacyData = content[section.id];
return legacyData ? { ...section, data: clone(legacyData) } : section;
})
};
}
/**
* @param {unknown} seedValue
* @param {unknown} incomingValue
* @returns {unknown}
*/
function mergeWithSeed(seedValue, incomingValue) {
if (Array.isArray(seedValue)) {
return Array.isArray(incomingValue) ? incomingValue : clone(seedValue);
}
if (!isPlainObject(seedValue)) {
return incomingValue ?? seedValue;
}
/** @type {Record<string, unknown>} */
const merged = {};
const seedObject = /** @type {Record<string, unknown>} */ (seedValue);
const incomingObject = /** @type {Record<string, unknown>} */ (
isPlainObject(incomingValue) ? incomingValue : {}
);
for (const key of Object.keys(seedObject)) {
merged[key] = mergeWithSeed(seedObject[key], incomingObject[key]);
}
for (const key of Object.keys(incomingObject)) {
if (!(key in merged)) {
merged[key] = incomingObject[key];
}
}
return merged;
}
/** @param {unknown[]} sections */
function normalizeSections(sections) {
if (!Array.isArray(sections)) {
return [];
}
return sections
.filter(isPlainObject)
.map((section, index) => {
const value = /** @type {Record<string, unknown>} */ (section);
const type = typeof value.type === 'string' ? value.type : 'richText';
return {
id: typeof value.id === 'string' ? value.id : `${type}-${index + 1}`,
type,
name:
typeof value.name === 'string'
? value.name
: type.charAt(0).toUpperCase() + type.slice(1),
anchor: typeof value.anchor === 'string' && value.anchor ? value.anchor : null,
data: isPlainObject(value.data) ? clone(value.data) : {}
};
});
}
/** @param {unknown} page */
export function normalizePageDocument(page) {
const input = isPlainObject(page) ? /** @type {Record<string, unknown>} */ (page) : {};
const seed =
slugify(/** @type {string | undefined} */ (input.slug)) === 'home'
? buildHomepagePageSeed()
: buildGenericPageTemplate({
slug: /** @type {string | undefined} */ (input.slug),
title: /** @type {string | undefined} */ (input.title)
});
const slug = slugify(/** @type {string | undefined} */ (input.slug)) || seed.slug;
const title = typeof input.title === 'string' && input.title.trim() ? input.title.trim() : seed.title;
const normalized = {
slug,
title,
path: slug === 'home' ? '/' : `/${slug}`,
meta: /** @type {any} */ (mergeWithSeed(seed.meta, input.meta)),
nav: /** @type {any} */ (mergeWithSeed(seed.nav, input.nav)),
footer: /** @type {any} */ (mergeWithSeed(seed.footer, input.footer)),
sections: normalizeSections(
Array.isArray(input.sections) && input.sections.length ? input.sections : seed.sections
)
};
return normalized;
}
/** @param {unknown} post */
export function normalizeBlogPost(post) {
const input = isPlainObject(post) ? /** @type {Record<string, unknown>} */ (post) : {};
const seed = buildBlogPostTemplate({
slug: /** @type {string | undefined} */ (input.slug),
title: /** @type {string | undefined} */ (input.title)
});
const normalized = /** @type {any} */ (mergeWithSeed(seed, input));
normalized.slug = slugify(normalized.slug || normalized.title) || seed.slug;
normalized.title = String(normalized.title || seed.title).trim() || seed.title;
normalized.status = normalized.status === 'published' ? 'published' : 'draft';
normalized.tags = Array.isArray(normalized.tags)
? normalized.tags.map((tag) => String(tag).trim()).filter(Boolean)
: [];
normalized.path = `/blog/${normalized.slug}`;
return normalized;
}
export function createBlankPage(input = {}) {
return normalizePageDocument(buildGenericPageTemplate(input));
}
export function createBlankBlogPost(input = {}) {
return normalizeBlogPost(buildBlogPostTemplate(input));
}
export function hasDatabaseConfig() {
return Boolean(env.MONGODB_URI && env.MONGODB_DB);
}
async function getMongoDb() {
if (!hasDatabaseConfig()) {
return null;
}
if (!mongoClientPromise) {
const mongoUri = /** @type {string} */ (env.MONGODB_URI);
mongoClientPromise = new MongoClient(mongoUri).connect();
}
const client = await mongoClientPromise;
return client.db(/** @type {string} */ (env.MONGODB_DB));
}
async function getSafeDb() {
try {
return await getMongoDb();
} catch {
return null;
}
}
async function ensurePageSeeds(db) {
const pagesCollection = db.collection(PAGE_COLLECTION);
const existingHome = await pagesCollection.findOne({ slug: 'home' });
if (existingHome) {
return;
}
const legacyCollection = db.collection(LEGACY_COLLECTION);
const legacyHome = await legacyCollection.findOne({ key: LEGACY_HOMEPAGE_KEY });
const homePage = legacyHome?.content
? createLegacyHomePage(legacyHome.content)
: buildHomepagePageSeed();
const now = new Date();
await pagesCollection.insertOne({
...homePage,
createdAt: now,
updatedAt: now
});
}
export async function listPages() {
const db = await getSafeDb();
if (!db) {
return [buildHomepagePageSeed()];
}
await ensurePageSeeds(db);
const pages = await db
.collection(PAGE_COLLECTION)
.find({})
.sort({ slug: 1 })
.toArray();
return pages
.map((page) => normalizePageDocument(page))
.sort((a, b) => {
if (a.slug === 'home') return -1;
if (b.slug === 'home') return 1;
return a.title.localeCompare(b.title);
});
}
/** @param {string} slug */
export async function getPageBySlug(slug) {
const normalizedSlug = slugify(slug) || 'home';
const db = await getSafeDb();
if (!db) {
return normalizedSlug === 'home' ? buildHomepagePageSeed() : null;
}
await ensurePageSeeds(db);
const page = await db.collection(PAGE_COLLECTION).findOne({ slug: normalizedSlug });
return page ? normalizePageDocument(page) : null;
}
/** @param {unknown} input */
export async function savePage(input) {
const db = await getMongoDb();
if (!db) {
throw new Error('Database is not configured. Set MONGODB_URI and MONGODB_DB.');
}
const page = normalizePageDocument(input);
const now = new Date();
await db.collection(PAGE_COLLECTION).updateOne(
{ slug: page.slug },
{
$set: {
...page,
updatedAt: now
},
$setOnInsert: {
createdAt: now
}
},
{ upsert: true }
);
return {
page,
updatedAt: now
};
}
/** @param {string} slug */
export async function deletePage(slug) {
const normalizedSlug = slugify(slug);
if (!normalizedSlug || normalizedSlug === 'home') {
throw new Error('The homepage cannot be deleted.');
}
const db = await getMongoDb();
if (!db) {
throw new Error('Database is not configured. Set MONGODB_URI and MONGODB_DB.');
}
await db.collection(PAGE_COLLECTION).deleteOne({ slug: normalizedSlug });
}
export async function listBlogPosts(includeDrafts = true) {
const db = await getSafeDb();
if (!db) {
return [];
}
const filter = includeDrafts ? {} : { status: 'published' };
const posts = await db
.collection(BLOG_COLLECTION)
.find(filter)
.sort({ publishedAt: -1, title: 1 })
.toArray();
return posts.map((post) => normalizeBlogPost(post));
}
/** @param {string} slug */
export async function getBlogPostBySlug(slug, includeDrafts = false) {
const normalizedSlug = slugify(slug);
const db = await getSafeDb();
if (!db || !normalizedSlug) {
return null;
}
const filter = includeDrafts
? { slug: normalizedSlug }
: { slug: normalizedSlug, status: 'published' };
const post = await db.collection(BLOG_COLLECTION).findOne(filter);
return post ? normalizeBlogPost(post) : null;
}
/** @param {unknown} input */
export async function saveBlogPost(input) {
const db = await getMongoDb();
if (!db) {
throw new Error('Database is not configured. Set MONGODB_URI and MONGODB_DB.');
}
const post = normalizeBlogPost(input);
const now = new Date();
await db.collection(BLOG_COLLECTION).updateOne(
{ slug: post.slug },
{
$set: {
...post,
updatedAt: now
},
$setOnInsert: {
createdAt: now
}
},
{ upsert: true }
);
return {
post,
updatedAt: now
};
}
/** @param {string} slug */
export async function deleteBlogPost(slug) {
const normalizedSlug = slugify(slug);
const db = await getMongoDb();
if (!db) {
throw new Error('Database is not configured. Set MONGODB_URI and MONGODB_DB.');
}
if (!normalizedSlug) {
throw new Error('Blog post slug is required.');
}
await db.collection(BLOG_COLLECTION).deleteOne({ slug: normalizedSlug });
}
export async function listSitemapEntries() {
const pages = await listPages();
const posts = await listBlogPosts(false);
const pageEntries = pages.map((page) => ({
path: page.path,
updatedAt: null
}));
const blogEntries = posts.map((post) => ({
path: post.path,
updatedAt: post.publishedAt || null
}));
return [...pageEntries, { path: '/blog', updatedAt: null }, ...blogEntries];
}
+16
View File
@@ -0,0 +1,16 @@
import { env } from '$env/dynamic/public';
const fallbackUrl = 'http://localhost:8080';
export const siteUrl = (env.PUBLIC_SITE_URL || fallbackUrl).replace(/\/$/, '');
export const seo = {
title: 'Lean 101 | Continuous Improvement Consulting That Sticks',
description:
'Lean 101 helps teams remove waste, improve flow, build internal capability, and turn process improvement into measurable operating results.',
image: `${siteUrl}/assets/lean101-logotipo.png`
};
export function absoluteUrl(path = '/') {
return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
+123
View File
@@ -0,0 +1,123 @@
// @ts-nocheck
import homeSource from './source/home.html?raw';
import consultingSource from './source/services/consulting.html?raw';
import coachingSource from './source/services/coaching.html?raw';
import digitalSolutionsSource from './source/services/digital-solutions.html?raw';
const defaultKeywords = [
'lean consulting',
'continuous improvement',
'process excellence',
'lean coaching',
'digital solutions',
'operational improvement'
];
function extract(source, pattern, fallback = '') {
const match = source.match(pattern);
return match ? match[1].trim() : fallback;
}
function decodeHtml(value) {
return String(value)
.replaceAll('&amp;', '&')
.replaceAll('&mdash;', '—')
.replaceAll('&ndash;', '')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
function normalizePathing(html) {
return html
.replaceAll('href="../index.html"', 'href="/"')
.replaceAll('href="services/consulting.html"', 'href="/services/consulting"')
.replaceAll('href="services/coaching.html"', 'href="/services/coaching"')
.replaceAll('href="services/digital-solutions.html"', 'href="/services/digital-solutions"')
.replaceAll('href="consulting.html"', 'href="/services/consulting"')
.replaceAll('href="coaching.html"', 'href="/services/coaching"')
.replaceAll('href="digital-solutions.html"', 'href="/services/digital-solutions"')
.replaceAll('href="../index.html#why"', 'href="/#why"')
.replaceAll('href="../index.html#process"', 'href="/#process"')
.replaceAll('href="../index.html#contact"', 'href="/#contact"')
.replaceAll('src="../assets/', 'src="/assets/')
.replaceAll('src="assets/', 'src="/assets/');
}
function extractFirstImage(body) {
const match = body.match(/<img[^>]+src="([^"]+)"/i);
return match ? match[1] : '/assets/lean101-logotipo.png';
}
function buildStructuredData(page) {
const base = {
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
name: 'Lean 101',
url: page.path,
image: page.image,
logo: '/assets/lean101-isotipo.png',
description: page.description,
email: 'hello@lean-101.com',
areaServed: 'Australia',
sameAs: ['https://www.linkedin.com/company/lean101/']
};
if (page.path === '/') {
return {
...base,
serviceType: [
'Process Excellence & Transformation',
'Coaching & Training',
'Digital Solutions'
]
};
}
return {
...base,
'@type': 'Service',
provider: {
'@type': 'Organization',
name: 'Lean 101',
url: '/'
},
serviceType: page.title.replace(/\s+—\s+Lean 101$/, '')
};
}
function parsePage(source, path) {
const title = decodeHtml(extract(source, /<title>([\s\S]*?)<\/title>/i, 'Lean 101'));
const description = extract(
source,
/<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i,
''
);
const styles = extract(source, /<style>([\s\S]*?)<\/style>/i);
const body = normalizePathing(extract(source, /<body[^>]*>([\s\S]*?)<\/body>/i));
const scripts = [...source.matchAll(/<script>([\s\S]*?)<\/script>/gi)].map((match) => match[1].trim());
const image = extractFirstImage(body);
const page = {
title,
description: decodeHtml(description),
socialDescription: decodeHtml(description),
path,
image,
keywords: defaultKeywords.join(', '),
styles,
body,
scripts
};
return {
...page,
structuredData: buildStructuredData(page)
};
}
export const homePage = parsePage(homeSource, '/');
export const consultingPage = parsePage(consultingSource, '/services/consulting');
export const coachingPage = parsePage(coachingSource, '/services/coaching');
export const digitalSolutionsPage = parsePage(
digitalSolutionsSource,
'/services/digital-solutions'
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export const prerender = false;
+5
View File
@@ -0,0 +1,5 @@
<script>
import '../app.css';
</script>
<slot />
+12
View File
@@ -0,0 +1,12 @@
import { error } from '@sveltejs/kit';
import { getPageBySlug } from '$lib/server/content';
export async function load() {
const page = await getPageBySlug('home');
if (!page) {
throw error(404, 'Homepage not found.');
}
return { page };
}
+6
View File
@@ -0,0 +1,6 @@
<script>
import LegacyHtmlPage from '$lib/components/LegacyHtmlPage.svelte';
import { homePage } from '$lib/v3/pages';
</script>
<LegacyHtmlPage page={homePage} styleId="lean101-v3-home" />
+12
View File
@@ -0,0 +1,12 @@
import { error } from '@sveltejs/kit';
import { getPageBySlug } from '$lib/server/content';
export async function load({ params }) {
const page = await getPageBySlug(params.slug);
if (!page || page.slug === 'home') {
throw error(404, 'Page not found.');
}
return { page };
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import SitePage from '$lib/components/SitePage.svelte';
export let data;
</script>
<SitePage page={data.page} />
+198
View File
@@ -0,0 +1,198 @@
import { fail, redirect } from '@sveltejs/kit';
import {
ADMIN_COOKIE_NAME,
createAdminSessionToken,
getAdminConfigIssues,
getAdminCookieOptions,
isAdminConfigured,
isValidAdminPassword,
verifyAdminSessionToken
} from '$lib/server/auth';
import {
createBlankBlogPost,
createBlankPage,
deleteBlogPost,
deletePage,
hasDatabaseConfig,
listBlogPosts,
listPages,
saveBlogPost,
savePage
} from '$lib/server/content';
/** @param {import('@sveltejs/kit').Cookies} cookies */
function isAuthenticated(cookies) {
return verifyAdminSessionToken(cookies.get(ADMIN_COOKIE_NAME));
}
/** @param {FormData} formData @param {string} key */
function getString(formData, key) {
return String(formData.get(key) || '');
}
/** @param {string} raw */
function parseJson(raw) {
return JSON.parse(raw);
}
export async function load({ cookies }) {
const authenticated = isAuthenticated(cookies);
const databaseConfigured = hasDatabaseConfig();
const adminConfigIssues = getAdminConfigIssues();
if (!authenticated) {
return {
authenticated,
databaseConfigured,
adminConfigured: isAdminConfigured(),
adminConfigIssues,
pages: [],
posts: [],
newPageTemplate: createBlankPage(),
newPostTemplate: createBlankBlogPost()
};
}
const [pages, posts] = await Promise.all([listPages(), listBlogPosts(true)]);
return {
authenticated,
databaseConfigured,
adminConfigured: isAdminConfigured(),
adminConfigIssues,
pages,
posts,
newPageTemplate: createBlankPage(),
newPostTemplate: createBlankBlogPost()
};
}
export const actions = {
login: async ({ request, cookies, url }) => {
if (!isAdminConfigured()) {
return fail(500, {
loginError:
'Admin login is not configured. Set ADMIN_PASSWORD and ADMIN_SESSION_SECRET first.'
});
}
const formData = await request.formData();
const password = getString(formData, 'password');
if (!isValidAdminPassword(password)) {
return fail(400, {
loginError: 'Incorrect password.'
});
}
cookies.set(ADMIN_COOKIE_NAME, createAdminSessionToken(), getAdminCookieOptions(url));
throw redirect(303, '/admin');
},
savePage: async ({ request, cookies }) => {
if (!isAuthenticated(cookies)) {
throw redirect(303, '/admin');
}
const formData = await request.formData();
const payload = getString(formData, 'payload');
const selectedPageSlug = getString(formData, 'selectedPageSlug');
const selectedTab = getString(formData, 'selectedTab') || 'pages';
try {
const { page, updatedAt } = await savePage(parseJson(payload));
return {
selectedTab,
selectedPageSlug: page.slug,
saveSuccess: `Saved page "${page.title}" at ${updatedAt.toISOString()}.`
};
} catch (error) {
return fail(400, {
selectedTab,
selectedPageSlug,
saveError: error instanceof Error ? error.message : 'Failed to save page.'
});
}
},
deletePage: async ({ request, cookies }) => {
if (!isAuthenticated(cookies)) {
throw redirect(303, '/admin');
}
const formData = await request.formData();
const slug = getString(formData, 'slug');
const selectedTab = getString(formData, 'selectedTab') || 'pages';
try {
await deletePage(slug);
return {
selectedTab,
selectedPageSlug: 'home',
saveSuccess: `Deleted page "${slug}".`
};
} catch (error) {
return fail(400, {
selectedTab,
selectedPageSlug: slug,
saveError: error instanceof Error ? error.message : 'Failed to delete page.'
});
}
},
savePost: async ({ request, cookies }) => {
if (!isAuthenticated(cookies)) {
throw redirect(303, '/admin');
}
const formData = await request.formData();
const payload = getString(formData, 'payload');
const selectedPostSlug = getString(formData, 'selectedPostSlug');
const selectedTab = getString(formData, 'selectedTab') || 'posts';
try {
const { post, updatedAt } = await saveBlogPost(parseJson(payload));
return {
selectedTab,
selectedPostSlug: post.slug,
saveSuccess: `Saved blog post "${post.title}" at ${updatedAt.toISOString()}.`
};
} catch (error) {
return fail(400, {
selectedTab,
selectedPostSlug,
saveError: error instanceof Error ? error.message : 'Failed to save blog post.'
});
}
},
deletePost: async ({ request, cookies }) => {
if (!isAuthenticated(cookies)) {
throw redirect(303, '/admin');
}
const formData = await request.formData();
const slug = getString(formData, 'slug');
const selectedTab = getString(formData, 'selectedTab') || 'posts';
try {
await deleteBlogPost(slug);
return {
selectedTab,
selectedPostSlug: '',
saveSuccess: `Deleted blog post "${slug}".`
};
} catch (error) {
return fail(400, {
selectedTab,
selectedPostSlug: slug,
saveError: error instanceof Error ? error.message : 'Failed to delete blog post.'
});
}
},
logout: async ({ cookies, url }) => {
cookies.delete(ADMIN_COOKIE_NAME, getAdminCookieOptions(url));
throw redirect(303, '/admin');
}
};
+646
View File
@@ -0,0 +1,646 @@
<script>
// @ts-nocheck
export let data;
export let form;
const sectionTypes = ['hero', 'services', 'process', 'outcomes', 'why', 'richText', 'cta'];
function clone(value) {
return structuredClone(value);
}
function prettyJson(value) {
return JSON.stringify(value, null, 2);
}
function hydratePage(page) {
return {
...clone(page),
navJson: prettyJson(page.nav),
footerJson: prettyJson(page.footer),
sections: page.sections.map((section) => ({
...clone(section),
json: prettyJson(section.data)
}))
};
}
function hydratePost(post) {
return {
...clone(post),
tagsText: Array.isArray(post.tags) ? post.tags.join(', ') : ''
};
}
function buildSectionTemplate(type, index) {
const id = `${type}-${Date.now()}-${index}`;
if (type === 'hero') {
return {
id,
type,
name: 'Hero',
anchor: null,
json: prettyJson({
eyebrow: 'Page intro',
titleLead: 'Add a page heading,',
titleEmphasis: 'right here.',
subtext: 'Use this section for the opening message on the page.',
primaryCta: { label: 'Contact us', href: '#contact' },
secondaryCta: { label: 'Learn more', href: '#content' },
image: {
src: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?auto=format&fit=crop&w=1600&q=80',
alt: 'Hero image'
},
stats: []
})
};
}
if (type === 'richText') {
return {
id,
type,
name: 'Rich text',
anchor: 'content',
json: prettyJson({
eyebrow: 'Overview',
title: 'Section title',
body: 'Write the section body here.\n\nSeparate paragraphs with a blank line.'
})
};
}
if (type === 'cta') {
return {
id,
type,
name: 'Call to action',
anchor: 'contact',
json: prettyJson({
titleLead: "Let's find",
titleEmphasis: 'your',
titleTail: 'next improvement.',
body: 'Add the closing CTA copy here.',
flowSteps: ['Step one', 'Step two', 'Step three'],
primaryCta: { label: 'Book a call', href: 'mailto:hello@lean-101.com' },
secondaryCta: { label: 'hello@lean-101.com', href: 'mailto:hello@lean-101.com' }
})
};
}
return {
id,
type,
name: type.charAt(0).toUpperCase() + type.slice(1),
anchor: null,
json: prettyJson({})
};
}
function serializePage(page) {
return {
slug: page.slug,
title: page.title,
meta: {
pageTitle: page.meta.pageTitle,
metaDescription: page.meta.metaDescription,
socialDescription: page.meta.socialDescription,
contactEmail: page.meta.contactEmail,
linkedinUrl: page.meta.linkedinUrl,
copyrightYear: page.meta.copyrightYear,
location: page.meta.location
},
nav: JSON.parse(page.navJson),
footer: JSON.parse(page.footerJson),
sections: page.sections.map((section) => ({
id: section.id,
type: section.type,
name: section.name,
anchor: section.anchor || null,
data: JSON.parse(section.json)
}))
};
}
function serializePost(post) {
return {
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
coverImage: post.coverImage,
coverAlt: post.coverAlt,
author: post.author,
publishedAt: post.publishedAt,
status: post.status,
body: post.body,
tags: String(post.tagsText || '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
};
}
function getPageSerialization(page) {
try {
return {
value: JSON.stringify(serializePage(page)),
error: ''
};
} catch (error) {
return {
value: '',
error: error instanceof Error ? error.message : 'Invalid page JSON.'
};
}
}
function getPostSerialization(post) {
try {
return {
value: JSON.stringify(serializePost(post)),
error: ''
};
} catch (error) {
return {
value: '',
error: error instanceof Error ? error.message : 'Invalid blog post.'
};
}
}
let activeTab = form?.selectedTab || 'pages';
let pages = data.pages.map(hydratePage);
let posts = data.posts.map(hydratePost);
let selectedPageSlug = form?.selectedPageSlug || pages[0]?.slug || data.newPageTemplate.slug;
let selectedPostSlug = form?.selectedPostSlug || posts[0]?.slug || '';
let newSectionType = 'richText';
let statusMessage = form?.saveSuccess || '';
let errorMessage = form?.saveError || form?.loginError || '';
$: selectedPage =
pages.find((page) => page.slug === selectedPageSlug) || pages[0] || hydratePage(data.newPageTemplate);
$: selectedPost =
posts.find((post) => post.slug === selectedPostSlug) || posts[0] || hydratePost(data.newPostTemplate);
$: currentPageSerialization = selectedPage ? getPageSerialization(selectedPage) : { value: '', error: '' };
$: currentPostSerialization = selectedPost ? getPostSerialization(selectedPost) : { value: '', error: '' };
function addPage() {
const template = hydratePage(data.newPageTemplate);
template.slug = `page-${pages.length + 1}`;
template.title = `New Page ${pages.length + 1}`;
template.meta.pageTitle = template.title;
pages = [...pages, template];
selectedPageSlug = template.slug;
activeTab = 'pages';
}
function deletePageLocally() {
if (selectedPage.slug === 'home') return;
pages = pages.filter((page) => page.slug !== selectedPage.slug);
selectedPageSlug = pages[0]?.slug || 'home';
}
function addSection() {
const section = buildSectionTemplate(newSectionType, selectedPage.sections.length + 1);
selectedPage.sections = [...selectedPage.sections, section];
pages = [...pages];
}
function moveSection(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= selectedPage.sections.length) return;
const sections = [...selectedPage.sections];
const [section] = sections.splice(index, 1);
sections.splice(targetIndex, 0, section);
selectedPage.sections = sections;
pages = [...pages];
}
function removeSection(index) {
selectedPage.sections = selectedPage.sections.filter((_, sectionIndex) => sectionIndex !== index);
pages = [...pages];
}
function addPost() {
const template = hydratePost(data.newPostTemplate);
template.slug = `post-${posts.length + 1}`;
template.title = `New Post ${posts.length + 1}`;
posts = [template, ...posts];
selectedPostSlug = template.slug;
activeTab = 'posts';
}
function deletePostLocally() {
posts = posts.filter((post) => post.slug !== selectedPost.slug);
selectedPostSlug = posts[0]?.slug || '';
}
</script>
<svelte:head>
<meta name="robots" content="noindex, nofollow" />
<title>Lean 101 CMS</title>
</svelte:head>
{#if !data.authenticated}
<section class="admin-shell">
<div class="admin-card admin-login-card">
<div class="admin-brand-lockup">
<img class="admin-brand-logo" src="/assets/lean101-logotipo.png" alt="Lean 101" />
<div class="admin-kicker">Lean 101 CMS</div>
</div>
<h1>Private content access</h1>
<p class="admin-copy">
Sign in to manage pages, rearrange sections, and create blog posts for the site.
</p>
{#if data.adminConfigIssues.length}
<div class="admin-alert admin-alert-error">
<strong>Configuration required.</strong>
<ul class="admin-list">
{#each data.adminConfigIssues as issue}
<li>{issue}</li>
{/each}
</ul>
</div>
{/if}
{#if errorMessage}
<div class="admin-alert admin-alert-error">{errorMessage}</div>
{/if}
<form method="POST" action="?/login" class="admin-form">
<label class="admin-field">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button class="admin-button admin-button-primary" type="submit">Sign in</button>
</form>
</div>
</section>
{:else}
<section class="admin-shell">
<div class="admin-card admin-cms-card">
<header class="admin-topbar">
<div class="admin-brand-lockup">
<img class="admin-brand-logo" src="/assets/lean101-logotipo.png" alt="Lean 101" />
<div class="admin-kicker">Lean 101 CMS</div>
<h1>Pages and publishing</h1>
<p class="admin-copy">
Manage page-level content, move sections within each page, and write blog posts from a
single admin area.
</p>
</div>
<div class="admin-actions">
<a class="admin-button admin-button-secondary" href="/" target="_blank" rel="noreferrer">
Open website
</a>
<a class="admin-button admin-button-secondary" href="/blog" target="_blank" rel="noreferrer">
Open blog
</a>
<form method="POST" action="?/logout">
<button class="admin-button admin-button-ghost" type="submit">Log out</button>
</form>
</div>
</header>
<div class="admin-meta-grid">
<div class="admin-meta-card">
<span class="admin-meta-label">Database</span>
<strong>{data.databaseConfigured ? 'Configured' : 'Missing env vars'}</strong>
</div>
<div class="admin-meta-card">
<span class="admin-meta-label">Pages</span>
<strong>{pages.length}</strong>
</div>
<div class="admin-meta-card">
<span class="admin-meta-label">Blog Posts</span>
<strong>{posts.length}</strong>
</div>
</div>
{#if statusMessage}
<div class="admin-alert admin-alert-success">{statusMessage}</div>
{/if}
{#if errorMessage}
<div class="admin-alert admin-alert-error">{errorMessage}</div>
{/if}
{#if !data.databaseConfigured}
<div class="admin-alert admin-alert-error">
The editor UI still works, but saving requires `MONGODB_URI` and `MONGODB_DB`.
</div>
{/if}
<div class="admin-workspace">
<aside class="admin-sidebar">
<div class="admin-tab-row">
<button
class:admin-tab-active={activeTab === 'pages'}
class="admin-tab"
type="button"
on:click={() => (activeTab = 'pages')}
>
Pages
</button>
<button
class:admin-tab-active={activeTab === 'posts'}
class="admin-tab"
type="button"
on:click={() => (activeTab = 'posts')}
>
Blog Posts
</button>
</div>
{#if activeTab === 'pages'}
<div class="admin-sidebar-head">
<h2>Site pages</h2>
<button class="admin-button admin-button-primary" type="button" on:click={addPage}>
New page
</button>
</div>
<div class="admin-entity-list">
{#each pages as page}
<button
class:admin-entity-active={selectedPageSlug === page.slug}
class="admin-entity-button"
type="button"
on:click={() => (selectedPageSlug = page.slug)}
>
<strong>{page.title}</strong>
<span>{page.path}</span>
</button>
{/each}
</div>
{:else}
<div class="admin-sidebar-head">
<h2>Blog posts</h2>
<button class="admin-button admin-button-primary" type="button" on:click={addPost}>
New post
</button>
</div>
<div class="admin-entity-list">
{#each posts as post}
<button
class:admin-entity-active={selectedPostSlug === post.slug}
class="admin-entity-button"
type="button"
on:click={() => (selectedPostSlug = post.slug)}
>
<strong>{post.title}</strong>
<span>{post.status} · {post.slug}</span>
</button>
{/each}
</div>
{/if}
</aside>
<div class="admin-content-panel">
{#if activeTab === 'pages'}
<div class="admin-editor-header">
<div>
<div class="admin-kicker">Page editor</div>
<h2>{selectedPage.title}</h2>
<p class="admin-copy">
Edit core page metadata and move sections using the controls on each card.
</p>
</div>
<div class="admin-actions">
<a class="admin-button admin-button-secondary" href={selectedPage.path} target="_blank" rel="noreferrer">
View page
</a>
{#if selectedPage.slug !== 'home'}
<button class="admin-button admin-button-ghost" type="button" on:click={deletePageLocally}>
Remove locally
</button>
{/if}
</div>
</div>
<div class="admin-grid-two">
<div class="admin-panel">
<h3>Page settings</h3>
<div class="admin-form-grid">
<label class="admin-field">
<span>Internal title</span>
<input bind:value={selectedPage.title} />
</label>
<label class="admin-field">
<span>Slug</span>
<input bind:value={selectedPage.slug} disabled={selectedPage.slug === 'home'} />
</label>
<label class="admin-field">
<span>Browser title</span>
<input bind:value={selectedPage.meta.pageTitle} />
</label>
<label class="admin-field">
<span>Contact email</span>
<input bind:value={selectedPage.meta.contactEmail} />
</label>
</div>
<label class="admin-field">
<span>Meta description</span>
<textarea class="admin-mini-textarea" bind:value={selectedPage.meta.metaDescription}></textarea>
</label>
<label class="admin-field">
<span>Social description</span>
<textarea class="admin-mini-textarea" bind:value={selectedPage.meta.socialDescription}></textarea>
</label>
<label class="admin-field">
<span>Navigation JSON</span>
<textarea class="admin-json-textarea" bind:value={selectedPage.navJson} spellcheck="false"></textarea>
</label>
<label class="admin-field">
<span>Footer JSON</span>
<textarea class="admin-json-textarea" bind:value={selectedPage.footerJson} spellcheck="false"></textarea>
</label>
</div>
<div class="admin-panel">
<div class="admin-panel-head">
<h3>Sections</h3>
<div class="admin-inline-controls">
<select bind:value={newSectionType} class="admin-select">
{#each sectionTypes as type}
<option value={type}>{type}</option>
{/each}
</select>
<button class="admin-button admin-button-secondary" type="button" on:click={addSection}>
Add section
</button>
</div>
</div>
<div class="admin-section-list">
{#each selectedPage.sections as section, index}
<article class="admin-section-card">
<div class="admin-section-head">
<div>
<span class="admin-section-type">{section.type}</span>
<input class="admin-inline-input" bind:value={section.name} />
</div>
<div class="admin-inline-controls">
<button class="admin-icon-button" type="button" on:click={() => moveSection(index, -1)}>
</button>
<button class="admin-icon-button" type="button" on:click={() => moveSection(index, 1)}>
</button>
<button class="admin-icon-button admin-icon-button-danger" type="button" on:click={() => removeSection(index)}>
×
</button>
</div>
</div>
<label class="admin-field">
<span>Anchor</span>
<input bind:value={section.anchor} placeholder="Optional section id" />
</label>
<label class="admin-field">
<span>Section data JSON</span>
<textarea class="admin-section-textarea" bind:value={section.json} spellcheck="false"></textarea>
</label>
</article>
{/each}
</div>
</div>
</div>
{#if currentPageSerialization.error}
<div class="admin-alert admin-alert-error">
This page has invalid JSON somewhere: {currentPageSerialization.error}
</div>
{/if}
<div class="admin-submit-row">
<form method="POST" action="?/savePage">
<input type="hidden" name="selectedTab" value="pages" />
<input type="hidden" name="selectedPageSlug" value={selectedPage.slug} />
<input type="hidden" name="payload" value={currentPageSerialization.value} />
<button
class="admin-button admin-button-primary"
type="submit"
disabled={!currentPageSerialization.value}
>
Save page
</button>
</form>
{#if selectedPage.slug !== 'home'}
<form method="POST" action="?/deletePage">
<input type="hidden" name="selectedTab" value="pages" />
<input type="hidden" name="slug" value={selectedPage.slug} />
<button class="admin-button admin-button-ghost" type="submit">Delete page</button>
</form>
{/if}
</div>
{:else}
<div class="admin-editor-header">
<div>
<div class="admin-kicker">Blog editor</div>
<h2>{selectedPost.title}</h2>
<p class="admin-copy">
Draft posts here, then mark them published to make them appear on `/blog`.
</p>
</div>
<div class="admin-actions">
{#if selectedPost.slug}
<a
class="admin-button admin-button-secondary"
href={`/blog/${selectedPost.slug}`}
target="_blank"
rel="noreferrer"
>
View post
</a>
{/if}
<button class="admin-button admin-button-ghost" type="button" on:click={deletePostLocally}>
Remove locally
</button>
</div>
</div>
<div class="admin-panel">
<div class="admin-form-grid">
<label class="admin-field">
<span>Title</span>
<input bind:value={selectedPost.title} />
</label>
<label class="admin-field">
<span>Slug</span>
<input bind:value={selectedPost.slug} />
</label>
<label class="admin-field">
<span>Author</span>
<input bind:value={selectedPost.author} />
</label>
<label class="admin-field">
<span>Status</span>
<select bind:value={selectedPost.status} class="admin-select">
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
<label class="admin-field">
<span>Published at</span>
<input bind:value={selectedPost.publishedAt} />
</label>
<label class="admin-field">
<span>Tags</span>
<input bind:value={selectedPost.tagsText} placeholder="lean, ops, process" />
</label>
</div>
<label class="admin-field">
<span>Excerpt</span>
<textarea class="admin-mini-textarea" bind:value={selectedPost.excerpt}></textarea>
</label>
<div class="admin-form-grid">
<label class="admin-field">
<span>Cover image URL</span>
<input bind:value={selectedPost.coverImage} />
</label>
<label class="admin-field">
<span>Cover alt text</span>
<input bind:value={selectedPost.coverAlt} />
</label>
</div>
<label class="admin-field">
<span>Body</span>
<textarea class="admin-post-textarea" bind:value={selectedPost.body}></textarea>
</label>
</div>
<div class="admin-submit-row">
<form method="POST" action="?/savePost">
<input type="hidden" name="selectedTab" value="posts" />
<input type="hidden" name="selectedPostSlug" value={selectedPost.slug} />
<input type="hidden" name="payload" value={currentPostSerialization.value} />
<button class="admin-button admin-button-primary" type="submit">Save post</button>
</form>
{#if selectedPost.slug}
<form method="POST" action="?/deletePost">
<input type="hidden" name="selectedTab" value="posts" />
<input type="hidden" name="slug" value={selectedPost.slug} />
<button class="admin-button admin-button-ghost" type="submit">Delete post</button>
</form>
{/if}
</div>
{/if}
</div>
</div>
</div>
</section>
{/if}
+6
View File
@@ -0,0 +1,6 @@
import { listBlogPosts } from '$lib/server/content';
export async function load() {
const posts = await listBlogPosts(false);
return { posts };
}
+67
View File
@@ -0,0 +1,67 @@
<script>
import { absoluteUrl } from '$lib/site';
export let data;
</script>
<svelte:head>
<title>Lean 101 Blog</title>
<meta
name="description"
content="Insights, practical improvement notes, and continuous improvement articles from Lean 101."
/>
<link rel="canonical" href={absoluteUrl('/blog')} />
</svelte:head>
<nav class="nav">
<div class="nav-inner">
<a href="/" 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="/">Home</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/#contact">Contact</a></li>
</ul>
<a href="/#contact" class="nav-cta">Book a call</a>
</div>
</nav>
<section class="section blog-index">
<div class="section-inner">
<div class="services-head blog-index-head">
<div class="section-eyebrow">Lean 101 Journal</div>
<h1 class="section-title">Improvement ideas, written plainly.</h1>
<p class="section-lede">
Articles, field notes, and practical guidance from Lean 101. Built for leaders and teams
who want useful thinking, not filler.
</p>
</div>
{#if data.posts.length}
<div class="blog-grid">
{#each data.posts as post}
<article class="blog-card">
<a class="blog-card-image" href={post.path}>
<img src={post.coverImage} alt={post.coverAlt} />
</a>
<div class="blog-card-body">
<div class="blog-meta-row">
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
<span>{post.author}</span>
</div>
<h2><a href={post.path}>{post.title}</a></h2>
<p>{post.excerpt}</p>
<a href={post.path} class="blog-link">Read article →</a>
</div>
</article>
{/each}
</div>
{:else}
<div class="blog-empty-state">
<h2>No published posts yet.</h2>
<p>Create one in the admin area and publish it when its ready.</p>
</div>
{/if}
</div>
</section>
+12
View File
@@ -0,0 +1,12 @@
import { error } from '@sveltejs/kit';
import { getBlogPostBySlug } from '$lib/server/content';
export async function load({ params }) {
const post = await getBlogPostBySlug(params.slug, false);
if (!post) {
throw error(404, 'Blog post not found.');
}
return { post };
}
+55
View File
@@ -0,0 +1,55 @@
<script>
import { absoluteUrl } from '$lib/site';
export let data;
const paragraphs = String(data.post.body || '')
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
</script>
<svelte:head>
<title>{data.post.title} | Lean 101 Blog</title>
<meta name="description" content={data.post.excerpt} />
<link rel="canonical" href={absoluteUrl(data.post.path)} />
<meta property="og:title" content={data.post.title} />
<meta property="og:description" content={data.post.excerpt} />
<meta property="og:image" content={data.post.coverImage} />
</svelte:head>
<nav class="nav">
<div class="nav-inner">
<a href="/" 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="/">Home</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/#contact">Contact</a></li>
</ul>
<a href="/#contact" class="nav-cta">Book a call</a>
</div>
</nav>
<article class="section blog-article">
<div class="section-inner blog-article-inner">
<a href="/blog" class="blog-back-link">← Back to blog</a>
<div class="blog-article-head">
<div class="blog-meta-row">
<span>{new Date(data.post.publishedAt).toLocaleDateString()}</span>
<span>{data.post.author}</span>
</div>
<h1 class="section-title">{data.post.title}</h1>
<p class="section-lede">{data.post.excerpt}</p>
</div>
<img class="blog-hero-image" src={data.post.coverImage} alt={data.post.coverAlt} />
<div class="blog-body">
{#each paragraphs as paragraph}
<p>{paragraph}</p>
{/each}
</div>
</div>
</article>
+17
View File
@@ -0,0 +1,17 @@
import { absoluteUrl } from '$lib/site';
export const prerender = false;
export function GET() {
const body = `User-agent: *
Allow: /
Sitemap: ${absoluteUrl('/sitemap.xml')}
`;
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8'
}
});
}
@@ -0,0 +1,6 @@
<script>
import LegacyHtmlPage from '$lib/components/LegacyHtmlPage.svelte';
import { coachingPage } from '$lib/v3/pages';
</script>
<LegacyHtmlPage page={coachingPage} styleId="lean101-v3-coaching" />
@@ -0,0 +1,6 @@
<script>
import LegacyHtmlPage from '$lib/components/LegacyHtmlPage.svelte';
import { consultingPage } from '$lib/v3/pages';
</script>
<LegacyHtmlPage page={consultingPage} styleId="lean101-v3-consulting" />
@@ -0,0 +1,6 @@
<script>
import LegacyHtmlPage from '$lib/components/LegacyHtmlPage.svelte';
import { digitalSolutionsPage } from '$lib/v3/pages';
</script>
<LegacyHtmlPage page={digitalSolutionsPage} styleId="lean101-v3-digital-solutions" />
+31
View File
@@ -0,0 +1,31 @@
import { absoluteUrl } from '$lib/site';
import { listSitemapEntries } from '$lib/server/content';
export const prerender = false;
export async function GET() {
const now = new Date().toISOString();
const entries = await listSitemapEntries();
const urls = entries
.map(
(entry) => ` <url>
<loc>${absoluteUrl(entry.path)}</loc>
<lastmod>${entry.updatedAt || now}</lastmod>
<changefreq>monthly</changefreq>
<priority>${entry.path === '/' ? '1.0' : '0.7'}</priority>
</url>`
)
.join('\n');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(body, {
headers: {
'Content-Type': 'application/xml; charset=utf-8'
}
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+9
View File
@@ -0,0 +1,9 @@
import adapter from '@sveltejs/adapter-node';
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});