aboutsummaryrefslogtreecommitdiffstats
path: root/src/content/blog/mobile-nav-and-subposts/subposts.mdx
blob: e5982b5e0e187008a142c66880fb61df96419b7b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
---
title: 'Implementing file-based subpost routing'
description: 'How file-based hierarchical content organization works under the hood'
date: 2025-05-21
tags: ['v1.6.0']
authors: ['enscribe']
order: 2
---

import Callout from '@/components/Callout.astro'

The subposts feature leverages Astro's file-based routing to automatically detect parent-child relationships without any configuration. The entire implementation hinges on a simple observation: if a post ID contains a forward slash, it's a subpost.

```typescript title="src/lib/data-utils.ts" startLineNumber={161}
export function isSubpost(postId: string): boolean {
  return postId.includes('/')
}

export function getParentId(subpostId: string): string {
  return subpostId.split('/')[0]
}
```

This is a pretty elegant solution which requires no frontmatter configuration, no manual relationship mapping, and zero migration effort for existing posts.

## Navigation complexity

One of the more intricate parts of this update was rethinking navigation. The original `getAdjacentPosts(){:ts}` function assumed simple previous/next relationships. With subposts, we now have three distinct navigation contexts:

<Callout variant="example">
  Consider this structure:
  ```
  blog/
    getting-started.mdx
    react-tutorial/
      index.mdx
      components.mdx
      state.mdx
    advanced-patterns.mdx
  ```

  Navigation depends on context:
  - From `getting-started.mdx`: next goes to `react-tutorial/index.mdx`
  - From `react-tutorial/components.mdx`: next goes to `state.mdx`, previous to `index.mdx`
  - From `react-tutorial/state.mdx`: previous goes to `components.mdx`, parent goes to `index.mdx`
</Callout>

Here's how we handle this complexity:

```typescript title="src/lib/data-utils.ts" startLineNumber={40}
export async function getAdjacentPosts(currentId: string): Promise<{
  newer: CollectionEntry<'blog'> | null
  older: CollectionEntry<'blog'> | null
  parent: CollectionEntry<'blog'> | null
}> {
  const allPosts = await getAllPosts()

  if (isSubpost(currentId)) {
    const parentId = getParentId(currentId)
    const parent = allPosts.find((post) => post.id === parentId) || null

    // Get all sibling subposts
    const posts = await getCollection('blog')
    const subposts = posts
      .filter(
        (post) =>
          isSubpost(post.id) &&
          getParentId(post.id) === parentId &&
          !post.data.draft
      )
      .sort((a, b) => a.data.date.valueOf() - b.data.date.valueOf())

    const currentIndex = subposts.findIndex((post) => post.id === currentId)
    
    return {
      newer: currentIndex < subposts.length - 1 
        ? subposts[currentIndex + 1] 
        : null,
      older: currentIndex > 0 
        ? subposts[currentIndex - 1] 
        : null,
      parent,
    }
  }

  // For parent posts, only navigate among other parent-level posts
  const parentPosts = allPosts.filter((post) => !isSubpost(post.id))
  const currentIndex = parentPosts.findIndex((post) => post.id === currentId)

  return {
    newer: currentIndex > 0 ? parentPosts[currentIndex - 1] : null,
    older: currentIndex < parentPosts.length - 1 
      ? parentPosts[currentIndex + 1] 
      : null,
    parent: null,
  }
}
```

As a TL;DR, subposts should only navigate among siblings and should be able to go up to their parent, while parent posts should only navigate among other parent-level posts.

## Other considerations

- The breadcrumb component required careful thought to handle three distinct cases:

    ```astro title="src/pages/blog/[...id].astro" startLineNumber={70}
    <Breadcrumbs
      items={[
        { href: '/blog', label: 'Blog', icon: 'lucide:library-big' },
        ...(isCurrentSubpost && parentPost
          ? [
              {
                href: `/blog/${parentPost.id}`,
                label: parentPost.data.title,
                icon: 'lucide:book-open',
              },
              {
                href: `/blog/${currentPostId}`,
                label: post.data.title,
                icon: 'lucide:file-text',
              },
            ]
          : [
              {
                href: `/blog/${currentPostId}`,
                label: post.data.title,
                icon: 'lucide:book-open-text',
              },
            ]),
      ]}
    />
    ```

    We append `-text` to `book-open` or `file` (for parent posts and subposts, respectively) to indicate the active post by differentiating it from inactive icons which would lack the text within the icon.

- The main blog listing (alongside other listings, e.g. filtering by tags, filtering by author) should exclude subposts to avoid cluttering the feed:

    ```typescript title="src/lib/data-utils.ts" startLineNumber={7}
    export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
    const posts = await getCollection('blog')
    return posts
        .filter((post) => !post.data.draft && !isSubpost(post.id))
        .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
    }
    ```

    Without this filter, your blog listing would show every subpost as a top-level entry, defeating the purpose of hierarchical organization.

- Desktop and mobile require fundamentally different approaches for displaying the subpost hierarchy. On desktop, we have the luxury of a persistent sidebar. On mobile, screen real estate demands integration with the sticky header system. This required careful slot management in the top-level `Layout.astro`:

    ```astro title="src/layouts/Layout.astro"
    <div class="bg-background/50 sticky top-0 z-50 border-b backdrop-blur-sm">
        <Header />
        <slot name="subposts-navigation" />
        <slot name="table-of-contents" />
    </div>
    ```

    The order is semantic here since subposts navigation comes before table of contents, creating a logical hierarchy of navigation options from broad (which post/subpost) to specific (which section).

- Not much testing has been done for deep nesting but my assumption is that it shouldn't work. This is intentional to maintain simplicity, since at that point you might as well use a documentation site rather than a blogging site.