Skip to content

Instantly share code, notes, and snippets.

@souporserious
Created August 15, 2024 19:04
Show Gist options
  • Save souporserious/65c08d6a0cda7539629188340e06c970 to your computer and use it in GitHub Desktop.
Save souporserious/65c08d6a0cda7539629188340e06c970 to your computer and use it in GitHub Desktop.
title date
Collections
2024-07-10

Collections

Collections are a way to group and organize related files. They can be used to generate static pages, create navigations, and more. At their core, they abstract directories and files into a Source, allowing you to analyze and render them programmatically.

Routing

Creating a Collection

A collection is created by calling createCollection with a glob pattern and optional options:

import { createCollection, type SourceOf } from 'mdxts'

export const PostsCollection = createCollection<{
  frontmatter: {
    title: string
    description: string
  }
}>('@/posts/*.mdx', {
  tsConfigFilePath: 'tsconfig.json',
})

export type PostSource = SourceOf<typeof Posts>

This will create a collection of files and directories normalized as a Source that can be used to generate static pages, render pages, and more.

Rendering a Page

import { PostsCollection, type PostSource } from '@/collections'

export default async function Page({ params }: { params: { slug: string } }) {
  const Post = await PostsCollection.getSource(params.path)

  if (!Post) notFound()

  const frontmatter = await Post.getNamedExport('frontmatter').getValue()
  const Content = await Post.getDefaultExport().getValue()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Content />
    </>
  )
}

Generating Static Params

To loop through each Source use the getSources method and return an object with the path as the key. This will be used to generate static multi-dimensional routes as it returns an array of path segments:

import { PostsCollection } from '@/collections'

export function generateStaticParams() {
  return PostsCollection.getSources().map((Source) => ({
    path: Source.getPath(),
  }))
}

Generating Navigations

To generate navigations we can use the getSources and getSiblings methods to loop through each Source and generate a list or tree of links.

List Navigation

Use getSources to render a list of the immediate sources in the collection:

export default async function Page() {
  return (
    <>
      <h1>All Posts</h1>
      <ul>
        {PostsCollection.getSources().map((Source) => (
          <Post key={Source.getPath()} Source={Source} />
        ))}
      </ul>
    </>
  )
}

Paginated Navigation

To paginate the sources, we can use the getSources method to retrieve all sources, sort them, and paginate them:

const LIMIT = 10

export default async function Page({
  searchParams,
}: {
  searchParams: { page: string; order: 'asc' | 'desc' }
}) {
  const page = parseInt(searchParams.page, 10) || 0
  const allSources = await PostsCollection.getSources()

  // Retrieve the frontmatter for sorting
  const sourcesWithfrontmatter = await Promise.all(
    allSources.map(async (source) => {
      const frontmatter = await source.getNamedExport('frontmatter').getValue()
      return { source, frontmatter }
    })
  )

  // Sort the sources based on the order
  sourcesWithfrontmatter.sort((a, b) => {
    if (searchParams.order === 'asc') {
      return new Date(a.frontmatter.date) - new Date(b.frontmatter.date)
    }
    return new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
  })

  // Paginate the sources
  const offset = page * LIMIT
  const totalPages = Math.ceil(allSources.length / LIMIT)
  const startIndex = offset
  const endIndex = startIndex + LIMIT
  const paginatedSources = sourcesWithfrontmatter.slice(startIndex, endIndex)

  return (
    <>
      <h1>Posts</h1>
      <nav>
        <ul>
          {paginatedSources.map(({ source }) => (
            <Post key={source.getPath()} Source={source} />
          ))}
        </ul>
      </nav>
      <nav>
        <ul>
          {Array.from(Array(totalPages).keys()).map((index) => (
            <li key={index}>
              <Link href={`/posts/page/${index}`}>{index + 1}</Link>
            </li>
          ))}
        </ul>
      </nav>
    </>
  )
}

Tree Navigation

Similar to list navigation, we can use getSources recursively to render a tree of links:

import { PostsCollection } from '@/collections'

export default async function Layout() {
  return (
    <nav>
      <ul>
        <TreeNavigation Source={PostsCollection} />
      </ul>
    </nav>
  )
}

async function TreeNavigation({ Source }: { Source: PostSource }) {
  const Sources = Source.getSources()
  const path = Source.getPath()
  const depth = Source.getDepth()
  const frontmatter = await Source.getNamedExport('frontmatter').getValue()

  if (Sources.length === 0) {
    return (
      <li style={{ paddingLeft: `${depth}rem` }}>
        <Link href={path} style={{ color: 'white' }}>
          {frontmatter.title}
        </Link>
      </li>
    )
  }

  const childrenSources = Sources.map((ChildSource) => (
    <TreeNavigation key={ChildSource.getPath()} Source={ChildSource} />
  ))

  if (depth > 0) {
    return (
      <li style={{ paddingLeft: `${depth}rem` }}>
        <Link href={path} style={{ color: 'white' }}>
          {frontmatter.title}
        </Link>
        <ul>{childrenSources}</ul>
      </li>
    )
  }

  return <ul>{childrenSources}</ul>
}

Sibling Navigation

export default async function Page({ params }) {
  const PostFile = Posts.getSource(params.slug)

  if (!PostFile) notFound()

  const Post = await PostFile.getDefaultExport().getValue()
  const frontmatter = await PostFile.getNamedExport('frontmatter').getValue()
  const [Previous, Next] = PostFile.getSiblings()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Post />
      {Previous ? <Sibling Source={Previous} direction="previous" /> : null}
      {Next ? <Sibling Source={Next} direction="next" /> : null}
    </>
  )
}

function Sibling({
  Source,
  direction,
}: {
  Source: ReturnType<typeof Posts.getSource>
  direction: 'previous' | 'next'
}) {
  const frontmatter = await Source.getNamedExport('frontmatter').getValue()
  return (
    <a href={Source.getPath()}>
      <span>{direction === 'previous' ? 'Previous' : 'Next'}</span>
      {frontmatter.title}
    </a>
  )
}

Blogs

blog/[slug]/page.tsx

import type { MDXContent, SourceOf } from 'mdxts'
import { createCollection } from 'mdxts'
import { getSiteMetadata } from '@/utils'

export const Posts = createCollection<{
  default: MDXContent
  frontmatter: {
    title: string
    description: string
  }
}>('@/posts/*.mdx', {
  tsConfigFilePath: 'tsconfig.json',
})

export type PostSource = SourceOf<typeof Posts>

export function generateStaticParams() {
  return Posts.getSources().map((Source) => ({
    slug: Source.getPath(),
  }))
}

export async function generateMetadata({ params }) {
  const Post = Posts.getSource(params.slug)
  const frontmatter = await Post.getNamedExport('frontmatter').getValue()

  return getSiteMetadata({
    title: `${frontmatter.title} - MDXTS`,
    description: frontmatter.description,
  })
}

export default async function Page({ params }) {
  const Post = await Posts.getSource(params.slug)

  if (!Post) notFound()

  const Content = await Post.getDefaultExport().getValue()
  const frontmatter = await Post.getNamedExport('frontmatter').getValue()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Content />
    </>
  )
}

blog/layout.tsx

import { Posts, type PostSource } from './[slug]/page'

function Navigation({ Source }: { Source: PostSource }) {
  const Sources = Source.getSources()

  if (Sources.length === 0) return null

  return (
    <ul>
      {Sources.map((SourceItem) => (
        <li key={SourceItem.getPath()}>
          {SourceItem.getPath()}
          <Navigation Source={SourceItem} />
        </li>
      ))}
    </ul>
  )
}

export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      <aside>
        <h2>Posts</h2>
        <Navigation Source={Posts} />
      </aside>
      <main>{children}</main>
    </div>
  )
}

blog/page.tsx

import { Posts, type PostSource } from './[slug]/page'

export default async function BlogPage() {
  return (
    <>
      <h1>All Posts</h1>
      <ul>
        {Posts.getSources().map((Source) => (
          <BlogPost key={Source.getPath()} Source={Source} />
        ))}
      </ul>
    </>
  )
}

async function BlogPost({ Source }: { Source: PostSource }) {
  const frontmatter = await Source.getNamedExport('frontmatter').getValue()

  return (
    <li>
      <a href={Source.getPath()}>
        <h2>{frontmatter.title}</h2>
        <p>{frontmatter.description}</p>
      </a>
    </li>
  )
}

API Documentation

components/[slug]/page.tsx

import { createCollection } from 'mdxts'

export const Components = createCollection('@/components/**/index.{ts,tsx}')

export const ComponentsMDX = createCollection('@/components/**/README.mdx')

export function generateStaticParams() {
  return Components.getSources().map((Component) => ({
    slug: Component.getPath(),
  }))
}

export default async function Page({ params }) {
  const Component = Components.getSource(params.slug)
  const ComponentMDX = ComponentsMDX.getSource(params.slug)

  if (!Component && !ComponentMDX) notFound()

  const Content = await ComponentMDX.getDefaultExport().getValue()

  return (
    <>
      <h1>{ComponentFile.getLabel()}</h1>
      <Content />
      <Component>
        <APIReference />
      </Component>
    </>
  )
}

components/[slug]/[example]/page.tsx

import { createCollection, Tokens } from 'mdxts'

export const ComponentExamples = createCollection<
  Record<string, React.ComponentType>
>('@/components/**/*.examples.tsx', {
  resolveBasePathname: (pathname) => pathname.replace('.examples', ''),
})

export function generateStaticParams() {
  return ComponentExamples.getSources().map((Component) => {
    const componentPath = Component.getPath()
    return Component.getNamedExports().map(([exportName]) => ({
      component: componentPath,
      example: exportName,
    }))
  })
}

export default async function Page({
  params,
}: {
  params: { component: string; example: string }
}) {
  const ExampleSource = ComponentExamples.getSource(params.component)

  if (!ExampleSource) notFound()

  const ExportedSource = ExampleSource.getNamedExport(params.example)

  if (!ExportedSource) notFound()

  const name = ExportedSource.getName()
  const Example = await ExportedSource.getValue()

  return (
    <div>
      {name}
      <ExampleSource>
        {/* show all examples and highlight the focused example */}
        <Tokens
          focus={[[ExportedSource.getStart(), ExportedSource.getEnd()]]}
        />

        {/* alternatively, pass the source */}
        <Tokens focus={[ExportedSource]} />
      </ExampleSource>

      <ExportedSource>
        {/* display highlighted example source */}
        <Tokens />
      </ExportedSource>

      {/* render the example */}
      <Example />
    </div>
  )
}

Custom TypeScript Configuration

packages/[slug]/page.tsx

import { APIReference, createCollection, type CollectionOptions } from '#mdxts'

const sharedOptions = {
  tsConfigFilePath: '../packages/mdxts/tsconfig.json',
} satisfies CollectionOptions

export const Packages = createCollection('src/**/index.{ts,tsx}', sharedOptions)

export const PackagesMDX = createCollection('src/**/README.mdx', sharedOptions)

export function generateStaticParams() {
  return Packages.getSources().map((file) => ({
    component: file.getPath(),
  }))
}

export default async function Page({ params }) {
  const [PackageFile, PackageMDXFile] = await Promise.all([
    Packages.getSource(params.component),
    PackagesMDX.getSource(params.component),
  ])

  if (!PackageFile && !PackageMDXFile) notFound()

  const PackageDocs = await PackageMDXFile.getDefaultExport().getValue()

  return (
    <>
      <h1>{PackageFile.getLabel()}</h1>
      <PackageDocs />
      <PackageFile>
        <APIReference />
      </PackageFile>
    </>
  )
}

Navigation

post/[slug].tsx

import { createCollection } from 'mdxts'

const Posts = createCollection('@/posts/*.mdx')

export default async function Page({ params }) {
  const PostFile = Posts.getSource(params.slug)

  if (!PostFile) notFound()

  const frontmatter = await PostFile.getNamedExport('frontmatter').getValue()
  const Post = await PostFile.getDefaultExport().getValue()
  const [Previous, Next] = PostFile.getSiblings()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Post />
      {Previous ? <Sibling Source={Previous} direction="previous" /> : null}
      {Next ? <Sibling Source={Next} direction="next" /> : null}
    </>
  )
}

function Sibling({
  Source,
  direction,
}: {
  Source: ReturnType<typeof Posts.getSource>
  direction: 'previous' | 'next'
}) {
  const frontmatter = await Source.getNamedExport('frontmatter').getValue()

  return (
    <a href={Source.getPath()}>
      <span>{direction === 'previous' ? 'Previous' : 'Next'}</span>
      {frontmatter.title}
    </a>
  )
}

Content

post.mdx

import { APIReference, File, Tokens } from 'mdxts'

<File name="index.ts" value="const a = 1;">
  <Tokens />
</File>

<File path="./Button.tsx">
  <APIReference />
</File>

Code Blocks

mdx-components.tsx

import { CodeBlock } from 'mdxts'

export function useMDXComponents() {
  return {
    pre: (props) => {
      const { value, language } = CodeBlock.parsePreProps(props)
      return <CodeBlock value={value} language={language} />
    },
  }
}

mdx-components.tsx (custom tsconfig)

import { CodeBlock } from 'mdxts'

export function useMDXComponents() {
  return {
    pre: (props) => {
      const { value, language } = CodeBlock.parsePreProps(props)
      return (
        <CodeBlock
          value={value}
          language={language}
          tsConfigFilePath="tsconfig.json"
        />
      )
    },
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment