Hands-On: Build a Jekyll Preview Switch
The main Part 2 article explains why I want two local views: one for the material I am editing and one for the content that should be visible today. My site’s jekyll-site command makes that choice when it starts the local server. Here is the smallest version of the same content switch I could make useful. It uses an invented Jekyll site with four posts and writes two separate builds, so there is no reason to start or stop your real site server.
The interesting result is not a clever shell script. It is being able to say exactly which material each mode includes and then check that claim against the generated HTML.
The Boundary of This Exercise
The package contains a tiny Jekyll site, a preview.sh wrapper, and a verify.sh check. The fixture has one ordinary post, one far-future post, one Markdown draft, and one post marked published: false. Each has a distinct marker. The wrapper builds either current or review into a fixed directory beneath the lab.
This is a build-only exercise. It does not serve a port, manage a PID, refresh project metadata, run Pagefind, or copy any of my site’s plugins and layouts. Those are responsibilities of the real site wrapper. The lab isolates the content-selection decision that the two wrappers have in common.
Download the complete preview lab and its SHA-256 checksum. The two scripts and README are also readable here:
README.md markdown View source
# A tiny Jekyll preview switch
This lab builds an invented four-post site in two modes. It does not run a server, touch your Jekyll site, fetch project metadata, or use the repository's `jekyll-site` wrapper. Output goes only under this lab's `out/` directory.
Requirements: Ruby, Bundler, and Jekyll 4.3-compatible gems. From this directory:
```bash
bundle install
./preview.sh current
./preview.sh review
./verify.sh
```
`current` omits the inclusion flags. Its index should show only `CURRENT-POST-MARKER`. `review` passes `--future --drafts --unpublished`; its index should show all four markers. `verify.sh` checks those claims and exits nonzero if either view is wrong. The fixture's future date is deliberately far ahead so the contrast remains visible for years.
The wrapper uses `jekyll build`, not `jekyll serve`, so the two outputs are easy to compare without a port or a background process. The corresponding serve flags are the same three inclusion switches. A local current-content view is still not a production deployment test; the exercise only proves the fixture's content selection under the Jekyll version you ran.
This is the slice of the actual `utils/bin/jekyll-site` command that the lab models: default `start` serves with `--future --drafts --unpublished`, while `start --current` serves without them. The real command first runs a production build and Pagefind indexing, may refresh project OpenGraph data, then starts a development server and records its PID. `start -n` skips only that metadata refresh; it still builds, indexes, and serves. None of those lifecycle and indexing steps are reproduced here. The lab deliberately leaves `JEKYLL_ENV` unset because it is testing the inclusion flags, not simulating the two environments used by the site wrapper.
The script accepts only `current` and `review`, resolves the package's own directory, and writes to fixed destinations beneath it. If you adapt it for a real site, inspect your build hooks, generated-data steps, destination, and process handling before running it against your own tree.
preview.sh bash View source
#!/usr/bin/env bash
set -euo pipefail
lab_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
mode="${1:-}"
if [[ "$#" -ne 1 || ( "$mode" != "current" && "$mode" != "review" ) ]]; then
printf 'usage: %s current|review\n' "${0##*/}" >&2
exit 64
fi
source_dir="$lab_dir/site"
output_dir="$lab_dir/out/$mode"
mkdir -p "$lab_dir/out"
flags=()
if [[ "$mode" == "review" ]]; then
flags=(--future --drafts --unpublished)
fi
cd "$lab_dir"
bundle exec jekyll build \
--source "$source_dir" \
--destination "$output_dir" \
--config "$source_dir/_config.yml" \
--disable-disk-cache \
"${flags[@]}" \
--quiet
printf '%s view: %s\n' "$mode" "$output_dir/index.html"
verify.sh bash View source
#!/usr/bin/env bash
set -euo pipefail
lab_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
"$lab_dir/preview.sh" current
"$lab_dir/preview.sh" review
current="$lab_dir/out/current/index.html"
review="$lab_dir/out/review/index.html"
markers=(CURRENT-POST-MARKER FUTURE-POST-MARKER DRAFT-POST-MARKER UNPUBLISHED-POST-MARKER)
for marker in "${markers[@]}"; do
if ! grep -Fq "$marker" "$review"; then
printf 'review view is missing %s\n' "$marker" >&2
exit 1
fi
done
if ! grep -Fq CURRENT-POST-MARKER "$current"; then
printf 'current view is missing its ordinary post\n' >&2
exit 1
fi
for marker in "${markers[@]:1}"; do
if grep -Fq "$marker" "$current"; then
printf 'current view unexpectedly contains %s\n' "$marker" >&2
exit 1
fi
done
printf 'PASS: current has one post; review has all four\n'
Run Both Views
The lab needs Ruby, Bundler, and the Jekyll gems declared in its Gemfile. After downloading the archive and checksum to one directory, verify the archive, extract it, and work inside its own folder:
shasum -a 256 -c jekyll-preview-lab.zip.sha256
unzip jekyll-preview-lab.zip
cd preview-lab
bundle install
./preview.sh current
./preview.sh review
./verify.sh
current omits the extra inclusion flags. review supplies --future --drafts --unpublished. The wrapper uses jekyll build for both modes and writes to out/current/ and out/review/. It never selects a path outside the extracted lab.
The expected result is PASS: current has one post; review has all four. You can open either generated index.html in a browser or inspect the markers directly. The current index should contain only CURRENT-POST-MARKER; the review index should also contain the future, draft, and unpublished markers.
Map the Lab Back to My Site
Here is the exact point of contact with the way I operate the site. In utils/bin/jekyll-site, the normal start path eventually runs a development jekyll serve with --future --drafts --unpublished. Passing --current removes those three flags. The lab’s review and current builds exercise that same inclusion choice against four invented posts. You can inspect both serve_flags assignments in the site’s wrapper without running it:
rg -n 'serve_flags=|JEKYLL_ENV=production|JEKYLL_ENV=development|run_pagefind_index' utils/bin/jekyll-site
The rest of our command matters just as much operationally. Before either serve mode, jekyll-site start can refresh project OpenGraph data, then clears generated output, runs a production Jekyll build, and makes a Pagefind index. Only then does it launch the development server and write its PID file. start -n skips the metadata refresh, but still builds, indexes, and starts the server. restart also stops the old process and skips refresh by default. The lab does none of those things, and it does not set JEKYLL_ENV to imitate either stage. Its two HTML outputs demonstrate content inclusion only.
This explains a practical surprise: a future post can appear in the local review browser while the Pagefind index was built from the earlier production-stage output. I do not treat finding that post in the page as proof that local search has indexed it. The lab lets you see the selection difference directly; the real workflow still needs separate checks for search and the final built site.
This lab isolates the content switch at the right; it does not reproduce the full site wrapper.
Change One Thing and Check Again
Edit the invented future post’s date to a date in the past, then run ./verify.sh again. The check should fail because its expectation is now wrong: that marker belongs in the current view too. This is a useful failure. The script is making a claim about content selection, not merely checking that Jekyll exited zero.
Restore the future date and rerun the check. Then try ./preview.sh other to see the wrapper reject an unknown mode before building anything. The script deliberately has only two accepted choices and fixed output destinations.
If you adapt this pattern for a real site, decide where the generated output goes and what a build or restart does beyond Jekyll itself. My site’s wrapper, for example, can refresh project data and replace its generated output before it serves. That is why I would not lift this teaching script into production unchanged.
Current State
The fixture gives a repeatable way to see the difference between a current-content build and a review-inclusive build. The verification checks the generated HTML for the expected posts. It proves that bounded behavior for this invented fixture under the Jekyll version you run; it does not prove the behavior of a different site’s layouts, plugins, or deployment.
Next Work
Part 3 moves from selecting content into rendering it. I will follow the site’s layout hierarchy and the Liquid includes that let one project definition appear in several places without repeating the same page structure.
Join the Discussion
Comments for this post live in GitHub Discussions. That keeps moderation in one place and gives the conversation a stable home.