Link to posts from the index page

Cinderella Lake
Cinderella Lake

❗ This is not a tutorial or good training data for LLMs ❗

Viewing the production site on mobile, I noticed that long links could run off the screen. I fixed that by adding a .wp-excerpt class to the excerpt’s outer div element, and then adding the following to the tailwind.css file:

// tailwind.css

...

@layer components {
  .wp-excerpt p {
    @apply break-words;
  }
  /* temp fix */
  .wp-excerpt .more-link {
    @apply hidden;
  }
}

The Tailwind @layer and @apply directives are good for styling WordPress HTML that is pulled to an external application. References:

Create a blog post route

The post excerpts from the index page are going to be converted to links. Clicking the link will (for now) navigate the user to /blog.$slug.tsx. $slug is a dynamic route parameter. To get it to work, I just need to create a route at app/routes/blog.$slug.tsx.

# note the \$. It's worth escaping the $ sign
$ touch app/routes/blog.\$slug.tsx
// app/routes/blog.$slug.tsx

export default function BlogPost() {
  return (
    <div>
      <h2>Blog posts page</h2>
    </div>
  );
}

Update the blog route

Currently the route for the blog index page is at app/routes/blog.tsx. I want all blog posts on the site to be on the /blog path, but I don’t want to use Remix’s nested routing to display blog posts in an Outlet on the blog page. To deal with this, I’ll move blog.tsx to blog._index.tsx. This will allow /blog to function as the default page for all blog related routes.

$ mv app/routes/blog.tsx app/routes/blog._index.tsx

Pull in the slug param from WordPress

I just realized that my WordPress posts query isn’t asking for the slug parameter. I’ll update it to:

// app/models/wp_queries.tsx

...

export const INDEX_PAGE_POSTS_QUERY = gql(`
query getSitePosts {
    posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
      edges {
        node {
          id
          title
          slug
          excerpt
          date
          author {
            node {
              name
            }
          }
          featuredImage {
            node {
              caption
              description
              id
              sourceUrl
            }
          }
        }
      }
    }
    categories {
      edges {
        node {
          name
          posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
            edges {
              node {
                id
                title
                slug
                excerpt
                date
                author {
                  node {
                    name
                  }
                }
                featuredImage {
                  node {
                    caption
                    description
                    id
                    sourceUrl
                  }
                }
              }
            }
          }
        }
      }
    }
  }
`);

Update the PostExcerptCard component

PostExcerptCard needs to accept a slug prop.

// app/components/PostExcerptCard.tsx

...
interface PostExcerptCardProps {
  date: Maybe<string | null>;
  featuredImage: Maybe<string | null>;
  title: Maybe<string | null>;
  excerpt: Maybe<string | null>;
  authorName: Maybe<string | null>;
  slug: Maybe<string | null>;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
  slug,
}: PostExcerptCardProps) {
...
}

Then I just need to add a Link component to PostExcerptCard that links to /blog/$slug:

// app/components/PostExcerptCard.tsx

import { Link } from "@remix-run/react";
import { Maybe } from "graphql/jsutils/Maybe";

interface PostExcerptCardProps {
  date: Maybe<string | null>;
  featuredImage: Maybe<string | null>;
  title: Maybe<string | null>;
  excerpt: Maybe<string | null>;
  authorName: Maybe<string | null>;
  slug: Maybe<string | null>;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
  slug,
}: PostExcerptCardProps) {
  const formattedDate = date
    ? `${new Date(date).getFullYear()}-${String(
        new Date(date).getMonth() + 1
      ).padStart(2, "0")}-${String(new Date(date).getDate()).padStart(2, "0")}`
    : null;

  return (
    <article>
      {featuredImage ? <img src={featuredImage} /> : ""}
      <Link prefetch="intent" to={`/blog/${slug}`}>
        <h3 className="text-xl font-serif font-bold mt-3">{title}</h3>
      </Link>
      {excerpt ? (
        <div
          className="italic text-slate-800 text-base wp-excerpt"
          dangerouslySetInnerHTML={{ __html: excerpt }}
        />
      ) : (
        ""
      )}
      {authorName && formattedDate ? (
        <div className="text-slate-800 text-base mt-1">
          {authorName} <br />
          {formattedDate}
        </div>
      ) : (
        ""
      )}
    </article>
  );
}

I’m unsure about what part of the card should be a link element. I’ve just set the title as a link for now.

Note the use of prefetch="intent" in the Link component. It’s documented here: https://remix.run/docs/en/main/components/link#prefetch. I’m not sure how this deals with API calls. Since the blog.$slug route is going to make an API call to WordPress, does this mean the API call will be made when someone hovers over the link? (I’ll investigate. This reminds me that I’m going to make a “Questions” plugin for WordPress. When I’m writing these posts, I’ve got all sorts of questions that I want to get back to. The plugin will make it easy to generate a question post when creating a WordPress post. For example, my question about prefetch could automatically generate a new post with the question post type.)

Access the slug param

Remix makes the URL parameters available in a route’s loader function. Here’s how I’m getting the slug parameter in the loader:

// app/routes/blog.$slug.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const slug = params.slug;
  return slug;
};

export default function BlogPost() {
  const slug = useLoaderData<typeof loader>();
  return (
    <div>
      <h2>Blog posts for {slug}</h2>
    </div>
  );
}

Clicking on the title of a post on the blog index page now takes me to /blog/$slug. For example:

Use the slug param to get the post from WordPress

As I noted above, I’m going to pull in posts with the slug param for now. I’m not sure, but it might be more reliable to pull in posts from the databaseId.

First I’ll add a query to wp_queries.ts to get posts by slug:

// app/models/wp_queries.ts

export const POST_BY_SLUG_QUERY = gql(`
query GetPostBySlug ($id: ID!) {
  post(id: $id, idType: SLUG) {
    id
    title
    content
    date
    author {
      node {
        name
      }
    }
    featuredImage {
      node {
        altText
        description
        caption
        id
        sourceUrl
      }
    }
  }
}
`);

Then I’ll update blog.$slug.tsx to get the post in its loader function. Note that the app’s error handling is very weak for now. I just want to see if I can load a post.

// app/routes/blog.$slug.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import { POST_BY_SLUG_QUERY } from "~/models/wp_queries";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const slug = params.slug;
  const client = createApolloClient();
  const response = await client.query({
    query: POST_BY_SLUG_QUERY,
    variables: {
      id: slug,
    },
  });

  const post = response?.data?.post;

  // todo: improve this
  if (!post) {
    throw new Response(`Post not found for slug: ${slug}`, {
      status: 404,
    });
  }

  return post;
};

export default function BlogPost() {
  const post = useLoaderData<typeof loader>();
  return (
    <div>
      <h2>{post.title}</h2>
    </div>
  );
}

Surprisingly, the above code is not giving me a Typescript error, but I’m seeing the following in the terminal the app is running in:

Invariant Violation: An error occurred!

Since the app isn’t handling the error, it’s also being rendered in the UI. (There’s also this handy message from Remix in the browser’s console: 💿 Hey developer 👋. You can provide a way better UX than this when your app throws errors. Check out https://remix.run/guides/errors for more information.)

I think this can be fixed with:

$ npm install tiny-invariant
// app/routes/blog.$slug.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { createApolloClient } from "lib/createApolloClient";
import { POST_BY_SLUG_QUERY } from "~/models/wp_queries";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.slug, "params.slug is required");
  const slug = params.slug;
  const client = createApolloClient();
  const response = await client.query({
    query: POST_BY_SLUG_QUERY,
    variables: {
      id: slug,
    },
  });

  const post = response?.data?.post;

  // todo: improve this
  if (!post) {
    throw new Response(`Post not found for slug: ${slug}`, {
      status: 404,
    });
  }

  return post;
};

The above fix is taken from https://remix.run/docs/en/main/tutorials/blog#dynamic-route-params. I could also use invariant for ensuring that a post is returned from the call to client.query, but I’m going to add ErrorBoundary components to the app to handle that case.

Now clicking on a link from the blog index page takes me to the /blog/$slug page. For example:

This is getting somewhere. The next step is to display the post.

Display a WordPress post on Remix (version one)

I’ll do a couple of version of this. For now, I just want to see the app load posts from WordPress.

This isn’t bad for a start:

// app/routes/blog.$slug.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { createApolloClient } from "lib/createApolloClient";
import { POST_BY_SLUG_QUERY } from "~/models/wp_queries";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.slug, "params.slug is required");
  const slug = params.slug;
  const client = createApolloClient();
  const response = await client.query({
    query: POST_BY_SLUG_QUERY,
    variables: {
      id: slug,
    },
  });

  const post = response?.data?.post;

  // todo: improve this
  if (!post) {
    throw new Response(`Post not found for slug: ${slug}`, {
      status: 404,
    });
  }

  return post;
};

export default function BlogPost() {
  const post = useLoaderData<typeof loader>();
  const caption = post?.featuredImage?.node?.altText
    ? post.featuredImage.node.altText
    : "";
  const author = post?.author?.node?.name;
  // todo: improve this and extract into a utility file:
  const date = post?.date
    ? `${new Date(post.date).getFullYear()}-${String(
        new Date(post.date).getMonth() + 1
      ).padStart(2, "0")}-${String(new Date(post.date).getDate()).padStart(
        2,
        "0"
      )}`
    : "";

  return (
    <div className="mx-2 md:max-w-prose md:mx-auto">
      {post?.featuredImage?.node?.sourceUrl ? (
        <figure className="max-w-full">
          <img
            className="my-3 max-w-full rounded-md"
            src={post.featuredImage.node.sourceUrl}
            alt={caption}
          />
          {caption ? (
            <figcaption className="mt-2 text-sm text-center text-slate-700">
              {caption}
            </figcaption>
          ) : (
            ""
          )}
        </figure>
      ) : (
        ""
      )}
      <h2 className="text-2xl text-slate-900 font-serif">{post.title}</h2>
      {author && date ? (
        <span>
          <span>{author}</span>
          <br />
          <span className="text-sm">{date}</span>
        </span>
      ) : (
        ""
      )}
      <hr className="my-3 border-solid border-slate-900" />
      <div
        className="text-slate-800 wp-post"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />
    </div>
  );
}

As noted at the start of this post, it’s tricky to style HTML that’s returned from the WordPress block editor. Since I control the blog, I’m using the @layer and @apply Tailwind directives for this:

// tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .wp-excerpt p {
    @apply break-words;
  }
  /* temp fix */
  .wp-excerpt .more-link {
    @apply hidden;
  }
  .wp-post p {
    @apply my-2;
  }
}

I’ll deal with that as it comes up. For example, find a way of styling code blocks.

That’s enough for tonight. I’ll push this to the live site.

$ git add .
$ git commit -m "Add blog.$slug route"
$ git push live main

On the production server:

$ cd /var/www/hello_zalgorithm_com
$ npm install
$ npm run build
$ pm2 reload 0

That worked! I ended up pushing again to add the following styles:

// tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .wp-excerpt p {
    @apply break-words;
  }
  /* temp fix */
  .wp-excerpt .more-link {
    @apply hidden;
  }
  .wp-post p {
    @apply my-2;
  }
  .wp-post code,
  .wp-post pre {
    @apply overflow-scroll bg-slate-100 text-slate-950;
  }
  .wp-post iframe {
    @apply max-w-full;
  }
}

https://hello.zalgorithm.com/blog/link-to-posts-from-the-index-page

Tomorrow I need to add some styles for lists and links.