House Cleaning

Live blogging the development of a headless WordPress / Remix site. I’m learning as I go. What other activities could be live blogged in a productive way?

Today I’m going to try to fix these issues:

  • Stripping the HTML from post excerpts has caused HTML entities (’, etc) to be displayed in post excerpts.
  • Absolute positioning of the header menu button has caused it to display outside of its container element on large screens.
  • The app currently has two Header components. I’ll update the BlogHeader component so that it works for all pages and can be moved back to root.tsx.
  • The horizontal alignment of the “Next Posts” right-arrow icon is wrong.
  • Some routes are missing appropriate meta tags.
  • I’d like to remove all references to the “uncategorized” category.
  • The site’s still using the default Remix favicon.
  • It’s not possible to use NavLink components to highlight a link to the blog’s homepage in the header drop down menu. (On any sub-route of /blog, the /blog route will always be considered to be “active”.
  • If the WordPress site is down, visiting a /blog route will crash the Remix app. For that case it should be displaying an “oops” page.
  • Add padding to paragraphs and images.
  • Improve list element CSS.

Stop stripping HTML from excerpts

$ git checkout -b stop_stripping_html_from_excerpts

I’m going with the minimum viable fix – change the PostExcerptCard from:

this
// app/components/PostExcerptCard.tsx

import { Link } from "@remix-run/react";
import { Maybe } from "graphql/jsutils/Maybe";
import { stripHtml, truncateText } from "~/utils/utilities";

// todo: I don't think I need to use `null` with the Maybe generic type
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>;
  excerptLength: number;
  includeMetaData: boolean;
  basePath: string;
  databaseId: number;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
  slug,
  excerptLength,
  includeMetaData,
  basePath,
  databaseId,
}: 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;
  const postExcerpt = excerpt
    ? truncateText(stripHtml(excerpt), excerptLength)
    : null;
...

to

this (PostExcerptCard with the excerptLength prop removed)
// app/components/PostExcerptCard.tsx

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

// todo: I don't think I need to use `null` with the Maybe generic type
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>;
  includeMetaData: boolean;
  basePath: string;
  databaseId: number;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
  slug,
  includeMetaData,
  basePath,
  databaseId,
}: 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 className="rounded-md" src={featuredImage} /> : ""}
      <Link prefetch="intent" to={`${basePath}/${databaseId}/${slug}`}>
        <h3 className="text-xl font-serif font-bold mt-3 text-sky-700 hover:underline">
          {title}
        </h3>
      </Link>
      {excerpt ? (
        <p className="italic text-slate-800 text-base wp-excerpt">
          {excerpt}
        </p>
      ) : (
        ""
      )}
      {includeMetaData && authorName && formattedDate ? (
        <div className="text-slate-800 text-base mt-1">
          {authorName} <br />
          {formattedDate}
        </div>
      ) : (
        ""
      )}
    </article>
  );
}

Typescript / VS Code is really helpful for this kind of thing. After making the change, three warnings appear in the VS Code Problems section:

// Problems

Type '{ title: Maybe<string> | undefined; date: Maybe<string> | undefined; featuredImage: Maybe<string> | undefined; excerpt: Maybe<string> | undefined; ... 6 more ...; key: number; }' is not assignable to type 'IntrinsicAttributes & PostExcerptCardProps'.
  Property 'excerptLength' does not exist on type 'IntrinsicAttributes & PostExcerptCardProps'.

Type '{ title: Maybe<string> | undefined; date: Maybe<string> | undefined; featuredImage: Maybe<string> | undefined; excerpt: Maybe<string> | undefined; ... 6 more ...; key: number; }' is not assignable to type 'IntrinsicAttributes & PostExcerptCardProps'.
  Property 'excerptLength' does not exist on type 'IntrinsicAttributes & PostExcerptCardProps'.

Type '{ title: Maybe<string> | undefined; date: Maybe<string> | undefined; featuredImage: Maybe<string> | undefined; excerpt: Maybe<string> | undefined; ... 6 more ...; key: number; }' is not assignable to type 'IntrinsicAttributes & PostExcerptCardProps'.
  Property 'excerptLength' does not exist on type 'IntrinsicAttributes & PostExcerptCardProps'.

I’ve removed excerptLength from the calls to PostExcerptCard and am confident enough to push the changes to the live site:

$ git add .
$ git commit -m "remove excerptLength prop"
$ git checkout main
$ git merge stop_stripping_html_from_excerpts
$ git branch -d stop_stripping_html_from_excerpts

$ git push live main

Then on the live site:

$ npm run build
$ pm2 reload zalgorithm

Oops:

I was misunderstanding the problem (should have tested that locally before deploying). Using dangerouslySetInnerHTML would have made it possible to use the stripped excerpts. For now, I’ll revert to this:

<div
    className="italic text-slate-800 text-base wp-excerpt"
    dangerouslySetInnerHTML={{ __html: excerpt }}
/>

That’s better:

Reverting that revert will be an item on my next to do list.

Positioning the header menu

$ git checkout -b fix_header_menu_positioning

This time I won’t delete the “fix” branch before testing the changes.

The menu icon’s positioning is especially bad on large screens. I’ll add a temporary background color to what’s supposed to be the header’s container div:

// app/components/BlogHeader.tsx

...
export default function BlogHeader({ categories }: BlogHeaderProps) {
  const location = useLocation();
  useEffect(() => {
    const detailsElement = document.getElementById("blog-nav");
    if (detailsElement) {
      detailsElement.removeAttribute("open");
    }
  }, [location]);
  return (
    <header className="bg-sky-800 text-slate-50 text-xl px-3 py-4 top-0 sticky overflow-visible">
      <div className="flex justify-between items-center w-full max-w-screen-xl mx-auto bg-red-700">

The first part of the fix was to add the relative class to the header’s content container:

<div className="flex justify-between items-center w-full max-w-screen-xl mx-auto bg-red-700 relative">

That way the icon is positioned relative its container div instead of the outer absolutely positioned header element.

The next problem was that the position was controlling the SVG element’s viewBox attribute. It wasn’t setting the position of the SVG element within the viewBox.

MDN Web Docs to the rescue:

The <use> element takes nodes from within the SVG document, and duplicates them somewhere else.

Attributes

href

The URL to an element/fragment that needs to be duplicated. See Usage notes for details on common pitfalls.
Value type<URL> ; Default value: none; Animatableyesxlink:href Deprecated

An <IRI> reference to an element/fragment that needs to be duplicated. If both href and xlink:href are present, the value given by href is used.
Value type<IRI> ; Default value: none; Animatableyesx

The x coordinate of an additional final offset transformation applied to the <use> element.
Value type<coordinate> ; Default value0Animatableyesy

The y coordinate of an additional final offset transformation applied to the <use> element.
Value type<coordinate> ; Default value0Animatableyeswidth

The width of the use element.
Value type<length> ; Default value0Animatableyesheight

The height of the use element.
Value type<length> ; Default value0Animatableyes

https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use

I’ve updated the Icon component to accept optional x and y props:

// app/components/Icon.tsx

nterface IconProps {
  id: "hamburger" | "arrow-left" | "arrow-right";
  className?: string;
  x?: number;
  y?: number;
}

export function Icon({ id, x = 0, y = 0, className }: IconProps): JSX.Element {
  return (
    <svg className={className}>
      <use href={`/sprite.svg#${id}`} x={`${x}`} y={`${y}`} />
    </svg>
  );
}

I also made the className prop optional while I was at it.

This might be a flaky approach, but it’s neat! Alignment is even working when the screen is zoomed in:

I’ll remove the container’s background color.

… actually 🙁

I’m going to keep the changes I made to the Icon component. They might come in handy for aligning other icons, but I ran into a problem when I tried adding a background to the icon that was triggered on hover (because the background gets added to the viewBox). There’s another way of dealing with SVG alignment… again from the MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol.

The symbol element accepts width and height attributes. They can be set either to a length in pixels, or to a percentage. Setting the width and height of the hamburger symbol element to 100% solves the alignment issue, and allows for styling the icon’s background:

<!-- sprite.svg -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <symbol viewBox="0 0 15 15" id="hamburger" height="100%" width="100%">
      <!-- the path element goes here -->
    </symbol>
  </defs>
</svg>

Setting width/height to 100% causes the symbol‘s viewBox to be the same size as the SVG element that it contains. Now I can add background styles to the icon that are triggered when it’s hovered over.

I’m adding an outline instead of a border when the icon’s hovered. Adding a border causes the icon to shrink:

// app/components/Icon.tsx

...
<Icon
  key="hamburger"
  id="hamburger"
  className="text-slate-50 rounded hover:bg-sky-700 hover:outline hover:outline-sky-700 hover:outline-4 hover:outline-solid w-8 h-8"
/>
...

My next to do list will include adding an “active” class to the menu icon when it’s clicked. That will allow it’s active styles to persist when it’s not being hovered over. I also want the menu to close when any element outside of the menu is clicked.

Left and right arrow icon alignment

Since I’ve been dealing with SVG alignment, I’ll go ahead and fix the alignment of the left and right arrow icons (and text.)

The right arrow needs to align with the end of the word “Posts”:

The left arrow is off by a few pixels from the beginning of the word “Previous.”

Similar issue here with the left and right arrows being a few pixels off:

Also, as seen in the image above, the “previous” and “next” arrows can get out of vertical alignment, depending on the screen size and the length of the post titles.

$ git checkout -b fix_left_and_right_arrows

I think I can fix one of the horizontal alignment issues by setting the left and right arrow icon’s x attribute.

Yup:

<Icon
  key="arrow-right"
  id="arrow-right"
  x={5}
  className="text-slate-700 w-10 h-10"
/>

That pushes the icon 5px to the right. For the left icon I used x={-5}

The fix for this was straightforward too:

The problem was that I’d set the link’s parent div as the flex container. The flex container actually needed to be the Link component: <Link className="hover:underline flex flex-col items-end"... > (for the right arrow).

Here’s the updated Paginator component:

// app/components/Paginator.tsx

import { Link } from "@remix-run/react";
import type { RootQueryToPostConnectionPageInfo } from "~/graphql/__generated__/graphql";
import { Icon } from "~/components/Icon";

interface PaginatorProps {
  pageInfo: RootQueryToPostConnectionPageInfo;
}

export default function Paginator({ pageInfo }: PaginatorProps) {
  const { hasNextPage, hasPreviousPage, startCursor, endCursor } = pageInfo;

  return (
    <div className="w-full flex justify-between">
      <div>
        {hasPreviousPage && startCursor ? (
          <Link
            to={`?startCursor=${startCursor}`}
            className="hover:underline flex flex-col items-start"
          >
            <Icon
              key="arrow-left"
              id="arrow-left"
              x={-5}
              className="text-slate-700 w-10 h-10"
            />
            <div>Previous Posts</div>
          </Link>
        ) : (
          ""
        )}
      </div>
      <div>
        {hasNextPage && endCursor ? (
          <Link
            to={`?endCursor=${endCursor}`}
            className="hover:underline flex flex-col items-end"
          >
            <Icon
              key="arrow-right"
              id="arrow-right"
              x={5}
              className="text-slate-700 w-10 h-10"
            />
            <div>Next Posts</div>
          </Link>
        ) : (
          ""
        )}
      </div>
    </div>
  );
}

The component name isn’t great – Paginator is for paginating between batches of archive posts. The other use of left/right icons is for moving between individual posts. That happens in two routes: blog.$postId.$slug.tsx and blog.category.$categorySlug.$postId.$slug.tsx. I’ll fix it in one place, then move the functionality into a component.

The first part was easy, just add the x={-5} and x={5} attributes to the Icon components. Now onto aligning these items:

That was easy! I’d used a grid instead of flex on the outer container when I initially set it up. The trick was: my-3 flex justify-between flex-col min-[431px]:flex-row.

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

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

import { createApolloClient } from "lib/createApolloClient";
import { POST_BY_ID_QUERY } from "~/models/wp_queries";
import type { Post } from "~/graphql/__generated__/graphql";
import { stripHtml, truncateText } from "~/utils/utilities";
import { Icon } from "~/components/Icon";

export const meta: MetaFunction = ({ data }) => {
  const post = data as Post;
  // Without this condition, errors in the loader function will cause an unhandled
  // error in the meta function.
  if (!post || !post?.title) {
    return [
      { title: "Error Page" },
      { description: "An error occurred while loading the post." },
    ];
  }

  const title = post?.title ? post.title : "Simon's blog";
  let description = post?.excerpt
    ? stripHtml(post.excerpt)
    : `Read more about ${post.title}`;
  description = truncateText(description, 160);
  // todo: set BASE_URL as an environental variable so that it doesn't have to be hard coded here:
  const url =
    post?.slug && post.databaseId
      ? `https://hello.zalgorithm.com/blog/${post.databaseId}/${post.slug}`
      : "";

  let metaTags = [
    { title: title },
    { description: description },
    { property: "og:title", content: title },
    { property: "og:description", content: description },
    { property: "og:type", content: "website" },
    { property: "og:url", content: url },
  ];

  if (post?.featuredImage?.node?.sourceUrl) {
    metaTags.push({
      property: "og:image",
      content: post.featuredImage.node.sourceUrl,
    });
  }

  return metaTags;
};

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

  if (response.errors) {
    throw new Error(`An error was returned when querying for post: ${postId}`);
  }

  const post = response?.data?.post ?? null;

  if (!post) {
    throw new Response(null, {
      status: 404,
      statusText: "Not Found",
    });
  }

  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"
      )}`
    : "";
  const previousTitle = post?.previousPost?.title;
  const previousSlug = post?.previousPost?.slug;
  const previousId = post?.previousPost?.databaseId;
  const nextTitle = post?.nextPost?.title;
  const nextSlug = post?.nextPost?.slug;
  const nextId = post?.nextPost?.databaseId;

  return (
    <div className="mx-2 my-6 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-3xl 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-6 border-solid border-slate-400" />
      <div
        className="text-slate-800 wp-post"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />
      <div className="my-3 flex justify-between flex-col min-[431px]:flex-row">
        {previousTitle && previousSlug && previousId ? (
          <div>
            <Link
              prefetch="intent"
              to={`/blog/${previousId}/${previousSlug}`}
              className="text-lg font-bold text-sky-700 hover:underline"
            >
              <div className="flex items-center">
                {" "}
                <Icon
                  key="arrow-left"
                  id="arrow-left"
                  x={-5}
                  className="text-slate-700 w-10 h-10 self-center"
                />{" "}
                <div className="text-slate-700">Previous </div>
              </div>
              {previousTitle}
            </Link>
          </div>
        ) : null}
        {nextTitle && nextSlug && nextId ? (
          <div className="min-[431px]:text-right">
            <Link
              prefetch="intent"
              to={`/blog/${nextId}/${nextSlug}`}
              className="text-lg font-bold text-sky-700 hover:underline"
            >
              <div className="flex items-center min-[431px]:justify-end">
                <div className="text-slate-700">Next</div>
                <Icon
                  key="arrow-right"
                  id="arrow-right"
                  x={5}
                  className="text-slate-700 inline w-10 h-10"
                />{" "}
              </div>
              {nextTitle}
            </Link>
          </div>
        ) : null}
      </div>
    </div>
  );
}

export function ErrorBoundary() {
  const error: any = useRouteError();
  const status = error?.status;

  return (
    <div className="px-2">
      <div className="mx-auto max-w-3xl px-2 py-4 my-10 bg-sky-100 border-2 border-sky-700 rounded">
        {status && status === 404 ? (
          <h2 className="text-xl">
            The page you were looking for could not be found
          </h2>
        ) : (
          <h2 className="text-xl">Sorry, something has gone wrong</h2>
        )}
        <p className="pt-1">
          Click this link to return to the blog's homepage:{" "}
          <Link className="text-sky-800 font-bold" to="/blog">
            Zalgorithm
          </Link>
        </p>
      </div>
    </div>
  );
}

I won’t extract the previous/next post code into a component for now. It’s getting late.

$ git add .
$ git commit -m "fix pagination alignment"
$ git checkout main
$ git merge fix_left_and_right_arrows
$ git branch -d fix_left_and_right_arrows

$ git push origin main
$ git push live main

CSS tweaks for WordPress posts

I’ll just change a few things that have been bugging me:

// tailwind.css

...
// give images some padding:
  .wp-post img {
    @apply rounded-md shadow-xl max-w-full my-6;
  }

// align list bullets with other text content:
  .wp-post ul {
    @apply list-disc ml-5;
  }

// add padding to paragraphs:
  .wp-post p {
    @apply my-3;
  }

// clean up block quotes:
  .wp-post .wp-block-quote {
    @apply border-l border-slate-400 pl-6 my-9 font-medium;
  }

That’s good for now 🙂