Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ hugo.linux

# AI
.claude/

# Attendee sites for the showcase, fetched at build time by bin/showcase-fetch.sh
sites/showcase/static/workshop-*/
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Attendee sites synced from the workshop showcase are served byte-for-byte.
sites/showcase/static/
209 changes: 121 additions & 88 deletions AGENTS.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ talk: # Scaffold a new conference talk: make talk NAME=my-conf-2026
hugo new --kind conference-bundle conferences/$(NAME) --source sites/ainsley-clark
.PHONY: talk

showcase: # Scaffold a new workshop showcase page: make showcase NAME=gophercon-africa-2026
hugo new --kind showcase-bundle $(NAME) --source sites/showcase
.PHONY: showcase

showcase-fetch: # Fetch the attendee sites for the showcase from the workshop repository
./bin/showcase-fetch.sh
.PHONY: showcase-fetch

showcase-sync: # Sync a workshop showcase into sites/showcase: make showcase-sync SRC=path/to/showcase/workshop-x SLUG=event-slug [CONF=conference-bundle-slug]
./bin/showcase-sync.sh $(SRC) $(SLUG) $(CONF)
.PHONY: showcase-sync

deploy-prod: # Deploy production to Vercel
vercel --prod
.PHONY: deploy-prod
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ Then add `images/featured.jpg` and `files/slides.pdf` to the bundle, fill in the
generate a QR code pointing to `https://ainsleyclark.com/conferences/my-conf-2026/`. The page stays in draft (excluded
from production builds) until `draft: false` is set, typically a day before the talk.

**Create a new workshop showcase (showcase.ainsleyclark.com)**

```shell
make showcase NAME=gophercon-africa-2026
make showcase-sync SRC=path/to/workshop-sqlc-templ/showcase/workshop-gophercon-africa-2026 SLUG=gophercon-africa-2026 CONF=gophercon-africa-2026-workshop
```

The attendee sites themselves stay in the workshop repository (`showcase/`): Vercel fetches them into
`sites/showcase/static/` with `bin/showcase-fetch.sh` before every showcase build (set `SHOWCASE_REPO_TOKEN` to a
read-only fine-grained token there) and locally `make showcase-fetch` does the same over SSH. `make showcase-sync` stages
a local build of them for preview, writes 1280×720 thumbnails and the featured/OG mosaics into the showcase bundle, and
copies the mosaics into the matching conference bundle when `CONF` is given. Afterwards list every attendee under `sites:` in the bundle's `index.md` (GitHub login, curated site name and
theme colour). Each Hugo site deploys from its own Vercel project, selected by the `SITE` env var: `ainsley-dev`,
`ainsley-clark` or `showcase`.

## Open Source

ainsley.dev permits the use of any HTML, SCSS and Javascript found within the repository for use with external projects.
Expand Down
10 changes: 8 additions & 2 deletions bin/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ SITE=${SITE:-ainsley-dev}
echo "Vercel Env: $VERCEL_ENV"
echo "Building site: $SITE"

# The showcase serves attendee sites fetched from the workshop repository.
if [[ $SITE == "showcase" ]]; then
./bin/showcase-fetch.sh
fi

if [[ $VERCEL_ENV == "production" ]]; then
echo "Building production"
pnpm run build:prod:$SITE
Expand All @@ -21,8 +26,9 @@ echo "Copying output to root public directory"
rm -rf public
cp -r sites/$SITE/public public

# Optimize images in production builds
if [[ $VERCEL_ENV == "production" ]]; then
# Optimize images in production builds. Skipped for the showcase site, whose
# static files are attendee sites that must be served byte-for-byte.
if [[ $VERCEL_ENV == "production" && $SITE != "showcase" ]]; then
echo "Optimizing images"
pnpm image
fi
8 changes: 4 additions & 4 deletions bin/generate-agents-md.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ OUTPUT="AGENTS.md"

# Directories
DOCS_DIR="docs"
GUIDELINES_DIR="content/guidelines"
GUIDELINES_DIR="sites/ainsley-dev/content/guidelines"

# Technologies to include
TECHNOLOGIES=("go" "js" "html" "scss" "git")
Expand All @@ -38,7 +38,7 @@ cat > "$OUTPUT" << 'EOF'
This file is automatically generated by bin/generate-agents-md.sh
To update this documentation:
1. Edit docs/AGENTS.md for project-specific content
2. Edit content/guidelines/ for coding standards
2. Edit sites/ainsley-dev/content/guidelines/ for coding standards
3. Run: npm run agents
-->

Expand Down Expand Up @@ -77,7 +77,7 @@ for tech in "${TECHNOLOGIES[@]}"; do
echo "Processing $tech guidelines..."

# Add technology section header
echo "## ${tech^^} Guidelines" >> "$OUTPUT"
echo "## $(echo "$tech" | tr "[:lower:]" "[:upper:]") Guidelines" >> "$OUTPUT"
echo "" >> "$OUTPUT"

# Process _index.md if exists
Expand All @@ -92,7 +92,7 @@ for tech in "${TECHNOLOGIES[@]}"; do
filename=$(basename "$file" .md)

# Add subsection header
echo "### ${filename^}" >> "$OUTPUT"
echo "### $(printf "%s" "${filename:0:1}" | tr "[:lower:]" "[:upper:]")${filename:1}" >> "$OUTPUT"
echo "" >> "$OUTPUT"

# Strip frontmatter and demote headings (## becomes ####, ### becomes ####)
Expand Down
55 changes: 55 additions & 0 deletions bin/showcase-fetch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash
#
# Fetches the attendee sites for the showcase from the workshop repository
# into sites/showcase/static, where Hugo serves them byte-for-byte. The tree
# is far too large to commit here, so Vercel runs this before every showcase
# build and developers run it once locally with make showcase-fetch.
#
# On Vercel set SHOWCASE_REPO_TOKEN to a fine-grained personal access token
# with read-only Contents access to the workshop repository; without it the
# script clones over SSH, which is what you want locally.

set -euo pipefail

# Set Variables
REPO="${SHOWCASE_REPO:-ainsleyclark/workshop-sqlc-templ}"
REF="${SHOWCASE_REPO_REF:-main}"
DEST="sites/showcase/static"
TMP="$(mktemp -d)"

cd "$(dirname "$0")/.."
trap 'rm -rf "${TMP}"' EXIT

if [ -n "${SHOWCASE_REPO_TOKEN:-}" ]; then
URL="https://x-access-token:${SHOWCASE_REPO_TOKEN}@github.com/${REPO}.git"
else
URL="git@github.com:${REPO}.git"
fi

# Sparse, blobless clone so only showcase/ is downloaded
echo '--------------------------------------------'
echo "Fetching showcase/ from ${REPO}@${REF}"
echo '--------------------------------------------'
export GIT_TERMINAL_PROMPT=0
git clone --quiet --depth 1 --branch "${REF}" --filter=blob:none --sparse "${URL}" "${TMP}"
git -C "${TMP}" sparse-checkout set --quiet showcase 2>/dev/null || git -C "${TMP}" sparse-checkout set showcase

# One folder per workshop; the generated listing page is replaced by the
# redirect in vercel.json.
mkdir -p "${DEST}"
count=0
for dir in "${TMP}"/showcase/workshop-*/; do
[ -d "${dir}" ] || continue
name="$(basename "${dir}")"
rm -rf "${DEST:?}/${name}"
cp -R "${dir}" "${DEST}/${name}"
rm -f "${DEST}/${name}/index.html"
count=$((count + 1))
echo " ${name}: $(find "${DEST}/${name}" -type f | wc -l | tr -d ' ') files"
done

if [ "${count}" -eq 0 ]; then
echo "No workshop-*/ folders found under showcase/ in ${REPO}@${REF}"
exit 1
fi
echo "Fetched ${count} workshop(s) into ${DEST}"
123 changes: 123 additions & 0 deletions bin/showcase-sync.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/bin/bash
#
# Shell script to generate the thumbnails and mosaics a showcase page uses
# from a workshop's `showcase build` output, and to stage that output under
# sites/showcase/static (git-ignored) for local preview. Production fetches
# the same tree from the workshop repository with bin/showcase-fetch.sh.
#
# Usage:
# ./bin/showcase-sync.sh <path-to-showcase/workshop-dir> <event-slug> [conference-bundle-slug]
#
# Example:
# ./bin/showcase-sync.sh ~/workshop-sqlc-templ/showcase/workshop-gophercon-africa-2026 \
# gophercon-africa-2026 gophercon-africa-2026-workshop
#
# Set FEATURED to a comma separated list of GitHub logins to place
# them first in the mosaics, e.g. FEATURED="ru0ya,MUKE-coder".

set -euo pipefail

USAGE="Usage: showcase-sync.sh <path-to-workshop-dir> <event-slug> [conference-bundle-slug]"

# Set Variables
SRC="${1:?${USAGE}}"
SLUG="${2:?${USAGE}}"
CONF="${3:-}"
SRC="${SRC%/}"
REPO="$(basename "${SRC}")"
STATIC="sites/showcase/static/${REPO}"
BUNDLE="sites/showcase/content/${SLUG}/images"
OG_STATIC="sites/showcase/static/opengraph.jpg"
CONF_IMAGES=""
QUALITY=80

cd "$(dirname "$0")/.."

# Check inputs
if [ ! -d "${SRC}" ]; then
echo "Workshop directory not found: ${SRC}"
exit 1
fi
if [ ! -d node_modules/sharp ]; then
echo "Run pnpm install to get sharp before generating thumbnails"
exit 1
fi
if [ -n "${CONF}" ]; then
CONF_IMAGES="sites/ainsley-clark/content/conferences/${CONF}/images"
mkdir -p "${CONF_IMAGES}"
fi

# Sync static files
echo '--------------------------------------------'
echo "Syncing ${SRC} -> ${STATIC}"
echo '--------------------------------------------'
mkdir -p "${STATIC}" "${BUNDLE}"
rsync -a --delete --exclude='.DS_Store' "${SRC}/" "${STATIC}/"
# The generated listing page is replaced by the redirect in vercel.json.
rm -f "${STATIC}/index.html"

# Thumbnails & mosaics
echo '--------------------------------------------'
echo 'Generating thumbnails and mosaics'
echo '--------------------------------------------'
node - "${STATIC}" "${BUNDLE}" "${QUALITY}" "${OG_STATIC}" "${CONF_IMAGES}" "${FEATURED:-}" <<'JS'
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

const [src, bundle, q, ogStatic, confImages, featured] = process.argv.slice(2);
const quality = Number(q);
const background = '#0a0a0a';
const first = featured ? featured.split(',').map((s) => s.trim().toLowerCase()) : [];

const login = (hero) => path.basename(path.dirname(hero)).toLowerCase();
const heroes = fs
.readdirSync(src, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
.map((d) => path.join(src, d.name, 'screenshot-hero.jpg'))
.filter((f) => fs.existsSync(f))
.sort((a, b) => {
const ia = first.indexOf(login(a)), ib = first.indexOf(login(b));
if (ia !== ib) return (ia === -1 ? 1e9 : ia) - (ib === -1 ? 1e9 : ib);
return login(a).localeCompare(login(b));
});

const mosaic = async (out, width, height, cols, rows, tile, gap) => {
const picks = heroes.slice(0, cols * rows);
const top0 = Math.round((height - (rows * tile.h + (rows + 1) * gap)) / 2);
const tiles = [];
for (let i = 0; i < picks.length; i++) {
tiles.push({
input: await sharp(picks[i]).resize(tile.w, tile.h).toBuffer(),
left: gap + (i % cols) * (tile.w + gap),
top: top0 + gap + Math.floor(i / cols) * (tile.h + gap),
});
}
await sharp({ create: { width, height, channels: 3, background } })
.composite(tiles)
.jpeg({ quality })
.toFile(out);
};

(async () => {
for (const hero of heroes) {
await sharp(hero).resize(1280, 720).jpeg({ quality }).toFile(path.join(bundle, `${login(hero)}.jpg`));
}
await mosaic(path.join(bundle, 'featured.jpg'), 1440, 1130, 3, 4, { w: 464, h: 261 }, 12);
await mosaic(path.join(bundle, 'og.jpg'), 1200, 630, 3, 2, { w: 384, h: 216 }, 12);
fs.copyFileSync(path.join(bundle, 'og.jpg'), ogStatic);
if (confImages) {
fs.copyFileSync(path.join(bundle, 'featured.jpg'), path.join(confImages, 'featured.jpg'));
fs.copyFileSync(path.join(bundle, 'og.jpg'), path.join(confImages, 'og.jpg'));
}
console.log(`Wrote ${heroes.length} thumbnails and 2 mosaics to ${bundle}`);
})().catch((err) => {
console.error(err);
process.exit(1);
});
JS

echo '--------------------------------------------'
echo "Done. Review the 'sites:' list in sites/showcase/content/${SLUG}/index.md"
echo "against the folders in ${STATIC} (new or removed logins)."
echo '--------------------------------------------'
55 changes: 29 additions & 26 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,30 @@ ainsley.dev is a professional portfolio and agency website showcasing digital cr
## Directory Structure

```
/home/user/website/
├── content/ # Hugo content (markdown files)
│ ├── guidelines/ # Developer guidelines
│ ├── insights/ # Blog posts
│ ├── portfolio/ # Portfolio projects
│ ├── services/ # Service pages
│ └── about/ # About pages
├── api/ # Go API backend
│ ├── _pkg/ # API packages
│ ├── _sdk/ # Generated SDK
│ └── _mocks/ # Test mocks
├── layouts/ # Hugo templates
├── assets/ # Source files
│ ├── js/ # TypeScript files
│ ├── scss/ # Stylesheets
│ └── images/ # Images
├── config/ # Hugo configuration
│ ├── _default/ # Base config
│ ├── production/ # Production overrides
│ └── staging/ # Staging overrides
├── static/ # Static files (copied as-is)
├── bin/ # Build scripts
└── public/ # Generated output (git-ignored)
website/
├── sites/ # One Hugo site per domain, selected on Vercel with SITE=<name>
│ ├── ainsley-dev/ # ainsley.dev (dev port 3000)
│ ├── ainsley-clark/ # ainsleyclark.com (dev port 3001)
│ │ └── content/
│ │ ├── conferences/ # Talks and workshops, aggregated under /media/
│ │ └── insights/ # Blog posts
│ └── showcase/ # showcase.ainsleyclark.com (dev port 3002)
│ ├── content/<event>/ # One showcase bundle per workshop (make showcase)
│ └── static/<repo>/ # Attendee sites, git-ignored, fetched by bin/showcase-fetch.sh at build time
├── themes/shared/ # Layouts, SCSS, TypeScript and static assets shared by every site
├── api/ # Go API backend
│ ├── _pkg/ # API packages
│ ├── _sdk/ # Generated SDK
│ └── _mocks/ # Test mocks
├── openapi/ # OpenAPI specification
├── bin/ # Build, image and showcase sync scripts
├── docs/ # Source for the generated AGENTS.md
└── public/ # Vercel output (git-ignored)
```

Every site has the same shape: `archetypes/`, `assets/`, `config/{_default,production,staging}/`, `content/`,
`layouts/` and `static/`. Site `layouts/` and `assets/` override `themes/shared/`.

## Development Workflow

### Setup
Expand All @@ -65,14 +64,16 @@ ainsley.dev is a professional portfolio and agency website showcasing digital cr
# Install dependencies
pnpm install

# Local development server (runs both sites simultaneously with colors)
# Local development server (runs all three sites simultaneously with colors)
# - ainsley-dev on http://localhost:3000
# - ainsley-clark on http://localhost:3001
# - showcase on http://localhost:3002
pnpm dev

# Single site development
pnpm dev:ainsley-dev # ainsley-dev only
pnpm dev:ainsley-clark # ainsley-clark only
pnpm dev:showcase # showcase.ainsleyclark.com on http://localhost:3002

# Or use Vercel dev server
vercel dev
Expand All @@ -87,6 +88,7 @@ pnpm build
# Development build (single site)
pnpm build:ainsley-dev
pnpm build:ainsley-clark
pnpm build:showcase # showcase.ainsleyclark.com (run make showcase-fetch first)

# Staging build (both sites, minified)
pnpm build:staging
Expand Down Expand Up @@ -149,9 +151,10 @@ The Go API is located in `/api/_pkg/` and provides:
## Deployment

### Automatic Deployment
- **Main branch** → Production (ainsley.dev)
- **Main branch** → Production (ainsley.dev, ainsleyclark.com, showcase.ainsleyclark.com)
- **Staging branch** → Staging (staging.ainsley.dev)
- Handled automatically by Vercel on git push
- Handled automatically by Vercel on git push. Each site is its own Vercel project on this repository,
selected with the `SITE` env var (`ainsley-dev`, `ainsley-clark` or `showcase`) read by `bin/build.sh`.

### Manual Deploy
```bash
Expand Down
Loading
Loading