Skip to content

feat: add tags to project metadata #69#164

Merged
markteekman merged 17 commits into
mainfrom
feature/tag-badges
Oct 13, 2025
Merged

feat: add tags to project metadata #69#164
markteekman merged 17 commits into
mainfrom
feature/tag-badges

Conversation

@peterpadberg
Copy link
Copy Markdown
Member

@peterpadberg peterpadberg commented Oct 11, 2025

Add tags to project and add filter buttons to portfolio page

Summary by CodeRabbit

  • New Features

    • Project tags added and shown as tag badges; tag-based filtering UI and dedicated, paginated tag pages with the current tag highlighted.
    • Breadcrumbs now support custom labels and an option to join the last breadcrumb.
  • Bug Fixes / UX

    • Pagination only displays when multiple pages exist and respects page bounds.
  • Chores

    • Copyright years and attribution updated.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Oct 11, 2025

Walkthrough

Added a tags field to the projects collection and updated project MDX front matter. Implemented tag filtering UI and new tag-specific paginated portfolio pages, added a slugify util, adjusted imports/aliases and Astro integrations, and extended PageHeader breadcrumb props.

Changes

Cohort / File(s) Summary
Content schema
src/content.config.ts
Added tags: z.array(z.string()).default([]) to the projects collection schema.
Project metadata
src/content/projects/*
Added front-matter tags arrays to project MDX files (src/content/projects/project-0*.mdx) (metadata-only).
Portfolio index
src/pages/portfolio/[...page].astro
Compute uniqueTags (uses slugify), pass uniqueTags through paginate props; render tag filter panel with Badge/Link; show tag badges on project cards; extend page props; conditional pagination uses page.lastPage.
Tag-filtered portfolio pages
src/pages/portfolio/tag/[tag]/[...page].astro
New paginated tag route with export const getStaticPaths; aggregates tags, assigns featured images, paginates per tag (pageSize=6), and returns props (currentTag, uniqueTags).
Utility added
src/utils/slugify.ts
New slugify(text: string): string utility creating lowercased, hyphenated slugs.
Pagination tweak
src/pages/blog/[...page].astro
Pagination UI now renders conditionally and uses page.lastPage for visibility and total pages.
Astro config / integrations
astro.config.mjs
Reordered imports and adjusted integrations order to [compress(), icon(), mdx(), sitemap()]; added fileURLToPath import and reorganized import layout.
Path aliases / tsconfig
tsconfig.json
Reformatted path mappings and added "@/utils/*": ["src/utils/*"] alias.
Layout imports
src/layouts/DefaultLayout.astro
Switched several component imports to path aliases (e.g., @components/...); no logic changes.
Page header breadcrumb
src/components/PageHeader.astro
Added customBreadcrumbLabels?: Record<string,string> and joinLastBreadcrumb?: boolean props; introduced formatting helper and adjusted breadcrumb rendering to support label overrides and optional joined last breadcrumb.
License
LICENSE
Updated copyright line to "Copyright (c) 2021-2025 Incluud / Mark Teekman".

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant PI as Portfolio Index
  participant C as Content Collections
  participant S as Slugify
  participant UI as UI Components

  U->>PI: Request /portfolio
  PI->>C: Load projects (with `tags`)
  C-->>PI: Projects metadata
  PI->>S: slugify(tag labels)
  S-->>PI: tag slugs
  PI->>UI: Render uniqueTags (filters) & project cards (tag badges)
  UI-->>U: Page with filters and projects
Loading
sequenceDiagram
  autonumber
  participant Build as Static Build
  participant TP as Tag Page Generator
  participant C as Content Collections
  participant P as Paginator

  Note over Build,TP: Build-time getStaticPaths
  Build->>TP: Invoke getStaticPaths
  TP->>C: Fetch all projects (with tags)
  C-->>TP: Projects list
  TP->>TP: Aggregate uniqueTags (use slugify)
  loop For each tag
    TP->>P: Paginate filtered projects (pageSize=6)
    P-->>TP: Paginated pages with props (currentTag, uniqueTags)
  end
  TP-->>Build: Return tag-specific pages
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I hop through tags with eager paws,
Badges gleam on each fine clause,
Slugs I carve in hyphen lines,
Pages paged in tidy mines,
Breadcrumbs joined — hooray, applause! 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly highlights the addition of a tags field to project metadata, which is a core change in this pull request’s schema and frontmatter updates. It does not mention the accompanying UI enhancements for tag-based filtering and pagination. Overall it accurately reflects a significant aspect of the implementation but could be more comprehensive.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/tag-badges

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 and usage tips.

@netlify
Copy link
Copy Markdown

netlify Bot commented Oct 11, 2025

Deploy Preview for accessible-astro-starter-incluud ready!

Name Link
🔨 Latest commit b263aaa
🔍 Latest deploy log https://app.netlify.com/projects/accessible-astro-starter-incluud/deploys/68ebe5cfd728530008bcb367
😎 Deploy Preview https://deploy-preview-164--accessible-astro-starter-incluud.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

coderabbitai[bot]

This comment was marked as duplicate.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
src/content/projects/project-04.mdx (1)

5-5: Consider more specific tags for the e-learning platform.

While "machine-learning" aligns with the AI-driven personalization mentioned, "voice-ui" isn't clearly described. Consider adding more relevant tags like "education", "accessibility", "adaptive-learning", or "dyslexia-support" to improve filtering accuracy.

src/content/projects/project-06.mdx (1)

5-5: Consider adding healthcare-specific tags.

"voice-ui" is appropriate (voice commands mentioned), but "machine-learning" isn't clearly described. Consider adding more relevant tags like "healthcare", "accessibility", "hipaa", or "medical-records" to improve categorization.

src/content/projects/project-07.mdx (1)

5-5: Consider adding tags for additional input methods.

"voice-ui" is appropriate (voice control mentioned), but the project also features eye tracking and adaptive switches which aren't represented. Consider adding tags like "eye-tracking", "adaptive-controls", "smart-home", or "mobility" for better categorization.

src/pages/portfolio/[...page].astro (2)

19-20: Consider renaming tags to allTags for consistency.

The variable is renamed to allTags in the tag-filtered page (src/pages/portfolio/tag/[tag]/[...page].astro line 19), which is more descriptive. Consider using the same naming here for consistency across the codebase.

Apply this diff:

-  const tags = projects.map((project) => project.data.tags).flat()
-  const uniqueTags = [...new Set(tags)]
+  const allTags = projects.map((project) => project.data.tags).flat()
+  const uniqueTags = [...new Set(allTags)]

57-70: Use template literals for URL construction.

While the logic is correct, prefer template literals over string concatenation for better readability and consistency with modern JavaScript practices.

Apply this diff:

           uniqueTags.map((tag: string) => (
-              <Link isButton size="sm" href={'/portfolio/tag/' + tag} class="mr-2">
+              <Link isButton size="sm" href={`/portfolio/tag/${tag}`} class="mr-2">
               {tag}
             </Link>
           ))

Note: The explicit type annotation (tag: string) is also redundant since TypeScript can infer the type from uniqueTags: string[], but this is a minor style preference.

src/pages/portfolio/tag/[tag]/[...page].astro (1)

117-122: Consider using semantic classes or theme variables for badge styling.

The current tag badge uses inline Tailwind classes "bg-blue-600 text-white". For better maintainability and theming consistency, consider defining a semantic CSS class or using theme variables that can be managed centrally.

Example approaches:

  1. Define a semantic class in your CSS:
.badge-active {
  background-color: var(--theme-primary);
  color: var(--theme-primary-text);
}
  1. Or extract to a reusable constant:
const ACTIVE_BADGE_CLASSES = 'bg-blue-600 text-white'

This improves maintainability and makes theme updates easier.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between af95f2a and 2a6ad06.

📒 Files selected for processing (10)
  • src/content.config.ts (1 hunks)
  • src/content/projects/project-01.mdx (1 hunks)
  • src/content/projects/project-02.mdx (1 hunks)
  • src/content/projects/project-03.mdx (1 hunks)
  • src/content/projects/project-04.mdx (1 hunks)
  • src/content/projects/project-05.mdx (1 hunks)
  • src/content/projects/project-06.mdx (1 hunks)
  • src/content/projects/project-07.mdx (1 hunks)
  • src/pages/portfolio/[...page].astro (5 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)
🔇 Additional comments (9)
src/content.config.ts (1)

14-14: Schema addition is correct, but verify tag usage consistency across projects.

The schema correctly adds an optional tags field with a sensible default. However, the actual tag values in the project MDX files show concerning patterns (see comments on individual project files).

src/content/projects/project-01.mdx (1)

5-5: LGTM!

Tags are semantically appropriate and align well with the project description. This project features accessibility for visual impairments, smart home automation, voice-first interface, machine learning for speech recognition, and IoT devices.

src/pages/portfolio/[...page].astro (4)

4-4: LGTM!

The additional imports for Badge and Link components are necessary for the tag filtering UI.


31-34: LGTM!

Correctly passes uniqueTags through pagination props to make it available on all pages.


39-39: LGTM!

The uniqueTags property is correctly added to the Props interface.


84-88: Verify spacing between tag badges.

The tag badges are rendered without explicit spacing between them. Ensure that the Badge component from accessible-astro-components includes default spacing, or add spacing classes to prevent badges from appearing crowded.

Consider adding spacing if needed:

               <span slot="meta">
-                  {project.data.tags.map((tag) => (
-                    <Badge>{tag}</Badge>
+                  {project.data.tags.map((tag, index) => (
+                    <Badge class={index > 0 ? 'ml-2' : ''}>{tag}</Badge>
                   ))}
                 </span>
src/pages/portfolio/tag/[tag]/[...page].astro (3)

1-16: LGTM!

The imports are correct and necessary for the tag-filtered portfolio page. The duplication with the main portfolio page is acceptable for separate route files.


17-50: LGTM!

The getStaticPaths implementation correctly generates paginated pages for each tag. The approach of iterating through uniqueTags and creating filtered, paginated pages is appropriate for this use case.


86-98: Verify Link’s disabled support. accessible-astro-components’ Link is used with both a disabled prop and class="disabled". Confirm Link supports disabled; if so, remove the redundant class, otherwise implement disabling per its API.

Comment thread src/content/projects/project-02.mdx Outdated
Comment thread src/content/projects/project-03.mdx Outdated
Comment thread src/content/projects/project-05.mdx Outdated
title: FinanceForAll
author: Viktor Nakamoto
description: A fully accessible financial management platform that makes banking and investment tools available to users with various disabilities
tags: ["voice-ui", "machine-learning"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tags don't reflect the described features.

FinanceForAll is described with screen-reader optimization and keyboard navigation, but neither "voice-ui" nor "machine-learning" are mentioned in the description or technical challenges.

Consider tags that match the actual features: "finance", "banking", "accessibility", "screen-reader", "keyboard-navigation".

🤖 Prompt for AI Agents
In src/content/projects/project-05.mdx around line 5, the tag line currently
lists ["voice-ui", "machine-learning"] which do not match the project's
description; replace those tags with ones that reflect the content (e.g.,
"finance", "banking", "accessibility", "screen-reader", "keyboard-navigation")
so the metadata matches the described features and accessibility focus.

Comment thread src/pages/portfolio/tag/[tag]/[...page].astro Outdated
Comment thread src/pages/portfolio/tag/[tag]/[...page].astro Outdated
Comment thread src/pages/portfolio/tag/[tag]/[...page].astro Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/pages/portfolio/[...page].astro (1)

63-67: Slugify tags before using them in URLs (duplicate concern).

This issue was previously flagged: tags containing spaces, slashes, or special characters (e.g., UI/UX, Design Systems) will break the URL or create invalid route segments. The previous review provided a detailed solution involving a { label, slug } structure to keep routing stable.

src/pages/portfolio/tag/[tag]/[...page].astro (2)

87-101: Keep routing slugs separate from display labels (duplicate concern).

This issue was previously flagged: using raw tag strings in both params: { tag } and <Link> URLs will break for tags with spaces, punctuation, or special characters. The previous review provided a solution using { label, slug } structure.


132-140: Use page.size instead of hard-coded page size (duplicate concern).

Lines 132, 138, and 140 all hard-code the page size as 6. This was previously flagged: these calculations should use page.size for consistency and maintainability. The previous review provided specific diffs for each line.

🧹 Nitpick comments (2)
src/pages/portfolio/[...page].astro (1)

84-88: Consider adding spacing between tag badges.

The badges are rendered without spacing, which may appear cramped when multiple tags are present. Adding a small margin or gap would improve visual clarity.

Apply this diff to add spacing:

                 <span slot="meta">
-                  {project.data.tags.map((tag) => (
+                  {project.data.tags.map((tag, index) => (
-                    <Badge>{tag}</Badge>
+                    <Badge class={index > 0 ? 'ml-1' : ''}>{tag}</Badge>
                   ))}
                 </span>

Or use a flex gap on the parent span:

-                <span slot="meta">
+                <span slot="meta" class="flex flex-wrap gap-1">
                   {project.data.tags.map((tag) => (
                     <Badge>{tag}</Badge>
                   ))}
                 </span>
src/pages/portfolio/tag/[tag]/[...page].astro (1)

17-29: Consider extracting shared project loading logic.

Lines 17-29 are nearly identical to the corresponding lines in src/pages/portfolio/[...page].astro. The project loading, tag extraction, and image mapping logic could be extracted into a shared helper function to reduce duplication and improve maintainability.

Example helper structure:

// src/utils/portfolio.ts
export async function loadProjectsWithMetadata() {
  const projects = await getCollection('projects')
  const allTags = projects.map((project) => project.data.tags ?? []).flat().filter(Boolean)
  const uniqueTags = [...new Set(allTags)].sort((a, b) => a.localeCompare(b))
  
  const projectImages = [projectImage1, projectImage2, /* ... */]
  const projectsWithImages = projects.map((project, index) => ({
    ...project,
    featuredImage: projectImages[index % projectImages.length],
  }))
  
  return { projectsWithImages, uniqueTags }
}

Then import and use in both files:

import { loadProjectsWithMetadata } from '@utils/portfolio'

export const getStaticPaths = (async ({ paginate }) => {
  const { projectsWithImages, uniqueTags } = await loadProjectsWithMetadata()
  // ... rest of logic
}) satisfies GetStaticPaths
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a6ad06 and 560a8c5.

📒 Files selected for processing (9)
  • src/content/projects/project-01.mdx (1 hunks)
  • src/content/projects/project-02.mdx (1 hunks)
  • src/content/projects/project-03.mdx (1 hunks)
  • src/content/projects/project-04.mdx (1 hunks)
  • src/content/projects/project-05.mdx (1 hunks)
  • src/content/projects/project-06.mdx (1 hunks)
  • src/content/projects/project-07.mdx (1 hunks)
  • src/pages/portfolio/[...page].astro (5 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/content/projects/project-02.mdx
  • src/content/projects/project-01.mdx
  • src/content/projects/project-03.mdx
  • src/content/projects/project-06.mdx
  • src/content/projects/project-05.mdx
  • src/content/projects/project-04.mdx
  • src/content/projects/project-07.mdx
🔇 Additional comments (5)
src/pages/portfolio/[...page].astro (2)

31-34: LGTM!

Passing uniqueTags through the paginate props is the correct approach for making tag data available across all paginated pages.


39-39: LGTM!

The Props interface correctly extends the type to include uniqueTags.

src/pages/portfolio/tag/[tag]/[...page].astro (3)

34-46: Ensure tag URL params use slugified values (related to other comments).

Line 39 sets params: { tag } using the raw tag string. This must be updated to use the slugified version when implementing the slugification fix suggested in other comments (lines 87-101). The [tag] route parameter will receive the URL-safe slug, so the filtering logic (line 35) must also compare against the slug or maintain a mapping.

After implementing the slugification approach suggested in previous comments, ensure:

  1. params: { tag: slug } passes the URL-safe slug
  2. Store a mapping or use the slug for filtering
  3. Pass both slug and label in props for display purposes

52-58: LGTM!

The Props interface is well-typed and includes all necessary data for rendering the tag-filtered portfolio page.


117-122: LGTM!

The conditional styling for the current tag badge (blue background) provides clear visual feedback about which tag filter is active.

Comment thread src/pages/portfolio/[...page].astro Outdated
Comment thread src/pages/portfolio/tag/[tag]/[...page].astro Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (3)
src/pages/portfolio/tag/[tag]/[...page].astro (2)

132-132: Use page.size instead of hard-coded 6 for pagination math

Keeps UI consistent with getStaticPaths and future config changes.

Apply:

-        page && Math.ceil(page.total / 6) > 1 && (
+        page && Math.ceil(page.total / page.size) > 1 && (
@@
-              lastPage={page.url.next ? `/portfolio/tag/${currentTag}/${Math.ceil(page.total / 6)}` : null}
+              lastPage={page.url.next ? `/portfolio/tag/${currentTag}/${Math.ceil(page.total / page.size)}` : null}
@@
-              totalPages={`${Math.ceil(page.total / 6)}`}
+              totalPages={`${Math.ceil(page.total / page.size)}`}

Also applies to: 138-140


34-44: Slugify tags for routing; keep human-readable labels for display

Using raw tags in routes breaks for spaces, punctuation, or “/”. Use { label, slug }:

  • Generate slugs in getStaticPaths
  • Set params: { tag: slug }
  • Pass both currentTag (slug) and currentTagLabel (label)
  • Render links with slug, display label
  • Compare currentTag against slugs; compare badges against currentTagLabel

Suggested changes:

@@
-export const getStaticPaths = (async ({ paginate }) => {
+export const getStaticPaths = (async ({ paginate }) => {
   const projects = await getCollection('projects')
-  const allTags = projects.map((project) => project.data.tags).flat()
-  const uniqueTags = [...new Set(allTags)].sort((a, b) => a.localeCompare(b))
+  const allTags = projects.map((project) => project.data.tags).flat()
+  const uniqueTagLabels = [...new Set(allTags)].sort((a, b) => a.localeCompare(b))
+  const uniqueTags = uniqueTagLabels.map((label) => ({
+    label,
+    slug: label
+      .toLowerCase()
+      .trim()
+      .replace(/[^a-z0-9]+/g, '-')
+      .replace(/^-+|-+$/g, ''),
+  }))
@@
-  for (const tag of uniqueTags) {
-    const filteredProjects = projectsWithImages.filter((project) => project.data.tags.includes(tag))
+  for (const { slug, label } of uniqueTags) {
+    const filteredProjects = projectsWithImages.filter((project) => project.data.tags.includes(label))
@@
-    const paginatedPages = paginate(filteredProjects, {
+    const paginatedPages = paginate(filteredProjects, {
       pageSize: 6,
-      params: { tag },
+      params: { tag: slug },
       props: {
-        currentTag: tag,
-        uniqueTags,
+        currentTag: slug,
+        currentTagLabel: label,
+        uniqueTags,
       },
     })
-interface Props {
-  page: Page<CollectionEntry<'projects'> & { featuredImage: any }>
-  currentTag: string
-  uniqueTags: string[]
-}
+interface Props {
+  page: Page<CollectionEntry<'projects'> & { featuredImage: any }>
+  currentTag: string // slug
+  currentTagLabel: string
+  uniqueTags: Array<{ label: string; slug: string }>
+}
-const { page, currentTag, uniqueTags } = Astro.props
+const { page, currentTag, currentTagLabel, uniqueTags } = Astro.props
-  <PageHeader
-    title={`Portfolio: ${currentTag}`}
-    subtitle={`Showing ${page.total} project${page.total !== 1 ? 's' : ''} tagged with "${currentTag}". <a href="/portfolio">View all projects</a> or filter by other tags below.`}
+  <PageHeader
+    title={`Portfolio: ${currentTagLabel}`}
+    subtitle={`Showing ${page.total} project${page.total !== 1 ? 's' : ''} tagged with "${currentTagLabel}". <a href="/portfolio">View all projects</a> or filter by other tags below.`}
-          {
-            uniqueTags.map((tag: string) => {
-              const isCurrentTag = tag === currentTag
+          {
+            uniqueTags.map(({ slug, label }) => {
+              const isCurrentTag = slug === currentTag
               return isCurrentTag ? (
-                <Link isButton size="sm" href={'/portfolio/tag/' + tag} disabled class="disabled">
-                  {tag}
+                <Link isButton size="sm" href={'/portfolio/tag/' + slug} disabled class="disabled">
+                  {label}
                 </Link>
               ) : (
-                <Link isButton size="sm" href={'/portfolio/tag/' + tag}>
-                  {tag}
+                <Link isButton size="sm" href={'/portfolio/tag/' + slug}>
+                  {label}
                 </Link>
               )
             })
           }
-  <PageHeader
-    title={`Portfolio: ${currentTag}`}
+  <PageHeader
+    title={`Portfolio: ${currentTagLabel}`}
-                      {project.data.tags.map((tag) => {
-                        const isCurrentTag = tag === currentTag
-                        return isCurrentTag ? <Badge class="bg-blue-600 text-white">{tag}</Badge> : <Badge>{tag}</Badge>
-                      })}
+                      {project.data.tags.map((tag) => {
+                        const isCurrentTag = tag === currentTagLabel
+                        return isCurrentTag ? <Badge class="bg-blue-600 text-white">{tag}</Badge> : <Badge>{tag}</Badge>
+                      })}

Also applies to: 80-91, 116-119, 62-69, 52-56, 58-58, 17-21

src/pages/portfolio/[...page].astro (1)

19-20: Slugify tags for links; pass { label, slug } via props

Avoid broken routes for tags with spaces/slashes; keep labels for display.

Apply:

-  const tags = projects.map((project) => project.data.tags).flat()
-  const uniqueTags = [...new Set(tags)].sort((a, b) => a.localeCompare(b))
+  const tags = projects.map((project) => project.data.tags).flat()
+  const uniqueTagLabels = [...new Set(tags)].sort((a, b) => a.localeCompare(b))
+  const uniqueTags = uniqueTagLabels.map((label) => ({
+    label,
+    slug: label
+      .toLowerCase()
+      .trim()
+      .replace(/[^a-z0-9]+/g, '-')
+      .replace(/^-+|-+$/g, ''),
+  }))
@@
-  return paginate(projectsWithImages, {
-    pageSize: 6,
-    props: { uniqueTags },
-  })
+  return paginate(projectsWithImages, {
+    pageSize: 6,
+    props: { uniqueTags },
+  })
@@
-interface Props {
+interface Props {
   page: Page<CollectionEntry<'projects'> & { featuredImage: any }>
-  uniqueTags: string[]
+  uniqueTags: Array<{ label: string; slug: string }>
 }
@@
-const { page, uniqueTags } = Astro.props
+const { page, uniqueTags } = Astro.props
@@
-              uniqueTags.map((tag: string) => (
-                <Link isButton size="sm" href={'/portfolio/tag/' + tag}>
-                  {tag}
+              uniqueTags.map(({ slug, label }) => (
+                <Link isButton size="sm" href={`/portfolio/tag/${slug}`}>
+                  {label}
                 </Link>
               ))

Also applies to: 31-34, 39-39, 42-42, 61-66

🧹 Nitpick comments (1)
astro.config.mjs (1)

13-13: Run astro-compress last in the integrations list

Safer to compress after other integrations generate assets.

Apply:

-  integrations: [compress(), icon(), mdx(), sitemap()],
+  integrations: [icon(), mdx(), sitemap(), compress()],
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 560a8c5 and 79d0517.

📒 Files selected for processing (5)
  • LICENSE (1 hunks)
  • astro.config.mjs (2 hunks)
  • src/layouts/DefaultLayout.astro (1 hunks)
  • src/pages/portfolio/[...page].astro (4 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • LICENSE
🔇 Additional comments (3)
src/layouts/DefaultLayout.astro (1)

4-6: Path alias switch looks good

Aliases match astro.config.mjs; no behavior change.

astro.config.mjs (1)

2-2: New imports are correct

fileURLToPath for aliases and mdx/sitemap integrations look good.

Also applies to: 5-6

src/pages/portfolio/tag/[tag]/[...page].astro (1)

19-21: Tag collection/sort looks safe given schema defaults

content.config.ts defines tags: z.array(z.string()).default([]), so this pipeline is fine.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/utils/slugify.ts (1)

6-12: Consider input validation and unicode normalization.

The slugification logic is sound for basic ASCII text, but consider these edge cases:

  • Empty strings or strings with only special characters will produce empty slugs.
  • Unicode characters (e.g., "café") are removed rather than transliterated (café → caf instead of cafe).
  • No protection against null/undefined input.

Consider this enhanced implementation:

 export function slugify(text: string): string {
+    if (!text) return ''
+    
     return text
+        .normalize('NFD')
+        .replace(/[\u0300-\u036f]/g, '')
         .toLowerCase()
         .trim()
         .replace(/[^a-z0-9]+/g, '-')
         .replace(/^-+|-+$/g, '')
 }

This adds:

  • Early return for empty input
  • Unicode normalization to convert accented characters (é → e)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 79d0517 and bea4d7a.

📒 Files selected for processing (13)
  • src/components/PageHeader.astro (2 hunks)
  • src/content/projects/project-01.mdx (1 hunks)
  • src/content/projects/project-02.mdx (1 hunks)
  • src/content/projects/project-03.mdx (1 hunks)
  • src/content/projects/project-04.mdx (1 hunks)
  • src/content/projects/project-05.mdx (1 hunks)
  • src/content/projects/project-06.mdx (1 hunks)
  • src/content/projects/project-07.mdx (1 hunks)
  • src/pages/blog/[...page].astro (1 hunks)
  • src/pages/portfolio/[...page].astro (4 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)
  • src/utils/slugify.ts (1 hunks)
  • tsconfig.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/content/projects/project-06.mdx
  • src/content/projects/project-05.mdx
  • src/content/projects/project-07.mdx
  • src/content/projects/project-04.mdx
  • src/content/projects/project-02.mdx
  • src/content/projects/project-03.mdx
🔇 Additional comments (10)
tsconfig.json (1)

6-17: LGTM! Clean addition of the utils path alias.

The reformatting to multi-line arrays improves readability, and the new @/utils/* alias enables cleaner imports throughout the codebase.

src/content/projects/project-01.mdx (1)

5-5: LGTM! Tags metadata added correctly.

The tags field aligns with the content schema and will be properly consumed by the portfolio listing and filtering pages.

src/pages/blog/[...page].astro (1)

87-101: LGTM! Improved pagination logic using page.lastPage.

The refactor correctly uses the built-in page.lastPage property instead of manually computing total pages, improving maintainability and eliminating the hard-coded page size.

src/components/PageHeader.astro (1)

47-51: LGTM! Custom breadcrumb labels implemented correctly.

The new customBreadcrumbLabels prop enables clean override of URL-based breadcrumb text, with proper fallback to the existing formatting logic. The optional chaining is used correctly.

Also applies to: 62-62, 96-102

src/pages/portfolio/tag/[tag]/[...page].astro (3)

18-57: LGTM! Static path generation correctly implements slugification.

The implementation properly separates routing slugs from display labels:

  • Tags are slugified for URL params (params: { tag: tag.slug })
  • Projects are filtered using slugified tags
  • Human-readable labels are preserved for display

This addresses the past review concerns about URL-unsafe tag names.


88-103: LGTM! Tag navigation uses slugs consistently.

The tag filter UI correctly:

  • Uses tag.slug in hrefs for routing
  • Compares tag.slug === currentTag.slug for current state
  • Displays tag.label for human-readable text

This addresses the past review concerns about keeping routing separate from display.


142-154: LGTM! Pagination uses page.lastPage consistently.

The pagination logic correctly uses page.lastPage throughout instead of hard-coded page sizes, addressing the past review concerns.

src/pages/portfolio/[...page].astro (3)

20-25: LGTM! Tag collection properly implements slugification.

The code correctly creates uniqueTags with both label and slug properties, addressing the past review concerns about URL-safe routing.


64-72: LGTM! Tag navigation uses slugs for URLs.

The tag filter links correctly use tag.slug for routing while displaying tag.label for readability, addressing the past review concerns.


102-116: LGTM! Pagination uses page.lastPage consistently.

The pagination implementation correctly uses page.lastPage instead of hard-coded calculations, matching the pattern from the blog page refactor.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/pages/portfolio/[...page].astro (1)

20-25: LGTM! Tag processing logic is correctly implemented.

The tag extraction, deduplication, slugification, and prop passing are all correct. The implementation mirrors the logic in the tag-specific page, which ensures consistency.

Consider extracting shared tag processing logic (optional).

Both this file and src/pages/portfolio/tag/[tag]/[...page].astro contain identical tag processing logic (lines 20-25 here, lines 19-25 there). While the duplication is minimal and acceptable for separate pages, you could optionally extract this to a shared utility function like getUniqueTags(projects) to ensure both pages stay in sync if the logic evolves.

Also applies to: 36-39, 44-44, 47-47

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bea4d7a and b1e7043.

📒 Files selected for processing (2)
  • src/pages/portfolio/[...page].astro (5 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)
🔇 Additional comments (6)
src/pages/portfolio/tag/[tag]/[...page].astro (3)

18-57: LGTM! Well-structured static path generation.

The getStaticPaths implementation correctly:

  • Extracts and deduplicates tags from all projects
  • Slugifies tags for URL-safe routing while preserving display labels
  • Filters projects by comparing slugified tags (line 41)
  • Generates paginated pages for each tag with proper props

The round-robin image assignment (line 32) is a practical approach for demo content.


59-66: LGTM! Clear type definitions.

The Props interface correctly defines all required props with proper types, including the Page type for Astro pagination and the tag shape with label and slug.


68-150: LGTM! Solid template implementation with good UX touches.

The template correctly implements:

  • Custom breadcrumb labels for improved navigation (lines 76-78)
  • Tag filter panel with proper disabled state for the current tag (lines 88-96)
  • Tag badge highlighting that compares display labels (line 121)
  • Pagination using page.lastPage property (lines 134, 140, 142) instead of hard-coded calculations

All past concerns have been addressed.

src/pages/portfolio/[...page].astro (3)

61-68: LGTM! Clean tag navigation UI.

The tag filter panel correctly generates links using slugified URLs while displaying human-readable labels. The layout uses flexbox with wrapping for responsive behavior.


84-88: LGTM! Tag badges enhance project cards.

The meta slot correctly displays all project tags as badges. Unlike the tag-specific page, there's no highlighting here, which is appropriate since this is the main portfolio view showing all projects.


98-112: LGTM! Pagination correctly uses page properties.

The pagination component properly uses page.lastPage for all calculations and conditionally renders only when multiple pages exist. This avoids the hard-coded page size issues flagged in earlier reviews.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b1e7043 and 6c7edc3.

📒 Files selected for processing (2)
  • src/components/PageHeader.astro (2 hunks)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)

Comment on lines +110 to +125
if (joinLastBreadcrumb && index === segments.length - 1) {
return null
}

const url = `/${segments.slice(0, index + 1).join('/')}`
const isLast = index === segments.length - 1
const formattedName = segment
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
const isLast = index === segments.length - 1 || (joinLastBreadcrumb && index === segments.length - 2)

const formattedName = (() => {
if (joinLastBreadcrumb && isLast) {
const prevSegment = segments[segments.length - 2]
const lastSegment = segments[segments.length - 1]
return `${formatSegment(prevSegment)}: ${formatSegment(lastSegment)}`
}

return formatSegment(segment)
})()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard joinLastBreadcrumb for single-segment paths

With joinLastBreadcrumb enabled on a single-segment route (e.g. /portfolio), the lone breadcrumb item is suppressed because we null out the final segment even though there is nothing to join. That regresses the breadcrumb trail to just “Home”. Please gate the join-only logic behind a segments.length > 1 check so single-level pages keep their label.

-                if (joinLastBreadcrumb && index === segments.length - 1) {
+                if (joinLastBreadcrumb && segments.length > 1 && index === segments.length - 1) {
                   return null
                 }
 
                 const url = `/${segments.slice(0, index + 1).join('/')}`
-                const isLast =
-                  index === segments.length - 1 ||
-                  (joinLastBreadcrumb && index === segments.length - 2)
+                const isLast =
+                  index === segments.length - 1 ||
+                  (joinLastBreadcrumb && segments.length > 1 && index === segments.length - 2)
 
                 const formattedName = (() => {
-                  if (joinLastBreadcrumb && isLast) {
+                  if (joinLastBreadcrumb && segments.length > 1 && index === segments.length - 2) {
                     const prevSegment = segments[segments.length - 2]
                     const lastSegment = segments[segments.length - 1]
                     return `${formatSegment(prevSegment)}: ${formatSegment(lastSegment)}`
                   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (joinLastBreadcrumb && index === segments.length - 1) {
return null
}
const url = `/${segments.slice(0, index + 1).join('/')}`
const isLast = index === segments.length - 1
const formattedName = segment
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
const isLast = index === segments.length - 1 || (joinLastBreadcrumb && index === segments.length - 2)
const formattedName = (() => {
if (joinLastBreadcrumb && isLast) {
const prevSegment = segments[segments.length - 2]
const lastSegment = segments[segments.length - 1]
return `${formatSegment(prevSegment)}: ${formatSegment(lastSegment)}`
}
return formatSegment(segment)
})()
if (joinLastBreadcrumb && segments.length > 1 && index === segments.length - 1) {
return null
}
const url = `/${segments.slice(0, index + 1).join('/')}`
const isLast =
index === segments.length - 1 ||
(joinLastBreadcrumb && segments.length > 1 && index === segments.length - 2)
const formattedName = (() => {
if (joinLastBreadcrumb && segments.length > 1 && index === segments.length - 2) {
const prevSegment = segments[segments.length - 2]
const lastSegment = segments[segments.length - 1]
return `${formatSegment(prevSegment)}: ${formatSegment(lastSegment)}`
}
return formatSegment(segment)
})()
🤖 Prompt for AI Agents
In src/components/PageHeader.astro around lines 110–125, the joinLastBreadcrumb
logic can suppress the only breadcrumb on single-segment pages; guard the
join-only behavior with a segments.length > 1 check so single-level routes still
render their label. Specifically, require segments.length > 1 before returning
null for the last segment, include the same segments.length > 1 condition when
computing isLast, and only run the "join prev and last segment" branch of
formattedName when segments.length > 1.

markteekman
markteekman previously approved these changes Oct 12, 2025
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6c7edc3 and d6e9634.

📒 Files selected for processing (1)
  • src/pages/portfolio/tag/[tag]/[...page].astro (1 hunks)

Comment on lines +73 to +75
title={`Portfolio: ${currentTag.label}`}
subtitle={`Showing ${page.total} project${page.total !== 1 ? 's' : ''} tagged with "${currentTag.label}". <a href="/portfolio">View all projects</a> or filter by other tags below.`}
bgType="bordered"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Correct the subtitle project count.

page.total is the total number of items across all paginated pages, so on page 1 we only display 6 projects yet tell the user we're showing (say) 12. Swap to the per-page count to keep the UI truthful.

-    subtitle={`Showing ${page.total} project${page.total !== 1 ? 's' : ''} tagged with "${currentTag.label}". <a href="/portfolio">View all projects</a> or filter by other tags below.`}
+    subtitle={`Showing ${page.end - page.start + 1} project${page.end - page.start + 1 !== 1 ? 's' : ''} tagged with "${currentTag.label}". <a href="/portfolio">View all projects</a> or filter by other tags below.`}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/pages/portfolio/tag/[tag]/[...page].astro around lines 73-75, the
subtitle uses page.total (total across all pages) but should show the number of
projects on the current page; replace page.total with the current-page count
(e.g. the length of the array that holds projects for this page, such as
page.items.length or page.data.length depending on your pagination shape) so the
subtitle reads "Showing X project(s) tagged with…" where X is the per-page
count.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/pages/portfolio/[...page].astro (1)

63-70: Tag filtering UI looks good.

The implementation correctly uses slugified URLs for tag links and displays human-readable labels. The tag navigation will work properly now that slugs are being used for routing.

For consistency with line 104, consider using a template literal on line 67:

-            <Link href={'/portfolio/tag/' + tag.slug}>{tag.label}</Link>
+            <Link href={`/portfolio/tag/${tag.slug}`}>{tag.label}</Link>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d6e9634 and b263aaa.

📒 Files selected for processing (1)
  • src/pages/portfolio/[...page].astro (5 hunks)
🔇 Additional comments (2)
src/pages/portfolio/[...page].astro (2)

86-90: LGTM! Tag badges enhance project cards nicely.

The meta slot implementation cleanly displays project tags using Badge components, providing good visual hierarchy and metadata organization.


97-111: Pagination implementation is solid.

The conditional rendering (page.lastPage > 1) correctly hides pagination when unnecessary, and the component receives all required props for proper navigation. The URL construction properly handles first, previous, next, and last page links.

const { page } = Astro.props
const { page, uniqueTags } = Astro.props

console.log(page.lastPage)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove debug console.log statement.

This debug logging statement should be removed before merging to production.

Apply this diff:

-console.log(page.lastPage)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(page.lastPage)
🤖 Prompt for AI Agents
In src/pages/portfolio/[...page].astro around line 49, remove the debug
console.log(page.lastPage) statement; simply delete that line (or replace it
with a proper logger call if intentional debug info is required behind a feature
flag) so no debug console output remains in production.

@markteekman markteekman merged commit 2bcbd20 into main Oct 13, 2025
5 checks passed
@markteekman markteekman deleted the feature/tag-badges branch January 6, 2026 19:21
@coderabbitai coderabbitai Bot mentioned this pull request Feb 14, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Mar 28, 2026
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.

2 participants