diff --git a/.gitignore b/.gitignore index d1ad4f629..b50e66ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -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-*/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..9f0344ed5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Attendee sites synced from the workshop showcase are served byte-for-byte. +sites/showcase/static/ diff --git a/AGENTS.md b/AGENTS.md index 6e02d13e7..0a026908d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 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 --> @@ -38,77 +38,85 @@ 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= +│ ├── 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// # One showcase bundle per workshop (make showcase) +│ └── static// # 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 ```bash # Install dependencies -npm install +pnpm install -# Local development server -npm run serve -# or -vercel dev +# 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 -# Hugo server only (no reload) -npm run serve-no-reload +# Or use Vercel dev server +vercel dev ``` ### Building ```bash -# Development build -npm run build +# Development build (both sites) +pnpm build -# Staging build (minified) -npm run build:staging +# Development build (single site) +pnpm build:ainsley-dev +pnpm build:ainsley-clark +pnpm build:showcase # showcase.ainsleyclark.com (run make showcase-fetch first) -# Production build (minified + optimized images) -npm run build:prod +# Staging build (both sites, minified) +pnpm build:staging -# Test build with API server -npm run build:test +# Production build (both sites, minified) +pnpm build:prod ``` ### Testing ```bash # Run Go tests -make test +pnpm test # Run with coverage -make test-coverage +pnpm cover # Run linting -make lint # All -make lint-go # Go only -make lint-ts # TypeScript only +pnpm lint # All +pnpm lint:go # Go only +pnpm lint:js # JavaScript/TypeScript only +pnpm lint:scss # SCSS only ``` ### Code Quality @@ -120,10 +128,14 @@ Pre-commit hooks automatically run: Manual commands: ```bash -npm run clean # Format with Prettier -npm run lint # Lint SCSS and JS -npm run lint:scss # Lint SCSS only -npm run lint:js # Lint/fix TypeScript +pnpm format # Format all code +pnpm format:go # Format Go +pnpm format:js # Format JS/TS/SCSS +pnpm lint # Lint all +pnpm lint:scss # Lint SCSS +pnpm lint:js # Lint JavaScript/TypeScript +pnpm lint:go # Lint Go +pnpm clean # Clean build artifacts ``` ## API Structure @@ -145,13 +157,15 @@ 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 -make deploy # Deploy to Vercel +pnpm deploy:prod # Deploy to production +pnpm deploy:staging # Deploy to staging ``` ### Environment Configuration @@ -186,28 +200,48 @@ Located in `/bin/`: ## Configuration Files -- **package.json** - Node.js dependencies and scripts +- **package.json** - Node.js dependencies and scripts (pnpm workspaces) - **go.mod** - Go module dependencies - **tsconfig.json** - TypeScript configuration - **vercel.json** - Vercel deployment configuration -- **Makefile** - Build automation commands - **.golangci.yaml** - Go linting rules -## Makefile Commands +## pnpm Scripts ```bash -make setup # Install dependencies -make serve # Run development server -make build # Build the project -make test # Run tests -make test-coverage # Run tests with coverage -make lint # Run all linters -make lint-go # Lint Go code -make lint-ts # Lint TypeScript -make format # Format all code -make sdk # Generate API SDK -make deploy # Deploy to Vercel -make clean # Clean build artifacts +pnpm setup # Install dependencies and setup +pnpm setup:vercel # Setup for Vercel deployment +pnpm dev # Run both Hugo servers (ports 3000 & 3001) +pnpm dev:ainsley-dev # Run ainsley-dev only +pnpm dev:ainsley-clark # Run ainsley-clark only +pnpm build # Build both sites +pnpm build:ainsley-dev # Build ainsley-dev only +pnpm build:ainsley-clark # Build ainsley-clark only +pnpm build:staging # Build staging (both sites) +pnpm build:staging:ainsley-dev # Build staging (ainsley-dev only) +pnpm build:staging:ainsley-clark # Build staging (ainsley-clark only) +pnpm build:prod # Build production (both sites) +pnpm build:prod:ainsley-dev # Build production (ainsley-dev only) +pnpm build:prod:ainsley-clark # Build production (ainsley-clark only) +pnpm test # Run Go tests +pnpm cover # Run tests with coverage report +pnpm lint # Run all linters (Go, JS, SCSS) +pnpm lint:go # Lint Go code +pnpm lint:js # Lint JavaScript/TypeScript +pnpm lint:scss # Lint SCSS +pnpm format # Format all code (Go, JS, TS, SCSS) +pnpm format:go # Format Go code +pnpm format:js # Format JavaScript/TypeScript/SCSS +pnpm sdk # Generate Go & TypeScript API SDKs +pnpm deploy:prod # Deploy to production +pnpm deploy:staging # Deploy to staging +pnpm clean # Clean build artifacts +pnpm mock # Generate mocks +pnpm image # Optimize images +pnpm video # Optimize videos +pnpm agents # Generate agents markdown +pnpm todo # Show TODO items +pnpm precommit # Run pre-commit checks ``` ## Git Workflow @@ -242,13 +276,12 @@ Code Copyright 2023 ainsley.dev. Code released under the BSD-3 Clause licence. ### Comments -- Document all exported types, functions, and constants with Go doc comments. -- Ensure that the comments convey the meaning behind the code, not just the what. +- Document all exported types, functions and constants with Go doc comments. - All comments must end with a full stop, including inline comments and multi-line comments. - Within function bodies, only keep comments that explain _why_ something is done, not _what_ is done. The code itself should be clear enough to show what it does. -- Keep high-level comments that explain the flow or purpose of a section (e.g., "Try loading - template file first", "Fallback to static markdown file"). +- Keep high-level comments that explain the flow or purpose of a section (e.g., 'Try loading + template file first', 'Fallback to static markdown file'). - Remove obvious comments that just restate the code (e.g., "Load base template" before a `LoadTemplate()` call). @@ -271,7 +304,7 @@ Constructors must validate all required dependencies using `enforce` helpers and types. Only to be used in the context of when being called from a `cmd` package. #### New -- Prefer `NewX()` constructors over global initialisation unless it's the only constructor in the package then it will +- Prefer `NewX()` constructors over global initialisation unless, it's the only constructor in the package then it will be written as `New()`. #### Enforce @@ -325,7 +358,7 @@ Use `context.Context` as the first parameter for functions that perform I/O or c #### Maps Over Switch Prefer using maps with function values over switch statements when dispatching based on string or integer keys. This -approach is more maintainable, extensible, and testable. +approach is more maintainable, extensible and testable. **Prefer** @@ -376,7 +409,7 @@ func dispatch(action string, req Request) (Response, error) { - Always check errors, never ignore them with `_` unless absolutely necessary. - If ignoring an error, add a comment explaining why. - Return errors up the stack; don't just log and continue unless appropriate. -- Always prioritise clarity over depth of stack trace — add context that helps debugging, not repetition +- Always prioritise clarity over depth of stack trace — add context that helps debugging, not repetition. #### Domain Error Types @@ -403,9 +436,9 @@ if balance < withdrawAmount { #### Using errors.Wrap Always use `errors.Wrap` from `github.com/pkg/errors` for adding context to errors. Use `fmt.Errorf` -if there are more than one argument that's not an error. +if there is more than one argument that's not an error. -I.e. you can use `fmt.Errorf`, but only when needing to format. +i.e. you can use `fmt.Errorf`, but only when needing to format. **Example:** @@ -462,7 +495,7 @@ Use `context.Context` as the first parameter for functions that perform I/O or c - Keep interfaces small and focused (single responsibility). - Prefer returning concrete types unless abstraction is required for testing or swapping implementations. -- Document interface expectations explicitly (e.g. "implementations must be thread-safe"). +- Document interface expectations explicitly (e.g. 'implementations must be thread-safe'). #### Defining Types @@ -628,14 +661,14 @@ func TestApp_OrderedCommands(t *testing.T) { #### Mocking Mocks should only be introduced when a test depends on an **external interface** or system -boundary — for example, Terraform execution, encryption providers, or file I/O wrappers. +boundary — for example, Terraform execution, encryption providers or file I/O wrappers. - Prefer fakes or real in-memory types where possible. - Place generated mocks under `internal/mocks/` and prefix them with `Mock` (e.g. `MockInfraManager`). - Clean up with `defer ctrl.Finish()` and avoid over-mocking. - Use [`gomock`](https://pkg.go.dev/go.uber.org/mock/gomock) for creating mocks. -- Generate mocks into the `internal/mocks/` directory using below's example. +- Generate mocks into the `internal/mocks/` directory using the example below. **Example:** @@ -652,7 +685,7 @@ go tool go.uber.org/mock/mockgen -source=gen.go -destination ../mocks/fs.go -pac - Accept `t *testing.T` as an argument. - Return any values required by multiple subtests (e.g., test structs, default app objects). - Call `t.Helper()` at the start. -- Use `setup(t)` in subtests to maintain readability, avoid duplication, and keep each test +- Use `setup(t)` in subtests to maintain readability, avoid duplication and keep each test self-contained. ```go @@ -834,11 +867,11 @@ All HTML should be using the [Markup Validation Service](https://validator.w3.or pushing to production. This will help avoid common mistakes such as closing tags, wrong attributes and many more. By validating HTML it ensures that web pages are consistent across multiple devices and platforms and increases the -chance of search engines to properly pass markup. +chance of search engines properly passing markup. #### Indentation -Use tabs instead of spaces for markup. Do not mix tabs with spaces, ensure it is probably formatted. +Use tabs instead of spaces for markup. Do not mix tabs with spaces and ensure it is properly formatted. **Avoid** @@ -858,8 +891,8 @@ Use tabs instead of spaces for markup. Do not mix tabs with spaces, ensure it is #### Quotes -Always use double quotes around attribute values. Emitting quotes can avoid to bad readability, despite HTML allowing -for attributes without quotes. +Always use double quotes around attribute values. Emitting quotes can prevent bad readability, despite HTML allowing for +attributes without quotes. **Avoid** @@ -875,9 +908,9 @@ for attributes without quotes. #### Line breaking -Break long lines when it exceeds the amount of characters within the editor. +Break long lines when it exceeds the number of characters within the editor. -It is also recommended to ensure that the closing tag is one a new line. This helps to locate the closing tag and +It is also recommended to ensure that the closing tag is on a new line. This helps to locate the closing tag and improves readability. **Avoid** @@ -900,7 +933,7 @@ improves readability. #### Letter-casing -All attribute names, classes, IDs should be lower case and with a hyphen between two words (kebab case). +All attribute names, classes and IDs should be lower case and with a hyphen between two words (kebab case). **Avoid** @@ -972,7 +1005,7 @@ scss/ - **Parent selector**: Use `$self: &;` for compound selectors. - **Nesting**: Nest related modifiers but avoid deep nesting (max 3 levels). -#### Breakpoint Mixin +#### Breakpoint Mixins Use breakpoint mixins for responsive design: @@ -1089,7 +1122,7 @@ Follow a conventional commit format with a type prefix and present tense gerund - `feat:` - Adding new features or functionality. - `fix:` - Fixing bugs or issues. -- `chore:` - Updating dependencies, linting, or other maintenance tasks. +- `chore:` - Updating dependencies, linting or other maintenance tasks. - `style:` - Refactoring code or improving code style (no functional changes). - `test:` - Adding or updating tests. - `docs:` - Updating documentation. diff --git a/Makefile b/Makefile index d2cd4ccce..5c19cf164 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f711a69b7..244a83ad7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/build.sh b/bin/build.sh index f0c3fbaa4..a13055ba3 100755 --- a/bin/build.sh +++ b/bin/build.sh @@ -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 @@ -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 diff --git a/bin/generate-agents-md.sh b/bin/generate-agents-md.sh index d888ee435..3edb5ad9f 100755 --- a/bin/generate-agents-md.sh +++ b/bin/generate-agents-md.sh @@ -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") @@ -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 --> @@ -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 @@ -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 ####) diff --git a/bin/showcase-fetch.sh b/bin/showcase-fetch.sh new file mode 100755 index 000000000..7a51cb564 --- /dev/null +++ b/bin/showcase-fetch.sh @@ -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}" diff --git a/bin/showcase-sync.sh b/bin/showcase-sync.sh new file mode 100755 index 000000000..0e741e64a --- /dev/null +++ b/bin/showcase-sync.sh @@ -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 [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 [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 '--------------------------------------------' diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 283119384..5607cad37 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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= +│ ├── 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// # One showcase bundle per workshop (make showcase) +│ └── static// # 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 @@ -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 @@ -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 @@ -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 diff --git a/package.json b/package.json index b1fe3d29f..b2b57ca96 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,22 @@ "precommit": "lint-staged --debug", "setup": "pnpm install && husky install && pnpm add -g vercel && pnpm add -g svgo && go mod tidy && go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest && go generate ./...", "setup:vercel": "pnpm install && sh ./bin/os.sh", - "dev": "concurrently --names \"ainsley-dev,ainsley-clark\" --prefix \"[{name}]\" --prefix-colors \"cyan,magenta\" \"pnpm dev:ainsley-dev\" \"pnpm dev:ainsley-clark\"", + "dev": "concurrently --names \"ainsley-dev,ainsley-clark,showcase\" --prefix \"[{name}]\" --prefix-colors \"cyan,magenta,yellow\" \"pnpm dev:ainsley-dev\" \"pnpm dev:ainsley-clark\" \"pnpm dev:showcase\"", "dev:ainsley-dev": "hugo server --source sites/ainsley-dev --port 3000 --buildDrafts --watch", "dev:ainsley-clark": "hugo server --source sites/ainsley-clark --port 3001 --buildDrafts --watch", + "dev:showcase": "hugo server --source sites/showcase --port 3002 --buildDrafts --watch", "build": "pnpm run build:ainsley-dev && pnpm run build:ainsley-clark", "build:ainsley-dev": "rimraf sites/ainsley-dev/public && hugo --source sites/ainsley-dev --environment development", "build:ainsley-clark": "rimraf sites/ainsley-clark/public && hugo --source sites/ainsley-clark --environment development", + "build:showcase": "rimraf sites/showcase/public && hugo --source sites/showcase --environment development", "build:staging": "pnpm run build:staging:ainsley-dev && pnpm run build:staging:ainsley-clark", "build:staging:ainsley-dev": "rimraf sites/ainsley-dev/public && hugo --source sites/ainsley-dev --environment staging --minify", "build:staging:ainsley-clark": "rimraf sites/ainsley-clark/public && hugo --source sites/ainsley-clark --environment staging --minify", + "build:staging:showcase": "rimraf sites/showcase/public && hugo --source sites/showcase --environment staging --minify", "build:prod": "pnpm run build:prod:ainsley-dev && pnpm run build:prod:ainsley-clark", "build:prod:ainsley-dev": "rimraf sites/ainsley-dev/public && hugo --source sites/ainsley-dev --environment production --minify", "build:prod:ainsley-clark": "rimraf sites/ainsley-clark/public && hugo --source sites/ainsley-clark --environment production --minify", + "build:prod:showcase": "rimraf sites/showcase/public && hugo --source sites/showcase --environment production --minify", "deploy:prod": "vercel --prod", "deploy:staging": "vercel --name staging", "sdk": "oapi-codegen --package=sdk openapi/spec.yaml > api/_sdk/api.gen.go && swagger-typescript-api --path openapi/spec.yaml --output assets/js/api --templates openapi/templates --name SDK --module-name-first-tag", @@ -38,7 +42,7 @@ "image": "./bin/image-optim.sh", "video": "./bin/video-optim.sh", "agents": "./bin/generate-agents-md.sh", - "kill": "sudo kill -9 $(lsof -ti:3000 -ti:3001)", + "kill": "sudo kill -9 $(lsof -ti:3000 -ti:3001 -ti:3002)", "todo": "grep --exclude-dir=.vercel --exclude-dir=.gen --exclude-dir=public --exclude-dir=node_modules --exclude-dir=.git -nRo -E '\\S*[^\\.] TODO.*' ." }, "dependencies": { diff --git a/sites/ainsley-clark/archetypes/conference-bundle/index.md b/sites/ainsley-clark/archetypes/conference-bundle/index.md index 262620384..5dfa1b518 100644 --- a/sites/ainsley-clark/archetypes/conference-bundle/index.md +++ b/sites/ainsley-clark/archetypes/conference-bundle/index.md @@ -17,6 +17,10 @@ event: location: Venue, City url: https://example.com/ # video: https://www.youtube.com/watch?v=xxxx +# links: +# - text: View attendee sites +# url: https://showcase.ainsleyclark.com/{{ .Name }}/ +# title: See what attendees built in the workshop slides: path: files/slides.pdf name: {{ .Name }}-ainsley-clark.pdf diff --git a/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/files/slides.pdf b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/files/slides.pdf new file mode 100644 index 000000000..7ce7bc372 Binary files /dev/null and b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/files/slides.pdf differ diff --git a/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/featured.jpg b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/featured.jpg new file mode 100644 index 000000000..bb9e6978c Binary files /dev/null and b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/featured.jpg differ diff --git a/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/og.jpg b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/og.jpg new file mode 100644 index 000000000..39f390258 Binary files /dev/null and b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/images/og.jpg differ diff --git a/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/index.md b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/index.md new file mode 100644 index 000000000..fb58133fc --- /dev/null +++ b/sites/ainsley-clark/content/conferences/gophercon-africa-2026-workshop/index.md @@ -0,0 +1,76 @@ +--- +title: Full-Stack Go Workshop at GopherCon Africa 2026 +description: A full-day workshop at GopherCon Africa 2026 in Nairobi on building domain-driven, full-stack Go apps with sqlc and templ, and the sites attendees shipped. +heading: GopherCon Africa 2026 Workshop +talkTitle: "Full-Stack Go: Domain-Driven Applications with sqlc and templ" +lead: A full-day, intermediate workshop building a complete Go application, from data ingestion through a type-safe sqlc data layer to a server-rendered templ UI, finishing with a theming block where every attendee shipped their own site. +weight: 1 +publishdate: 2026-10-25 +pageColour: white +draft: false +tags: + - Go + - Workshop + - sqlc + - templ +buttonName: View Workshop +event: + name: GopherCon Africa + date: 2026-10-15 + location: Nairobi, Kenya + url: https://gophercon.africa/ +slides: + path: files/slides.pdf + name: full-stack-go-sqlc-templ-workshop-ainsley-clark.pdf + text: Download slides +links: + - text: View attendee sites + url: https://showcase.ainsleyclark.com/gophercon-africa-2026/ + title: See every website built in the workshop +sources: + - title: 1. sqlc + url: https://sqlc.dev/ + description: Compile SQL into type-safe Go. The data layer of the workshop app. + - title: 2. templ + url: https://templ.guide/ + description: An HTML templating language for Go with compile-time checks. The front-end of the workshop app. + - title: 3. GBIF occurrence API + url: https://www.gbif.org/developer/occurrence + description: The public biodiversity records the sightings app ingests. + - title: 4. Open-Meteo + url: https://open-meteo.com/ + description: Free weather API used to enrich each sighting. + - title: 5. Domain-Driven Design + url: https://www.domainlanguage.com/ddd/ + description: Eric Evans. The reference behind the architecture block. + - title: 6. net/http/pprof + url: https://pkg.go.dev/net/http/pprof + description: Profiling the web handler under load in the profiling block. + - title: 7. Claude Code + url: https://claude.com/claude-code + description: Used with the /design-site skill in the theming block. + - title: 8. GopherCon Africa + url: https://gophercon.africa/ + description: The conference, Nairobi, October 2026. +--- + +Build a complete full-stack application in Go, from data ingestion through persistence to a server-rendered UI, purely in Go. This full-day workshop teaches you how to design maintainable full-stack applications that age well. + +We started from a working application built on a real public API: an animal-sightings site fed by GBIF records, stored in SQLite and enriched with Open-Meteo weather. From there we walked through five different architectures for the same feature (flat, layered, onion, hexagonal and feature-based) to see what domain-driven design actually buys you, then looked at how the app is built: a type-safe data layer with sqlc and a front-end written with templ. The build is a guided walkthrough of real, finished code, so the time goes on the decisions rather than the typing. + +## Agenda + +| Block | Minutes | +| --- | --- | +| Welcome and setup | 10 | +| App intro | 20 | +| Architecture and DDD practices | 50 | +| Querying with sqlc | 40 | +| Intro to templ | 40 | +| Profiling | 20 | +| Theming | 80 | +| Wrapping up | 10 | + +## What attendees built + +The hands-on part came last. Every attendee forked the workshop repository, profiled it under load with pprof and themed their own copy of the site using the `/design-site` skill in Claude Code or JetBrains Air, with two competitions running alongside: Best Website and Best Social Post. Thirty-three themed sites made it into the [showcase](https://showcase.ainsleyclark.com/gophercon-africa-2026/), each served exactly as its author shipped it. diff --git a/sites/ainsley-clark/content/conferences/gophercon-africa-2026/index.md b/sites/ainsley-clark/content/conferences/gophercon-africa-2026/index.md index e0fa6d712..cd34d921f 100644 --- a/sites/ainsley-clark/content/conferences/gophercon-africa-2026/index.md +++ b/sites/ainsley-clark/content/conferences/gophercon-africa-2026/index.md @@ -15,9 +15,13 @@ tags: buttonName: View Talk event: name: GopherCon Africa - date: 2026-10-30 + date: 2026-10-17 # location: Venue, City url: https://gophercon.africa/ +links: + - text: View workshop + url: /conferences/gophercon-africa-2026-workshop/ + title: View the full-stack Go workshop with sqlc and templ # Uncomment once files/slides.pdf has been added. # slides: # path: files/slides.pdf diff --git a/sites/ainsley-clark/layouts/conferences/single.html b/sites/ainsley-clark/layouts/conferences/single.html index e6d3e3c11..bc456b1a8 100644 --- a/sites/ainsley-clark/layouts/conferences/single.html +++ b/sites/ainsley-clark/layouts/conferences/single.html @@ -111,7 +111,7 @@

- {{ if or .Params.slides .Params.video }} + {{ if or .Params.slides .Params.video .Params.links }}
{{ with .Params.slides }} @@ -142,6 +142,18 @@

"attr" "target=_blank rel=noopener" )}} {{ end }} + + {{ range .Params.links }} + {{ partial "components/button.html" (dict + "text" .text + "link" .url + "colour" "white" + "size" "small" + "arrow" true + "title" (.title | default .text) + "attr" (cond (default false .newTab) "target=_blank rel=noopener" "") + )}} + {{ end }}

{{ end }} + {{ $events := (where site.RegularPages "Type" "showcase").ByDate.Reverse }} + {{ $latest := index $events 0 }} +
+
+
+
+ +
+
+ + {{ with .Params.heading -}} +

+ {{ . | safeHTML }} +

+ {{- else -}} + {{ warnf "No heading param provided to page: %s" .RelPermalink }} + {{- end }} + + {{ with .Params.lead -}} +

+ {{ . | safeHTML }} +

+ {{- end }} +
+ {{ partial "components/button.html" (dict + "colour" "white" + "link" "#events" + "text" "Explore" + "title" "Scroll down to the workshops" + "arrow" true + )}} +
+
+
+
+ + {{ with $latest }} + {{ partial "showcase/strip.html" . }} + {{ end }} +
+ + {{ if $events -}} +
+
+ +
+ {{ range $events -}} +
+ {{ .Render "card" }} +
+ {{- end }} +
+
+
+ {{- end }} +{{ end }} diff --git a/sites/showcase/layouts/partials/cards/showcase.html b/sites/showcase/layouts/partials/cards/showcase.html new file mode 100644 index 000000000..660150f64 --- /dev/null +++ b/sites/showcase/layouts/partials/cards/showcase.html @@ -0,0 +1,64 @@ + + + + +
+ + {{ with .image -}} + + {{ partial "media/picture.html" (dict + "image" . + "alt" (printf "%s, built by %s" $.title $.sub) + "lazy" $.lazy + )}} + + {{- end }} +
+ +
+ + {{ with .sub -}} +

{{ . }}

+ {{- end }} + + {{ with .title -}} +

{{ . }}

+ {{- end }} + + {{ with .award -}} + {{ partial "components/tag.html" (dict "text" . "colour" "grey" "class" "card-showcase-award") }} + {{- end }} +
+
+ +{{ with .github -}} + + View the code + {{ partial "svgs/arrow.html" (dict "rotation" 45 "colour" "white" "size" "small" "stroke" 1) }} + +{{- end }} diff --git a/sites/showcase/layouts/partials/showcase/strip.html b/sites/showcase/layouts/partials/showcase/strip.html new file mode 100644 index 000000000..44bf20b0a --- /dev/null +++ b/sites/showcase/layouts/partials/showcase/strip.html @@ -0,0 +1,31 @@ + +{{ $repo := .Params.repo }} +{{ $sites := .Params.sites }} +{{ $featured := slice }} +{{ range $sites }} + {{ if .featured }} + {{ $featured = $featured | append . }} + {{ end }} +{{ end }} +{{ if not $featured }} + {{ $featured = first 8 $sites }} +{{ end }} +{{ $strip := slice }} +{{ range $featured }} + {{ $strip = $strip | append (dict + "image" (printf "images/%s.jpg" (lower .user)) + "alt" (printf "%s, built by %s" .name .user) + "link" (printf "/%s/%s/" $repo .user) + )}} +{{ end }} +{{ partial "components/carousel.html" (dict + "ctx" . + "images" $strip + "hasLinks" true +)}} diff --git a/sites/showcase/layouts/robots.txt b/sites/showcase/layouts/robots.txt new file mode 100644 index 000000000..9ef021f6a --- /dev/null +++ b/sites/showcase/layouts/robots.txt @@ -0,0 +1,8 @@ +Sitemap: {{ partial "util/_base-url.html" . }}/sitemap.xml +User-agent: * +{{ if hugo.IsProduction -}} +{{ range where site.RegularPages "Type" "showcase" }}{{ with .Params.repo }}Disallow: /{{ . }}/ +{{ end }}{{ end }} +{{- else -}} +Disallow: / +{{- end }} diff --git a/sites/showcase/layouts/showcase/card.html b/sites/showcase/layouts/showcase/card.html new file mode 100644 index 000000000..9cafcce21 --- /dev/null +++ b/sites/showcase/layouts/showcase/card.html @@ -0,0 +1,16 @@ + +{{ $featured := .Resources.GetMatch "images/featured.*" }} +{{ partial "components/featured-card.html" (dict + "href" .RelPermalink + "image" $featured + "imageDefault" (.Resources.GetMatch "images/featured-small.*" | default $featured) + "heading" .Params.heading + "lead" (.Params.cardLead | default .Params.lead) + "buttonName" .Params.buttonName + "fullWidth" false + "numbers" false + "external" false +)}} diff --git a/sites/showcase/layouts/showcase/single.html b/sites/showcase/layouts/showcase/single.html new file mode 100644 index 000000000..585d6089b --- /dev/null +++ b/sites/showcase/layouts/showcase/single.html @@ -0,0 +1,134 @@ +{{ define "main" }} + + {{ $repo := .Params.repo }} + {{ $sites := .Params.sites }} + +
+
+
+
+ +
+ + {{ partial "components/breadcrumbs.html" (dict "ctx" .) }} + +
+ {{ with .Params.pageHeading | default .Params.heading -}} +

+ {{ . | safeHTML }} +

+ {{- else -}} + {{ warnf "No pageHeading or heading param provided to page: %s" .RelPermalink }} + {{- end }} + +

+ {{ with .Params.lead -}} + {{ . | safeHTML }} + {{- else -}} + {{ warnf "No lead param provided to page: %s" .RelPermalink }} + {{- end }} +

+ + {{ with .Params.event }} +

+ {{ with .name -}} + {{ with $.Params.event.url -}} + {{ $.Params.event.name }} + {{- else -}} + {{ . }} + {{- end }} + {{- end }} + {{- with .date }} · {{ dateFormat "Jan 2, 2006" . }}{{ end }} + {{- with .location }} · {{ . }}{{ end }} + {{- with $sites }} · {{ len . }} sites{{ end }} +

+ {{ end }} +
+ {{ partial "components/button.html" (dict + "colour" "white" + "link" "#sites" + "text" "Explore the sites" + "title" "Scroll down to the sites" + "arrow" true + )}} +
+
+
+
+ + {{ partial "showcase/strip.html" . }} +
+ + {{ with $sites }} +
+
+ +
+ {{ range $index, $site := . }} + {{ $isLeft := eq (mod $index 2) 0 }} + {{ $img := $.Resources.GetMatch (printf "images/%s.jpg" (lower $site.user)) }} + {{ if not $img }} + {{ warnf "No thumbnail found for %s on page: %s" $site.user $.RelPermalink }} + {{ end }} +
+
+
+ {{ partial "cards/showcase.html" (dict + "title" $site.name + "sub" (printf "@%s" $site.user) + "permalink" (printf "/%s/%s/" $repo $site.user) + "github" (printf "https://github.com/%s/%s" $site.user $repo) + "image" $img + "award" $site.award + "lazy" (ge $index 2) + )}} +
+
+
+ {{ end }} +
+
+
+ {{ end }} + +
+
+
+
+

+ Every site here is the attendee's own work, captured as it stood at the end of + the workshop and served as a snapshot. Follow the GitHub links to see how each + one was built. +

+ {{ with .Params.workshop }} + {{ with .url }} + {{ partial "components/button.html" (dict + "text" "About the workshop" + "link" . + "colour" "white" + "size" "small" + "arrow" true + "title" "Read about the workshop" + )}} + {{ end }} + {{ end }} +
+
+
+
+{{ end }} diff --git a/sites/showcase/static/opengraph.jpg b/sites/showcase/static/opengraph.jpg new file mode 100644 index 000000000..39f390258 Binary files /dev/null and b/sites/showcase/static/opengraph.jpg differ diff --git a/themes/shared/assets/js/animations/text.ts b/themes/shared/assets/js/animations/text.ts index fd798467e..a48258696 100644 --- a/themes/shared/assets/js/animations/text.ts +++ b/themes/shared/assets/js/animations/text.ts @@ -182,7 +182,11 @@ const hero = (): Playable => { ) .add( { - targets: wrapper.querySelector('.lead'), + // Anything marked .hero-animate-fade fades in alongside the lead. + targets: [ + wrapper.querySelector('.lead'), + ...Array.from(wrapper.querySelectorAll('.hero-animate-fade')), + ].filter((el) => el !== null), easing: 'easeOutQuad', opacity: [0, 1], duration: 1500, @@ -206,7 +210,7 @@ const hero = (): Playable => { duration: 1000, }, 900, - ) + ); const arrow = document.querySelector('.hero .arrow-hover'); if (arrow) { diff --git a/themes/shared/assets/scss/app.scss b/themes/shared/assets/scss/app.scss index aa6804df0..338db2ed0 100644 --- a/themes/shared/assets/scss/app.scss +++ b/themes/shared/assets/scss/app.scss @@ -119,3 +119,4 @@ @import 'pages/insight'; @import 'pages/portfolio'; @import 'pages/service'; +@import 'pages/showcase'; diff --git a/themes/shared/assets/scss/components/_card.scss b/themes/shared/assets/scss/components/_card.scss index 69d374c24..08a28a345 100644 --- a/themes/shared/assets/scss/components/_card.scss +++ b/themes/shared/assets/scss/components/_card.scss @@ -230,6 +230,11 @@ padding: 4rem 0; justify-content: flex-end; + // The row has no gutters, so keep the title off the description. + &-title { + padding-right: $margin-large; + } + &-image { display: block; position: absolute; diff --git a/themes/shared/assets/scss/pages/showcase.scss b/themes/shared/assets/scss/pages/showcase.scss new file mode 100644 index 000000000..97275efc3 --- /dev/null +++ b/themes/shared/assets/scss/pages/showcase.scss @@ -0,0 +1,87 @@ +// ----------------------------------------------------------------------------- +// _showcase.scss +// This file contains all styles related to the workshop showcase pages. +// ----------------------------------------------------------------------------- + +@import '../abstracts/mixins'; +@import '../abstracts/variables'; + +.showcase { + $self: &; + + // Meta + // ========================================================================= + + &-meta { + margin-top: $margin-small; + margin-bottom: 0; + font-size: 0.9rem; + + a { + color: colour('foreground'); + } + } + + // Outro + // ========================================================================= + + &-outro .btn { + margin-top: $margin-small; + } +} + +// Card +// ============================================================================= +// The theme's card, as the ainsley.dev portfolio uses it. Only the 16:9 ratio, +// the top-anchored screenshot and the GitHub link beneath differ. + +.card-showcase { + // The GitHub link beneath carries the card's bottom margin on mobile. + margin-bottom: 0; + + .card-image-wrapper { + padding-top: 56.25%; + } + + .card-image img { + object-position: top; + } + + &-award { + margin-top: $margin-small; + } + + &-github { + // Mirrors the header's menu toggle: small uppercase text with a tiny arrow. + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: $margin-small; + margin-bottom: $card-margin-bottom; + color: colour('paragraph'); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 2px; + text-transform: uppercase; + text-decoration: none; + transition: color 0.3s ease; + + svg { + width: 12px; + height: 12px; + transition: transform 0.3s ease; + } + + &:hover { + color: $white; + + svg { + transform: translate(2px, -2px); + } + } + + @include media-mob { + margin-bottom: 0; + } + } +} diff --git a/vercel.json b/vercel.json index ae449531d..ab788aa55 100644 --- a/vercel.json +++ b/vercel.json @@ -47,6 +47,16 @@ "value": "no-referrer-when-downgrade" } ] + }, + { + "source": "/:path(workshop-.*)", + "has": [{"type": "host", "value": "^showcase\\.ainsleyclark\\.com$"}], + "headers": [ + { + "key": "X-Robots-Tag", + "value": "noindex, nofollow" + } + ] } ], "redirects": [ @@ -86,6 +96,12 @@ "permanent": true, "has": [{"type": "host", "value": "ainsleyclark\\.com"}] }, + { + "source": "/workshop-gophercon-africa-2026/", + "destination": "/gophercon-africa-2026/", + "statusCode": 301, + "has": [{"type": "host", "value": "^showcase\\.ainsleyclark\\.com$"}] + }, { "source": ",/:path((?!compatible).*)", "has": [