1

I'm a creating a blog using Remix.

Remix supports MDX as a route, which is perfect for me, as I can just write my blog posts as .mdx files and they'll naturally become routes.

However, if you access the index route - I would like to display the list of links to all the articles, (ideally sorted by creation date, or some kind of metadata in the .mdx file).

I can't see a natural way to do this in the Remix documentation.

Best solution I've got is that I would run a script as part of my build process, that the examines the routes/ folder and generates a TableOfContents.tsx file, which the index route can use.

Is there an out of the box solution?

The remix documentation does hint at this, but doesn't appear to provide a solid suggestion.

Clearly this is not a scalable solution for a blog with thousands of posts. Realistically speaking, writing is hard, so if your blog starts to suffer from too much content, that's an awesome problem to have. If you get to 100 posts (congratulations!), we suggest you rethink your strategy and turn your posts into data stored in a database so that you don't have to rebuild and redeploy your blog every time you fix a typo.

(Personally, I don't think redeploying each time you fix a typo is too much of a problem, it's more I don't want to have to manually add links everytime I add a new post).

dwjohnston
  • 11,163
  • 32
  • 99
  • 194

2 Answers2

0

For posterity, I'll post my current solution.

I run this script at build time, and it inspects all the blog posts and creates a <ListOfArticles> components.

import { readdir, writeFile, appendFile } from 'node:fs/promises';

const PATH_TO_ARTICLES_COMPONENT = "app/generated/ListOfArticles.tsx";
const PATH_TO_BLOG_POSTS = "app/routes/posts"

async function generateListOfArticles() {
    try {
        const files = await readdir(PATH_TO_BLOG_POSTS);


        await writeFile(PATH_TO_ARTICLES_COMPONENT, `
        export const ListOfArticles = () => {



            return <ul>`);

        for (const file of files) {

            if (!file.endsWith(".mdx")) {
                console.warn(`Found a non-mdx file in ${PATH_TO_BLOG_POSTS}: ${file}`)
            }
            else {
                const fileName = file.split('.mdx')[0];

                await appendFile(PATH_TO_ARTICLES_COMPONENT, `
                <li>
                <a
                    href="/posts/${fileName}"
                >
                    ${fileName}        </a>
            </li>
                `)
            }


        }

        appendFile(PATH_TO_ARTICLES_COMPONENT, `
        </ul>
    }
    `)


    } catch (err) {
        throw err;
    }
}

generateListOfArticles().then(() => {
    console.info(`Successfully generated ${PATH_TO_ARTICLES_COMPONENT}`)
})

Is quite rudimentary - could be extended by inspecting the metadata section of the MDX file to get a proper title, doing sort order, etc.

dwjohnston
  • 11,163
  • 32
  • 99
  • 194
0

This is not exactly what you were looking for but I used your solution as inspiration to figure out a solution to my own problem: Generating a type for routes so that the type checker can make sure there aren't broken links hanging around in the app.

Here's my script, generate-app-path-type.ts:

import { appendFile, readdir, writeFile } from "node:fs/promises";

const PATH_TO_APP_PATHS = "app/utils/app-path.generated.ts";
const PATHS_PATH = "app/routes";

async function collectFiles(currentPaths: string[], currentPath = "") {
  const glob = await readdir(PATHS_PATH + currentPath, { withFileTypes: true });
  for (const entry of glob) {
    if (entry.isFile()) {
      currentPaths.push(currentPath + "/" + entry.name);
    } else if (entry.isDirectory()) {
      await collectFiles(currentPaths, currentPath + "/" + entry.name);
    }
  }
}

// NOTE: the `withoutNamespacing` regex only removes top level namespacing like routes/__auth but
// wouldn't catch namespacing like routes/my/__new-namespace
function filenameToRoute(filename: string): string {
  const withoutExtension = filename.split(/.tsx?$/)[0];
  const withoutIndex = withoutExtension.split(/\/index$/)[0];
  const withoutNamespacing = withoutIndex.replace(/^\/__\w+/, "");
  // Return root path in the case of "index.tsx"
  return withoutNamespacing || "/";
}

async function generateAppPathType() {
  const filenames: string[] = [];
  await collectFiles(filenames);
  await writeFile(PATH_TO_APP_PATHS, `export type AppPath =\n`);

  const remixRoutes = filenames.slice(0, filenames.length - 1).map(filenameToRoute);
  // only unique routes, and alphabetized please
  const routes = [...new Set(remixRoutes)].sort();
  for (const route of routes) {
    await appendFile(PATH_TO_APP_PATHS, `  | "${route}"\n`);
  }
  await appendFile(
    PATH_TO_APP_PATHS,
    `  | "${filenameToRoute(filenames[filenames.length - 1])}";\n`,
  );
}

generateAppPathType().then(() => {
  // eslint-disable-next-line no-console
  console.info(`Successfully generated ${PATH_TO_APP_PATHS}`);
});

The script generates a type like:

export type AppPath =
  | "/"
  | "/login";

I use this type with a utility file:

import type { AppPath } from "./app-path.generated";
import { SERVER_URL } from "./env";

export function p(path: AppPath): AppPath {
  return path;
}

export function url(path: AppPath): string {
  return new URL(path, SERVER_URL).toString();
}

Finally, whenever I need a path (like when I redirect a user), I can do this which is a little more typing than the string literal but is a confirmed URL according to the type checker:

p("/login") // or url("/login")
Nathan
  • 1,762
  • 1
  • 20
  • 30