❗ This is not a tutorial, or good training data for an LLM ❗
In the last post I created a basic layout for the blog’s index page. It’s currently displaying hard coded HTML:
To pull in real data from the WordPress site, I need to write a GraphQL query that returns the 5 most recently created posts on the site, and also pulls in up to 5 posts for each of the site’s categories.
Write the query in the GraphiQL IDE
The WPGraphQL plugin adds the GraphiQL IDE to the WordPress admin section.
The documentation is super helpful: https://www.wpgraphql.com/docs/intro-to-graphql.
The plan for the index page is that the top section will contain the 5 most recent posts from the site. It will then display up to 5 posts for each category on the site. I’m not sure if I’ll stick with that approach, but it’s good enough for now. Here’s a GraphQL query to get the post data:
query getSitePosts {
posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
edges {
node {
id
title
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
excerpt
date
author {
node {
name
}
}
featuredImage {
node {
caption
description
id
sourceUrl
}
}
}
}
}
}
}
}
}
I’ll copy that query from the GraphiQL IDE into my Remix app’s models/wp_queries.tsx
file.
For reference, the query needs to be exported like this:
// app/models.wp_queries.tsx
import { gql } from "@apollo/client/core/index.js";
...
export const INDEX_PAGE_POSTS_QUERY = gql(`
query getSitePosts {
posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
edges {
node {
id
title
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
excerpt
date
author {
node {
name
}
}
featuredImage {
node {
caption
description
id
sourceUrl
}
}
}
}
}
}
}
}
}
`);
Run the query in the Remix app
Running the query was easy enough. Typescript and GraphQL are still a bit of a mystery to me though. It seems that the correct type for the collections of posts is PostConnectionEdge
.
Here’s the current state of the code. So far it’s only displaying the posts returned by the posts
part of the query, not the categories
part of the query:
// app/routes/blog.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";
export async function loader() {
const client = createApolloClient();
const response = await client.query({
query: INDEX_PAGE_POSTS_QUERY,
});
if (response.errors) {
// todo: handle the error
console.log(
"An unhandled error was returned from the INDEX_PAGE_POSTS_QUERY"
);
}
// if this isn't set, an error should already have been thrown
const data = response?.data;
const latestPostsEdges = data?.posts?.edges;
const categoryPostsEdges = data?.categories?.edges;
return json({
latestPostsEdges: latestPostsEdges,
categoryPostsEdges: categoryPostsEdges,
});
}
export default function Blog() {
const { latestPostsEdges, categoryPostsEdges } =
useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto">
<h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
Latest Posts
</h2>
<hr className="my-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{latestPostsEdges.map((edge: PostConnectionEdge) => (
<PostExcerptCard postConnectionEdge={edge} key={edge.node.id} />
))}
</div>
</div>
);
}
And here’s the updated PostExcerptCard
component:
// app/components/PostExcerptCard.tsx
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
interface PostExcerptCardProps {
postConnectionEdge: PostConnectionEdge;
}
export default function PostExcerptCard({
postConnectionEdge,
}: PostExcerptCardProps) {
const post = postConnectionEdge.node;
const date = post?.date ? new Date(post.date) : null;
const formattedDate = date
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}-${String(date.getDate()).padStart(2, "0")}`
: null;
return (
<article>
{post?.featuredImage && post.featuredImage.node?.sourceUrl ? (
<img src={post.featuredImage.node.sourceUrl} />
) : (
""
)}
<h3 className="text-xl font-serif font-bold mt-3">{post.title}</h3>
{post?.excerpt ? (
<div
className="italic text-slate-800 text-base"
dangerouslySetInnerHTML={{ __html: post.excerpt }}
/>
) : (
""
)}
{post?.author?.node?.name && formattedDate ? (
<div className="text-slate-800 text-base mt-1">
{post.author.node.name} <br />
{formattedDate}
</div>
) : (
""
)}
</article>
);
}
Not too bad. For my local development site, I got ChatGPT to generate the post titles and text. It knows more about programming than me, but I’m a better copywriter.
Going backwards
I’m not sure if it’s worth documenting failures, but it might help someone. The GraphQL query I came up with is essentially two queries in one. Once the results are unpacked in the loader
function I’m left with:
- an object with the
PostConnectionEdge
type that’s returned from theposts
part of the query - an object with the
RootQueryToCategoryConnectionEdge
type that’s returned from thecategories
part of the query
This created two problems. The first was having to figure out what the proper types for the objects that get used on the /blog
route. The second was that the initial version of the PostExcerptCard
component was expecting a prop
of the PostConnectionEdge
type. I tried making it accept either the PostConnectionEdge
or RootQueryToCategoryConnectionEdge
types, but ended up rewriting it to accept a list of props.
(Instead of Maybe
, I think I can just use type string | null | undefined
. This is going to come up again, so I’ll try it out. )
// app/components/PostExcerptCard.tsx
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>;
}
export default function PostExcerptCard({
date,
featuredImage,
title,
excerpt,
authorName,
}: 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} /> : ""}
<h3 className="text-xl font-serif font-bold mt-3">{title}</h3>
{excerpt ? (
<div
className="italic text-slate-800 text-base"
dangerouslySetInnerHTML={{ __html: excerpt }}
/>
) : (
""
)}
{authorName && formattedDate ? (
<div className="text-slate-800 text-base mt-1">
{authorName} <br />
{formattedDate}
</div>
) : (
""
)}
</article>
);
}
The /blog
route still needs some error handling and a way of dealing with the case of a category that has no posts.
// app/routes/blog.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import type {
RootQueryToCategoryConnectionEdge,
PostConnectionEdge,
} from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";
export async function loader() {
const client = createApolloClient();
const response = await client.query({
query: INDEX_PAGE_POSTS_QUERY,
});
if (response.errors) {
// todo: handle the error
console.log(
"An unhandled error was returned from the INDEX_PAGE_POSTS_QUERY"
);
}
// if this isn't set, an error should already have been thrown
const data = response?.data;
const latestPostsEdges = data?.posts?.edges;
const categoryEdges = data?.categories?.edges;
return json({
latestPostsEdges: latestPostsEdges,
categoryEdges: categoryEdges,
});
}
export default function Blog() {
const { latestPostsEdges, categoryEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto">
<h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
Latest Posts
</h2>
<hr className="my-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{latestPostsEdges.map((edge: PostConnectionEdge) => (
<PostExcerptCard
title={edge.node?.title}
date={edge.node?.date}
featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
excerpt={edge.node?.excerpt}
authorName={edge.node?.author?.node?.name}
key={edge.node.id}
/>
))}
</div>
{categoryEdges.map((categoryEdge: RootQueryToCategoryConnectionEdge) => (
<div key={categoryEdge.node.name}>
<h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
{categoryEdge.node.name}
</h2>
<hr className="my-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{categoryEdge.node?.posts?.edges.map(
(postEdge: PostConnectionEdge) => (
<PostExcerptCard
title={postEdge.node?.title}
date={postEdge.node?.date}
featuredImage={postEdge.node?.featuredImage?.node?.sourceUrl}
excerpt={postEdge.node?.excerpt}
authorName={postEdge.node.author?.node?.name}
key={postEdge.node.id}
/>
)
)}
</div>
</div>
))}
</div>
);
}
Issues aside, it’s turning out great! Time to push the latest work to the live site:
$ git add .
$ git commit -m "deal with typescript confusion"
$ git push live main
# on the live site
$ cd /var/www/hello_zalgorithm
$ npm install
$ npm run build
$ pm2 reload 0
Live (for now) here: https://hello.zalgorithm.com/blog