Files
Alan Wizemann 4140983866 feat(site): marketing landing page for Mac + ScarfGo
Replace the gh-pages root placeholder with a real landing page that
sells both apps. Sources live at site/landing/ and publish through a
new scripts/site.sh that mirrors scripts/catalog.sh and scripts/wiki.sh
(check / build / preview / serve / publish, two-pass secret-scan, only
touches root files + assets/ on gh-pages so appcast.xml and templates/
stay disjoint).

Page is rust-palette tokens mapped from ScarfDesign, semantic HTML,
SEO + AEO infra (OpenGraph, Twitter cards, JSON-LD SoftwareApplication
+ MobileApplication + FAQPage, llms.txt, sitemap, manifest), 12-entry
FAQ, light/dark via prefers-color-scheme + manual toggle that swaps
both site chrome and screenshot variants. tools/og-image.html renders
the 1200x630 OG / 1200x600 Twitter cards via headless Chromium.

Real captures from the live Mac app (9 surfaces x light + dark) +
existing ScarfGo screenshots round out the imagery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:41:37 +02:00

279 lines
9.4 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Scarf landing-site helper — builds the marketing landing page from
# site/landing/ and (on `publish`) commits + pushes to gh-pages.
#
# Usage:
# ./scripts/site.sh check # validate that all required files exist
# ./scripts/site.sh build # render to .gh-pages-worktree/ root (with token substitution)
# ./scripts/site.sh preview [PORT] # build + serve on localhost:PORT (default 8000) + open browser
# ./scripts/site.sh serve [PORT] # serve .gh-pages-worktree/ without rebuilding (default 8000)
# ./scripts/site.sh publish # check + build + secret-scan + commit + push gh-pages (root files only)
# ./scripts/site.sh --help # this help
#
# Path discipline. This script ONLY touches root-level landing files plus the
# top-level assets/ directory on gh-pages. It NEVER touches:
# - appcast.xml (owned by scripts/release.sh)
# - templates/ (owned by scripts/catalog.sh)
# All three publishers stay on disjoint paths.
#
# Bootstrap (one-time): a .gh-pages-worktree/ clone of the gh-pages branch.
# scripts/release.sh creates it on first use. If missing:
# git worktree add .gh-pages-worktree gh-pages
#
# Token substitution. index.html and sitemap.xml.tmpl are run through a
# minimal {{TOKEN}} replacement at build time:
# {{VERSION}} — current Scarf version (read from appcast.xml on
# gh-pages, or "unreleased" if not found)
# {{LASTMOD}} — today's date in YYYY-MM-DD
# {{TEMPLATE_URLS}} — <url> entries for every template in
# templates/catalog.json (only used in sitemap.xml.tmpl)
set -euo pipefail
# ---------- config ----------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GHPAGES_DIR="$REPO_ROOT/.gh-pages-worktree"
SRC_DIR="$REPO_ROOT/site/landing"
PY="${PYTHON:-python3}"
# Files we OWN on gh-pages root. Anything else stays untouched.
OWNED_ROOT_FILES=(
index.html
styles.css
app.js
llms.txt
robots.txt
sitemap.xml
manifest.webmanifest
favicon.ico
apple-touch-icon.png
)
# ---------- helpers (same shape as scripts/catalog.sh / wiki.sh) ----------
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
need_src() {
[[ -d "$SRC_DIR" ]] || die "missing $SRC_DIR"
for f in index.html styles.css app.js llms.txt robots.txt sitemap.xml.tmpl manifest.webmanifest favicon.ico apple-touch-icon.png; do
[[ -e "$SRC_DIR/$f" ]] || die "missing required source file: $SRC_DIR/$f"
done
[[ -d "$SRC_DIR/assets" ]] || die "missing $SRC_DIR/assets/"
}
need_ghpages() {
[[ -e "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
Run: git worktree add .gh-pages-worktree gh-pages"
}
# ---------- token resolvers ----------
# Pull current version from appcast.xml on gh-pages (preferred — reflects
# what's actually shipped). Fall back to "unreleased".
resolve_version() {
if [[ -f "$GHPAGES_DIR/appcast.xml" ]]; then
APPCAST="$GHPAGES_DIR/appcast.xml" "$PY" -c '
import os, re
src = open(os.environ["APPCAST"], "r", encoding="utf-8").read()
# Sparkle uses <sparkle:shortVersionString>X.Y.Z</sparkle:shortVersionString>.
# Take the first match (newest entry — appcast is reverse-chronological).
m = re.search(r"<sparkle:shortVersionString>([^<]+)</sparkle:shortVersionString>", src)
print(m.group(1) if m else "unreleased")
'
else
echo "unreleased"
fi
}
# Render <url> entries for each template in catalog.json. The catalog lives
# at templates/catalog.json on gh-pages (built by scripts/catalog.sh).
resolve_template_urls() {
local catalog="$GHPAGES_DIR/templates/catalog.json"
if [[ ! -f "$catalog" ]]; then
return 0
fi
"$PY" - <<'PY' "$catalog"
import json, sys, datetime
catalog = json.load(open(sys.argv[1], 'r', encoding='utf-8'))
today = datetime.date.today().isoformat()
out = []
for tpl in catalog.get("templates", []):
slug = tpl.get("slug") or tpl.get("id") or ""
if not slug:
continue
out.append(
f' <url>\n'
f' <loc>https://awizemann.github.io/scarf/templates/{slug}/</loc>\n'
f' <lastmod>{today}</lastmod>\n'
f' <changefreq>monthly</changefreq>\n'
f' <priority>0.6</priority>\n'
f' </url>'
)
print("\n".join(out))
PY
}
# Apply {{TOKEN}} substitution: substitute_tokens VERSION LASTMOD TEMPLATE_URLS SRC_FILE DEST_FILE
substitute_tokens() {
local version="$1"
local lastmod="$2"
local template_urls="$3"
local src_file="$4"
local dest_file="$5"
VERSION="$version" LASTMOD="$lastmod" TEMPLATE_URLS="$template_urls" \
SRC="$src_file" DEST="$dest_file" \
"$PY" -c '
import os
src_path = os.environ["SRC"]
dest_path = os.environ["DEST"]
with open(src_path, "r", encoding="utf-8") as fh:
text = fh.read()
text = text.replace("{{VERSION}}", os.environ["VERSION"])
text = text.replace("{{LASTMOD}}", os.environ["LASTMOD"])
text = text.replace("{{TEMPLATE_URLS}}", os.environ["TEMPLATE_URLS"])
with open(dest_path, "w", encoding="utf-8") as fh:
fh.write(text)
'
}
# ---------- secret-scan (mirrors scripts/wiki.sh + catalog.sh) ----------
hard_regex='(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|ghs_[A-Za-z0-9]{30,}|ghu_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|ghr_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY-----|BEGIN OPENSSH PRIVATE KEY)'
scan_hard_source() {
# Pre-build pass: scan source files (text only — image content is on the
# author to review visually). Catches accidentally-pasted credentials.
local hits
hits="$(grep -rInE --exclude-dir=.git --include='*.html' --include='*.css' --include='*.js' --include='*.txt' --include='*.xml' --include='*.json' --include='*.tmpl' --include='*.webmanifest' "$hard_regex" "$SRC_DIR" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" >&2
die "hard-pattern secret match in source — refusing to build."
fi
}
scan_hard_rendered() {
# Post-build pass: scan the gh-pages tree we're about to publish, but
# only the files we own (so we don't false-flag on appcast.xml or
# templates/ which other scripts manage).
local hits=""
for f in "${OWNED_ROOT_FILES[@]}"; do
[[ -f "$GHPAGES_DIR/$f" ]] || continue
case "$f" in
*.png|*.ico|*.jpg|*.jpeg|*.webp) continue ;;
esac
local h
h="$(grep -InE "$hard_regex" "$GHPAGES_DIR/$f" 2>/dev/null || true)"
[[ -n "$h" ]] && hits="$hits$h"$'\n'
done
if [[ -d "$GHPAGES_DIR/assets" ]]; then
local h
h="$(grep -rInE --include='*.html' --include='*.css' --include='*.js' --include='*.txt' --include='*.xml' --include='*.json' --include='*.tmpl' "$hard_regex" "$GHPAGES_DIR/assets" 2>/dev/null || true)"
[[ -n "$h" ]] && hits="$hits$h"$'\n'
fi
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" >&2
die "hard-pattern secret match in rendered site — refusing to publish."
fi
}
# ---------- commands ----------
cmd_check() {
need_src
scan_hard_source
log "Source files OK ($(ls -1 "$SRC_DIR" | wc -l | tr -d ' ') entries; assets/: $(find "$SRC_DIR/assets" -type f | wc -l | tr -d ' ') files)"
}
cmd_build() {
need_src
need_ghpages
scan_hard_source
local version lastmod template_urls
version="$(resolve_version)"
lastmod="$(date -u +%Y-%m-%d)"
template_urls="$(resolve_template_urls)"
log "Building (version=$version, lastmod=$lastmod)"
# Static copies (no substitution needed)
for f in styles.css app.js llms.txt robots.txt manifest.webmanifest favicon.ico apple-touch-icon.png; do
cp "$SRC_DIR/$f" "$GHPAGES_DIR/$f"
done
# Token-substituted: index.html
substitute_tokens "$version" "$lastmod" "$template_urls" \
"$SRC_DIR/index.html" "$GHPAGES_DIR/index.html"
# Token-substituted: sitemap.xml (rendered from .tmpl)
substitute_tokens "$version" "$lastmod" "$template_urls" \
"$SRC_DIR/sitemap.xml.tmpl" "$GHPAGES_DIR/sitemap.xml"
# Sync assets/ — mirror the source tree
rm -rf "$GHPAGES_DIR/assets"
cp -R "$SRC_DIR/assets" "$GHPAGES_DIR/assets"
log "Built into $GHPAGES_DIR/"
}
cmd_preview() {
cmd_build
local port="${1:-8000}"
log "Built. Open http://localhost:$port/ in your browser."
log "Press Ctrl-C to stop the server."
cmd_serve "$port"
}
cmd_serve() {
need_ghpages
local port="${1:-8000}"
log "Serving $GHPAGES_DIR on http://localhost:$port/"
log "Open: http://localhost:$port/"
(cd "$GHPAGES_DIR" && "$PY" -m http.server "$port")
}
cmd_publish() {
need_src
need_ghpages
log "Validating source"
scan_hard_source
log "Building"
cmd_build
log "Secret-scanning rendered site"
scan_hard_rendered
log "Staging + committing gh-pages"
(cd "$GHPAGES_DIR" && git add "${OWNED_ROOT_FILES[@]}" assets/)
if (cd "$GHPAGES_DIR" && git diff --cached --quiet); then
log "No changes to publish."
return 0
fi
local msg
msg="site: rebuild landing page at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
(cd "$GHPAGES_DIR" && git commit -m "$msg")
log "Pushing gh-pages"
(cd "$GHPAGES_DIR" && git push origin gh-pages)
log "Published."
}
cmd_help() {
sed -n '1,32p' "$0" | sed -n '/^# Usage/,/^#$/p'
}
# ---------- dispatch ----------
sub="${1:-help}"
shift || true
case "$sub" in
check) cmd_check "$@" ;;
build) cmd_build "$@" ;;
preview) cmd_preview "$@" ;;
serve) cmd_serve "$@" ;;
publish) cmd_publish "$@" ;;
help|--help|-h) cmd_help ;;
*) die "unknown command: $sub (try --help)" ;;
esac