I have a lot of fields at the top of a post now. It would be nice if “Jekyll build complete” meant I got every one of them right. It doesn’t. The site’s strict taxonomy check has a specific job, and I need to know where that job ends.

The Part 3 article follows one project page through layouts, includes, and data. This companion puts a few invented posts through the site’s current tag-taxonomy plugin. Some fail exactly as they should. One has a missing series order and passes. That pass is the point of the exercise.

Use the Real Check on Invented Material

The lab includes a frozen, unchanged copy of html/_plugins/03_tag_taxonomy_validator.rb from this site, a small invented tag taxonomy, and seven invented post cases. It does not copy the site’s real project catalog or posts. Each case gets its own Jekyll build under the lab’s out/ directory; the runner checks both the exit status and the expected error text.

The site plugin checks canonical tags and optional content_type values for posts under _posts. It skips actual _drafts paths during draft-inclusive preview. It does not require every field that layouts and series pages use. The lab tests that boundary with the plugin itself rather than with a new lookalike validator.

Download the complete taxonomy lab and its SHA-256 checksum. The README, runner, small taxonomy, and plugin snapshot are available to read or download separately:

README.md markdown View source
# What the metadata check actually catches

This is an invented Jekyll site that carries a frozen copy of this website's `03_tag_taxonomy_validator.rb` plugin. The sample taxonomy is small, but the validation code is the same as the site version inspected for Part 3. The lab demonstrates what that code rejects and what it does not check. It does not run this website's pre-commit hooks, project generator, or publishing workflow.

Requirements: Ruby, Bundler, and Jekyll 4.3-compatible gems. From this directory:

```bash
bundle install
./run_lab.sh
```

Expected result:

```text
PASS valid: accepted
PASS unknown-tag: rejected
PASS alias-tag: rejected
PASS duplicate-tag: rejected
PASS unknown-content-type: rejected
PASS missing-series-order: accepted
PASS draft-with-old-tag: accepted
```

`run_lab.sh` copies the fixture into separate case directories under `out/`, adds one invented post per case, and builds each case with its own generated destination. It checks the exit status and the expected error text. Logs and rendered output stay under `out/` for inspection. All names, posts, and taxonomy values are invented.

The deliberately missing `series_order` passes because the current taxonomy plugin checks tags and `content_type`, not every series field. The draft with an old tag passes with `--drafts` because the plugin skips actual `_drafts` paths. Neither pass means that post is ready for publication. Move a draft into `_posts` and it becomes subject to the tag check. The real site's templates, project lookup, URL rules, images, and editorial review are outside this small fixture.

The frozen plugin is `site/_plugins/03_tag_taxonomy_validator.rb`, copied unchanged from this site's source with SHA-256 `f8734a26c65ece8d1c206ffa0a1d4756d6fdadaeb73af56c96f356d8369e39d7`. Compare it with the current website source before reusing the exercise after the site code changes; the article's results were verified against the snapshot packaged with it.
run_lab.sh bash View source
#!/usr/bin/env bash
set -euo pipefail

lab_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$lab_dir/out"
cd "$lab_dir"

run_case() {
  local name="$1" expected="$2" message="${3:-}"
  local root="$lab_dir/out/$name"
  rm -rf "$root"
  mkdir -p "$root/site/_posts" "$root/site/_drafts"
  cp -R "$lab_dir/site/." "$root/site/"

  local flags=()
  if [[ "$name" == "draft-with-old-tag" ]]; then
    cp "$lab_dir/cases/$name.md" "$root/site/_drafts/$name.md"
    flags=(--drafts)
  else
    cp "$lab_dir/cases/$name.md" "$root/site/_posts/2020-01-01-$name.md"
  fi

  local status=0
  bundle exec jekyll build \
    --source "$root/site" \
    --destination "$root/rendered" \
    --config "$root/site/_config.yml" \
    --disable-disk-cache \
    "${flags[@]}" \
    --quiet >"$root/build.log" 2>&1 || status=$?

  if [[ "$expected" == "accepted" ]]; then
    if [[ "$status" -ne 0 ]]; then
      cat "$root/build.log" >&2
      printf 'FAIL %s: expected acceptance\n' "$name" >&2
      exit 1
    fi
  else
    if [[ "$status" -eq 0 ]] || ! grep -Fq "$message" "$root/build.log"; then
      cat "$root/build.log" >&2
      printf 'FAIL %s: expected rejection containing %s\n' "$name" "$message" >&2
      exit 1
    fi
  fi
  printf 'PASS %s: %s\n' "$name" "$expected"
}

run_case valid accepted
run_case unknown-tag rejected 'unknown tag'
run_case alias-tag rejected 'is an alias'
run_case duplicate-tag rejected 'duplicate tags'
run_case unknown-content-type rejected 'unknown content_type'
run_case missing-series-order accepted
run_case draft-with-old-tag accepted
tag_taxonomy.yml yaml View source
groups:
  - id: development
    label: Development

tags:
  - id: jekyll
    label: Jekyll
    group: development
    aliases: [old-jekyll]
  - id: automation
    label: Automation
    group: development

content_types:
  - id: release
    label: Release
03_tag_taxonomy_validator.rb ruby View source
# frozen_string_literal: true

require "set"

module Jekyll
  class TagTaxonomyValidator < Generator
    safe true
    priority :highest

    def generate(site)
      taxonomy = site.data["tag_taxonomy"]
      raise Errors::FatalException, "tag taxonomy: missing _data/tag_taxonomy.yml" unless taxonomy

      groups = Array(taxonomy["groups"])
      tags = Array(taxonomy["tags"])
      content_types = Array(taxonomy["content_types"])

      group_ids = unique_ids!(groups, "group")
      tag_ids = unique_ids!(tags, "tag")
      content_type_ids = unique_ids!(content_types, "content type")
      aliases = {}

      tags.each do |tag|
        group = tag["group"].to_s
        unless group_ids.include?(group)
          raise Errors::FatalException,
            "tag taxonomy: #{tag['id'].inspect} references unknown group #{group.inspect}"
        end

        Array(tag["aliases"]).each do |alias_id|
          register_alias!(aliases, tag_ids, alias_id, tag.fetch("id"))
        end
      end

      content_types.each do |content_type|
        Array(content_type["source_tags"]).each do |source_tag|
          register_alias!(aliases, tag_ids, source_tag, "content_type:#{content_type.fetch('id')}")
        end
      end

      errors = []
      site.posts.docs.each do |post|
        # Draft-inclusive preview builds load historical and unfinished drafts
        # into site.posts. Keep those previews usable; canonical tags become a
        # hard requirement once a post is moved into a publishable _posts tree.
        next if draft?(post)

        label = post.relative_path
        post_tags = Array(post.data["tags"]).map(&:to_s)

        duplicate_tags = post_tags.group_by(&:itself).select { |_tag, values| values.size > 1 }.keys
        errors << "#{label}: duplicate tags #{duplicate_tags.join(', ')}" if duplicate_tags.any?

        post_tags.each do |tag|
          next if tag_ids.include?(tag)

          if aliases.key?(tag)
            errors << "#{label}: tag #{tag.inspect} is an alias; use #{aliases.fetch(tag).inspect}"
          else
            errors << "#{label}: unknown tag #{tag.inspect}; add it to _data/tag_taxonomy.yml first"
          end
        end

        content_type = post.data["content_type"].to_s
        if !content_type.empty? && !content_type_ids.include?(content_type)
          errors << "#{label}: unknown content_type #{content_type.inspect}"
        end
      end

      return if errors.empty?

      errors.each { |error| Jekyll.logger.error "TagTaxonomy:", error }
      raise Errors::FatalException, "tag taxonomy validation failed with #{errors.size} error(s)"
    end

    private

    def draft?(post)
      path = post.relative_path.to_s.tr("\\", "/")
      path.start_with?("_drafts/") || path.include?("/_drafts/")
    end

    def unique_ids!(entries, kind)
      ids = entries.map { |entry| entry["id"].to_s }
      missing = ids.count(&:empty?)
      duplicates = ids.group_by(&:itself).select { |_id, values| values.size > 1 }.keys

      raise Errors::FatalException, "tag taxonomy: #{missing} #{kind}(s) missing an id" if missing.positive?
      unless duplicates.empty?
        raise Errors::FatalException, "tag taxonomy: duplicate #{kind} ids: #{duplicates.join(', ')}"
      end

      ids.to_set
    end

    def register_alias!(aliases, canonical_ids, alias_id, target)
      alias_id = alias_id.to_s
      if alias_id.empty? || canonical_ids.include?(alias_id)
        raise Errors::FatalException, "tag taxonomy: invalid alias #{alias_id.inspect} for #{target.inspect}"
      end
      if aliases.key?(alias_id) && aliases[alias_id] != target
        raise Errors::FatalException,
          "tag taxonomy: alias #{alias_id.inspect} maps to both #{aliases[alias_id].inspect} and #{target.inspect}"
      end

      aliases[alias_id] = target
    end
  end
end

Run the Cases

The package needs Ruby, Bundler, and Jekyll 4.3-compatible gems. Put the archive and checksum in one directory, then verify and extract it:

shasum -a 256 -c jekyll-taxonomy-lab.zip.sha256
unzip jekyll-taxonomy-lab.zip
cd taxonomy-lab
bundle install
./run_lab.sh

You should see seven PASS lines. The valid post is accepted. Unknown, retired-alias, and duplicate tags are rejected, as is an unknown content_type. The case named missing-series-order is accepted even though its series metadata is incomplete. The old-tag draft is also accepted when Jekyll includes drafts, because the plugin deliberately leaves _drafts alone until that work moves into a publishable post tree.

The runner keeps each build log and its generated output under out/. Open the log for a rejected case if you want to see the exact Jekyll error. No case runs against your real site or changes a live post.

Authored front matter feeds layouts, discovery lists, and series views, while only tags and content type pass through the current strict taxonomy check.
Open full-size diagram

The lab exercises the checked branch. The other branches still need their own review.

The Pass That Should Make You Pause

Take a look at cases/missing-series-order.md. Its tags are valid, and it has a series name, but no series_order. The taxonomy validator accepts it because series order is not one of its checks. The real site’s series index uses that field to set reading order. This little fixture has no series index, so its successful build proves only that the taxonomy check did not catch the omission. It says nothing about where the article would appear in the real series. That is the editorial question I still need to answer.

The same gap applies to a wrong project category, a broken image path, or a next-article link that points somewhere else. The real site’s layouts and includes will use those values, but this particular plugin will not certify them. That is why I look at the rendered page and the surrounding series after the mechanical checks pass.

There is an older front-matter utility in this repository that can add missing title, image, and excerpt fields by editing files. This lab does not run it. Nor does it run the site’s short-link checks, redirect checks, pre-commit chain, or deployment workflow. The point here is to make one active validator’s responsibility visible, not to rename a small lab “the publishing pipeline.”

Current State

The same taxonomy plugin this site uses rejects the invented invalid tags and content type. It allows an incomplete series entry because that field is outside its remit, and it allows an actual draft to keep an old tag while I am still working on it. I can inspect those results and the retained logs without touching my site’s content.

That is a useful check with a clear boundary. I want it in the build. I also want to know when to stop asking it a question it was never written to answer.

Next Work

Part 4 moves to another field that a successful page build can hide: the identity of a public URL. I will trace how the site keeps project-post links stable when source paths and titles change.