Skip to content

feat(blog): nested categories via parentSlug - #1668

Closed
aka-sacci-ccr wants to merge 1 commit into
mainfrom
enable-caveman-mode
Closed

aka-sacci-ccr wants to merge 1 commit into
mainfrom
enable-caveman-mode

Conversation

@aka-sacci-ccr

@aka-sacci-ccr aka-sacci-ccr commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Context

Blog categories were flat. Category had no way to say it belongs under another one, so a site could not express Recipes > Desserts, and the listing filter was an exact slug match.

Model

Category.parentSlug?: string — one field, no new records, no nested object in the decofile (which would allow literal recursion in the JSON).

Chosen over a children: string[] array because the breadcrumb needs a parent walk either way, and only a parent ref makes the trail unambiguous: a child has exactly one parent, and editing a child never touches the parent's record.

Guards

Categories come from a decofile a human edits, so parentSlug may point at nothing, at itself, or around a cycle. All graph logic lives in one module, blog/core/categoryTree.ts, and every walk is bounded twice — by a visited Set and by MAX_CATEGORY_DEPTH (4). A malformed record truncates the chain instead of throwing or hanging a request.

Covered by blog/tests/categoryTree.test.ts: cycle, self-reference, dangling parent, over-deep chain, duplicated slugs, descendant walk over a cycle.

Behaviour

  • BlogpostListing / BlogpostList accept a category path ("parent/child") and expand the leaf into its whole subtree, so a parent lists its children's posts.
  • BlogpostListing returns categoryPath, BlogPostPage returns categories — both the ancestor chain, root first, for the UI breadcrumb.
  • Canonical URLs are rewritten with the real chain. A request reaching a child through a stale or wrong path still renders and points at the one canonical URL, so Google consolidates rather than 404ing or seeing duplicates.
  • GetCategories gains parentSlug to list direct children (menus).

SEO

blog/utils/jsonLD.ts is unchanged. It already derives crumbs from the pathname and resolves names by slug; now that the canonical carries the full chain and SeoBlogPost passes the ancestor chain, the breadcrumb comes out Blog > Parent > Child > Post with sequential position and only the last item without item. articleSection untouched.

Incidental cleanup

filterPostsByCategory now takes a slug or a list of them. That makes filterRelatedPosts an alias and removes the Array.isArray branch of filterPosts — net negative diff, and the list case gains term filtering for free. loadCategoryBySlug, which re-read the whole collection a second time, is gone.

Required on the site side

Route the listing as a catch-all (/blog/:category*, post /blog/:category*/:slug) for subcategory URLs to match. mod.ts documents it and Template.tsx already builds the preview URL with the full path.

Verification

deno test -A blog/tests/ — 44 passed. deno task check clean.

End to end: create parent and child (parentSlug: "parent"), publish a post in child, then check (a) /blog/parent lists the child's post, (b) /blog/parent/child has its own canonical, (c) the post's JSON-LD BreadcrumbList has 4 items in order.

🤖 Generated with Claude Code


Summary by cubic

Adds Category.parentSlug so blog categories can nest ("Recipes > Desserts" instead of flat), and makes category listings accept a full path (parent/child) and expand into the subtree. All category-graph logic is in one module, blog/core/categoryTree.ts, with walks bounded by a visited set and a depth ceiling — cycles, self-references, and dangling parents truncate instead of hanging.

Behavior

  • BlogpostListing and BlogpostList expand a category path into the leaf's whole subtree, so a parent lists its children's posts.
  • Canonical URLs are rewritten with the real ancestor chain; a request through a stale or wrong path still renders and points at the one canonical URL instead of 404ing or duplicating.
  • BlogpostListing returns categoryPath and BlogPostPage returns categories (the ancestor chain, root first) for breadcrumbs; JSON-LD breadcrumbs are unaffected.
  • GetCategories gains parentSlug to list direct children.
  • filterPostsByCategory now accepts a slug or slug list; filterRelatedPosts becomes an alias and list filtering gains term filtering for free.

Migration

  • Route the listing as a catch-all: /blog/:category* and /blog/:category*/:slug.

Written for commit 2cc3c46. Summary will update on new commits.

Review in cubic

Categories were flat: a `Category` had no way to say it belongs under
another one, so a site could not express "Recipes > Desserts" and the
listing filter was an exact slug match.

Adds `Category.parentSlug` and a single graph module,
`blog/core/categoryTree.ts`, that every loader shares. Records come from a
decofile a human edits, so a `parentSlug` may point at nothing, at itself,
or around a cycle; every walk is bounded twice, by a visited set and by
MAX_CATEGORY_DEPTH, and truncates instead of throwing or hanging.

- BlogpostListing/BlogpostList accept a category path ("parent/child") and
  expand it into the whole subtree, so a parent lists its children's posts.
- BlogpostListing returns `categoryPath` and BlogPostPage returns
  `categories`, both the ancestor chain, root first.
- Canonical URLs are rewritten with the real chain. A request that reaches a
  child through a stale or wrong path still renders and points at the one
  canonical URL, so Google consolidates instead of 404ing or seeing
  duplicates. The BreadcrumbList builder is unchanged: it already derives
  crumbs from the pathname and resolves names by slug.
- GetCategories gains `parentSlug` to list direct children.

filterPostsByCategory now takes a slug or a list of them, which makes
filterRelatedPosts an alias and removes the Array branch of filterPosts —
the list case gains term filtering for free.

Sites must route the listing as a catch-all (/blog/:category*) for
subcategory URLs to match; mod.ts documents it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 0.163.1 update
  • 🎉 for Minor 0.164.0 update
  • 🚀 for Major 1.0.0 update

@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 4c6984a3-b486-44f5-82ca-da899e8006db

📥 Commits

Reviewing files that changed from the base of the PR and between d2a310e and 2cc3c46.

📒 Files selected for processing (12)
  • blog/core/categoryTree.ts
  • blog/core/handlePosts.ts
  • blog/loaders/BlogPostPage.ts
  • blog/loaders/BlogpostList.ts
  • blog/loaders/BlogpostListing.ts
  • blog/loaders/GetCategories.ts
  • blog/mod.ts
  • blog/sections/Seo/SeoBlogPost.tsx
  • blog/sections/Template.tsx
  • blog/tests/categoryTree.test.ts
  • blog/tests/handlePosts.test.ts
  • blog/types.ts
 _______________________________
< The only circle I like is CI. >
 -------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@aka-sacci-ccr
aka-sacci-ccr deleted the enable-caveman-mode branch September 25, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant