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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

src/content/** merge=ours
src/assets/** merge=ours
src/styles/** merge=ours
public/** merge=ours
fonts.config.mjs merge=ours

# --- Binary types (no merge attempts, no noisy diffs) ---------------------
*.png binary
Expand Down
73 changes: 58 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,40 +34,69 @@ Add Scaffold as a remote (one-time):
git remote add template https://github.com/draftlab-org/scaffold.git
```

Pull updates whenever you want them:
Then, whenever you want updates:

```sh
git fetch template
git merge template/main
npm run update-from-scaffold
```

### What's protected on merge
That's it. The script handles `--allow-unrelated-histories` on the first merge, re-applies any deletions you've made in protected paths (so demo content doesn't reappear via modify/delete conflicts), and auto-removes any brand-new upstream files in `src/content/`, `src/assets/`, and `public/` (to keep our demo content out of your production site).

### What's protected

Scaffold ships a `.gitattributes` file that marks these paths as **downstream-wins** using Git's built-in `merge=ours` driver — your version is always kept on merge, no per-clone setup required:
Scaffold ships a `.gitattributes` file that marks these paths as **downstream-wins** on merge — your version is always kept:

- `src/content/**` — all content collections (pages, articles, people, etc.)
- `src/assets/**` — uploaded images, logos, artwork
- `src/styles/**` — your theme tokens, typography, component utilities
- `public/**` — favicons, OG images, robots.txt, and anything else you've added there
- `fonts.config.mjs` — your font choices (see "Changing fonts" below)

Everything else merges normally. Real code conflicts get flagged like any merge, and the script stops so you can resolve them by hand.

### New upstream files and content collections

Everything else merges normally. If there's a real conflict in code, Git will flag it and you resolve it as usual.
When upstream adds a brand-new file in `src/content/`, `src/assets/`, or `public/`, the script removes it as part of the merge. Demo content shouldn't sneak into your production site, and you can always add your own files later.

### Heads-up about new upstream files
Brand-new files in `src/styles/` are *not* auto-removed — new stylesheets may be required by new components in the merge.

`merge=ours` resolves *conflicts*, but it doesn't stop **new** upstream files in protected paths from appearing in your working tree (no conflict exists when the file is new on the upstream side). After merging, run `git diff HEAD~1 --stat` and `git rm` any demo content you don't want.
If Scaffold ships an entirely new **content collection** (a new directory under `src/content/`), you'll see a notice at the end of the run:

```
ℹ There are new content collections on Scaffold — check out https://scaffold.org to see what's new
```

### If you forked before `.gitattributes` existed
The collection's schema arrives via `src/content.config.ts` (which isn't protected and merges in normally); the demo files in the new directory are removed. If you want to use the new collection, head to scaffold.org to see what it is, then add your own content under that directory.

Git reads `.gitattributes` from the working tree *at the start* of a merge. If you forked Scaffold before this file was added, run this one-time bootstrap so the rules apply to your first merge:
### If you forked before this update script existed

Git reads `.gitattributes` from the working tree *at the start* of a merge, so existing forks need a one-time bootstrap to land the protection rules and the script itself:

```sh
git fetch template
git checkout template/main -- .gitattributes
git add .gitattributes
git commit -m "Adopt Scaffold merge driver"
git merge template/main
git checkout template/main -- .gitattributes scripts/scaffold-update.sh
git commit -m "Adopt Scaffold update script"
bash scripts/scaffold-update.sh
```

Then add the npm shortcut to your `package.json` scripts so you don't have to remember the bash path:

```json
"update-from-scaffold": "bash scripts/scaffold-update.sh"
```

After that, the two-command flow above is all you need.
After this, `npm run update-from-scaffold` is all you need.

### Doing it without the script

If you'd rather invoke Git directly, the equivalent is:

```sh
git fetch template
git merge template/main # add --allow-unrelated-histories on first run
```

You'll be on your own for modify/delete conflict resolution and new-file review.

## Stack

Expand Down Expand Up @@ -357,6 +386,20 @@ Tailwind CSS v4 uses @theme definitions in the style files. Update `src/styles/c

BaseLayout handles document-level concerns while PageLayout adds header, footer, and content structure. Create specialized layouts by extending these base layouts.

### Changing fonts

Fonts are configured in `fonts.config.mjs` at the repo root — separated out from `astro.config.mjs` so you can change them without conflicting with template updates. The file is also marked `merge=ours` in `.gitattributes`, so your font choices survive `npm run update-from-scaffold`.

Scaffold uses [Bunny Fonts](https://fonts.bunny.net) as the provider — a privacy-friendly CDN that mirrors Google Fonts' catalogue without the third-party tracking. To change a font:

1. Browse [fonts.bunny.net](https://fonts.bunny.net) and pick what you want.
2. Open `fonts.config.mjs` and update the `name` and `weights` for the slot you want to change (`--font-sans`, `--font-serif`, or `--font-mono`).
3. Restart `npm run dev` so Astro re-fetches the new font.

Keep the `cssVariable` names as they are — `--font-sans` / `--font-serif` / `--font-mono` are referenced from typography, components, and Tailwind utilities. Just point them at different fonts.

If you want to use a different provider (Google Fonts, local files, etc.) see [Astro's font docs](https://docs.astro.build/en/guides/fonts/).

## Learn More

- [Astro Documentation](https://docs.astro.build)
Expand Down
24 changes: 3 additions & 21 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import netlify from '@astrojs/netlify';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, fontProviders } from 'astro/config';
import { defineConfig } from 'astro/config';
import expressiveCode from 'astro-expressive-code';
import Icons from 'unplugin-icons/vite';
import { fonts } from './fonts.config.mjs';
import { siteConfig } from './src/lib/config.ts';

// https://astro.build/config
Expand All @@ -16,26 +17,7 @@ export default defineConfig({
devToolbar: {
enabled: false,
},
fonts: [
{
provider: fontProviders.bunny(),
name: 'Rubik',
weights: [300, 400, 500, 600, 700, 800],
cssVariable: '--font-rubik',
},
{
provider: fontProviders.bunny(),
name: 'IBM Plex Serif',
weights: [300, 400, 500, 600, 700],
cssVariable: '--font-ibm-plex-serif',
},
{
provider: fontProviders.bunny(),
name: 'JetBrains Mono',
weights: [300, 400],
cssVariable: '--font-jetbrains-mono',
},
],
fonts,

vite: {
plugins: [
Expand Down
38 changes: 38 additions & 0 deletions fonts.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// fonts.config.mjs
//
// Customise your site's fonts here. This file is protected on merge — your
// changes survive `npm run update-from-scaffold`.
//
// Fonts come from Bunny Fonts (https://fonts.bunny.net), a privacy-friendly
// CDN with the same selection as Google Fonts and no third-party tracking.
// Browse the catalogue at fonts.bunny.net, pick what you want, then update
// the `name` and `weights` below.
//
// The CSS variable names (`--font-sans`, `--font-serif`, `--font-mono`) are
// referenced throughout the codebase. Don't rename them — just change which
// font they point to.

// @ts-check
import { fontProviders } from 'astro/config';

/** @type {import('astro').AstroUserConfig['fonts']} */
export const fonts = [
{
provider: fontProviders.bunny(),
name: 'Rubik',
weights: [300, 400, 500, 600, 700, 800],
cssVariable: '--font-sans',
},
{
provider: fontProviders.bunny(),
name: 'IBM Plex Serif',
weights: [300, 400, 500, 600, 700],
cssVariable: '--font-serif',
},
{
provider: fontProviders.bunny(),
name: 'JetBrains Mono',
weights: [300, 400],
cssVariable: '--font-mono',
},
];
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"update-from-scaffold": "bash scripts/scaffold-update.sh"
},
"dependencies": {
"@astrojs/mdx": "^5.0.4",
Expand Down
33 changes: 33 additions & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ Dev server runs at http://localhost:4321. Build with `npm run build`
- Works with any static host (Vercel, Cloudflare Pages, GitHub Pages) by
swapping the adapter in `astro.config.mjs`.

## Updating from upstream

Scaffold is designed to be forked. Downstream sites can pull future Scaffold
updates without losing content, assets, styles, or branding:

git remote add template https://github.com/draftlab-org/scaffold.git
npm run update-from-scaffold

`scripts/scaffold-update.sh` (wrapped as `npm run update-from-scaffold`):

- Fetches `template/main` and merges (auto-handling `--allow-unrelated-histories`
on first merge after `npx create-astro --template ...`, which has no shared root)
- Resolves modify/delete conflicts in protected paths by re-applying the
downstream deletion (Git's `merge=ours` driver only handles content conflicts)
- Auto-removes brand-new upstream files in `src/content/`, `src/assets/`, and
`public/` to keep demo content out of production (`src/styles/` is excluded
since new stylesheets may be required by new components)
- If a new directory appears under `src/content/` (indicating a new content
collection), prints an alert at end of run pointing to https://scaffold.org

A checked-in `.gitattributes` marks these paths as `merge=ours` (downstream
always wins on conflict):

- `src/content/**` — content collections
- `src/assets/**` — uploaded images, logos, artwork
- `src/styles/**` — theme tokens, typography, component utilities
- `public/**` — favicons, OG images, robots.txt, branding
- `fonts.config.mjs` — font choices (see Architecture)

Everything else (components, layouts, utils, `astro.config.mjs`, `package.json`)
merges normally and consumers receive template improvements.

## Documentation

- [How to use Scaffold](https://scaffold.draftlab.org/how-to-use): Full guide covering installation, page building, content collections, PagesCMS, and deployment
Expand Down Expand Up @@ -87,6 +119,7 @@ Scaffold ships with a complete `.pages.yml` configuration for
- **TypeScript**: Strict mode with path aliases (`@components/*`, `@utils/*`, `@lib/*`, `@content/*`, `@styles/*`, `@layouts/*`, `@assets/*`, `@pages/*`)
- **Icons**: [unplugin-icons](https://github.com/unplugin/unplugin-icons) + [@iconify/json](https://icon-sets.iconify.design/) — import as `~icons/[collection]/[icon-name]`
- **Styling**: Tailwind v4 with `@theme` tokens in `src/styles/colors.css`, `typography.css`, `breakpoints.css`
- **Fonts**: Declared in `fonts.config.mjs` at repo root (split out from `astro.config.mjs` so consumers can swap fonts without conflicting with template updates). Three slots — `--font-sans`, `--font-serif`, `--font-mono` — loaded from [Bunny Fonts](https://fonts.bunny.net) by default. Change a font by editing `name` and `weights` in `fonts.config.mjs`; the CSS variable names should not be renamed.

## API endpoints

Expand Down
144 changes: 144 additions & 0 deletions scripts/scaffold-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Pull Scaffold template updates while strictly preserving downstream state
# of src/content/, src/assets/, src/styles/, and public/.
#
# Behaviour:
# - Modify/delete conflicts in protected paths: re-apply the downstream
# deletion so demo content doesn't reappear.
# - Brand-new upstream files in src/content/, src/assets/, public/ are
# auto-removed (demo content stays out of production). src/styles is
# intentionally excluded — new stylesheets may be required by new
# components.
# - If new directories appear under src/content/ (typically a new content
# collection), an alert is shown at the end of the run.
# - Code-side conflicts halt the script for manual resolution.
#
# Usage:
# ./scripts/scaffold-update.sh # remote=template, branch=main
# ./scripts/scaffold-update.sh <remote> # custom remote, branch=main
# ./scripts/scaffold-update.sh <remote> <branch> # custom both
#
# Or via npm:
# npm run update-from-scaffold
# npm run update-from-scaffold -- <remote> <branch>

set -euo pipefail

REMOTE="${1:-template}"
BRANCH="${2:-main}"

# Paths where modify/delete conflicts resolve to keep the downstream deletion.
PROTECTED=(src/content src/assets src/styles public)

# Subset of PROTECTED where brand-new upstream files are auto-removed. Demo
# content shouldn't sneak into production. src/styles is intentionally NOT
# here — new stylesheets may be required by new components.
AUTO_REMOVE=(src/content src/assets public)

# --- preflight -------------------------------------------------------------

if ! git diff --quiet || ! git diff --cached --quiet; then
echo "✗ Working tree is dirty — commit or stash your changes first."
exit 1
fi

if ! git remote get-url "$REMOTE" > /dev/null 2>&1; then
echo "✗ Remote '$REMOTE' is not configured."
echo " Set it up once with:"
echo " git remote add $REMOTE https://github.com/draftlab-org/scaffold.git"
exit 1
fi

# --- fetch -----------------------------------------------------------------

echo "→ Fetching $REMOTE/$BRANCH"
git fetch "$REMOTE" "$BRANCH"

# Snapshot file list under auto-remove paths so we can identify new arrivals
# after the merge. sort -u dedupes paths that appear at multiple stages while
# the merge is in flight.
BEFORE=$(mktemp)
MID=$(mktemp)
trap 'rm -f "$BEFORE" "$MID"' EXIT
git ls-files -- "${AUTO_REMOVE[@]}" 2>/dev/null | sort -u > "$BEFORE"

# --- merge (no commit; we modify the index before finalising) -------------
# First merge after `npx create-astro --template ...` has no shared root and
# needs --allow-unrelated-histories.

if git merge-base HEAD "$REMOTE/$BRANCH" > /dev/null 2>&1; then
echo "→ Merging $REMOTE/$BRANCH"
git merge --no-commit --no-edit "$REMOTE/$BRANCH" || true
else
echo "→ Merging $REMOTE/$BRANCH (no shared history → --allow-unrelated-histories)"
git merge --no-commit --no-edit --allow-unrelated-histories "$REMOTE/$BRANCH" || true
fi

# --- modify/delete handling -----------------------------------------------
# .gitattributes `merge=ours` resolves content conflicts but not modify/delete.
# DU = deleted by us, modified by them. Re-apply the deletion in protected
# paths so demo content the user removed doesn't come back.

RM_DU_COUNT=0
while IFS= read -r line; do
[ "${line:0:2}" = "DU" ] || continue
path="${line:3}"
for p in "${PROTECTED[@]}"; do
if [[ "$path" == "$p"/* ]]; then
[ "$RM_DU_COUNT" -eq 0 ] && echo "→ Re-applying your deletions in protected paths"
git rm -f -- "$path" > /dev/null
RM_DU_COUNT=$((RM_DU_COUNT + 1))
fi
done
done < <(git status --porcelain)

# Snapshot AFTER the merge + DU resolution but BEFORE we auto-remove new files.
# This is the state we diff against BEFORE to find brand-new upstream files.
git ls-files -- "${AUTO_REMOVE[@]}" 2>/dev/null | sort -u > "$MID"

NEW_FILES=$(comm -13 "$BEFORE" "$MID" || true)

# Detect new directories under src/content/ (one level deep — collection level).
# Derived from the file list so it doesn't matter whether the dirs are
# tracked separately by Git.
NEW_CONTENT_DIRS=$(comm -13 \
<(awk -F/ 'NF >= 3 && $1=="src" && $2=="content" {print $1"/"$2"/"$3}' "$BEFORE" | sort -u) \
<(awk -F/ 'NF >= 3 && $1=="src" && $2=="content" {print $1"/"$2"/"$3}' "$MID" | sort -u) \
|| true)

# --- auto-remove new files -------------------------------------------------
# All brand-new files in src/content/, src/assets/, public/ are removed.
# We don't enumerate them — the user just wants demo content gone.

if [ -n "$NEW_FILES" ]; then
COUNT=$(printf '%s\n' "$NEW_FILES" | wc -l | tr -d ' ')
echo "→ Removing $COUNT new upstream file(s) in protected paths"
printf '%s\n' "$NEW_FILES" | while IFS= read -r f; do
[ -n "$f" ] && git rm -f -- "$f" > /dev/null
done
fi

# --- finalize --------------------------------------------------------------

print_collection_alert() {
if [ -n "$NEW_CONTENT_DIRS" ]; then
echo
echo "ℹ There are new content collections on Scaffold — check out https://scaffold.org to see what's new"
fi
}

if [ -f .git/MERGE_HEAD ]; then
if git status --porcelain | grep -qE '^(UU|AA|DD|UD|AU|UA) '; then
echo
echo "⚠ Code-side conflicts remain. Resolve them, then run:"
echo " git commit"
print_collection_alert
exit 1
fi
git commit --no-edit > /dev/null
echo "✓ Merge committed."
else
echo "✓ Already up to date."
fi

print_collection_alert
Loading
Loading