❗ 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:
- https://tailwindcss.com/docs/functions-and-directives#layer
- https://wpengine.com/builders/headless-wordpress-remix-tailwind-css/ (The whole article is worth reading. Its “Supporting Tailwind CSS” section is relevant to this issue.)
- https://www.wpgraphql.com/2021/03/09/gutenberg-and-decoupled-applications (Discusses a few issues related to the WordPress Block editor and headless WordPress sites.)
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.