Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to add an Estimated Reading Time to Astro Blog? #74

Closed
dushyanth31 opened this issue May 29, 2023 · 10 comments
Closed

How to add an Estimated Reading Time to Astro Blog? #74

dushyanth31 opened this issue May 29, 2023 · 10 comments

Comments

@dushyanth31
Copy link

I just wanted to read-time to blog posts, I have already tried to add read-Time docs given by the Astro itself but ended in vain.

Kindly help me figure out how to implement this.

Thank you.

I needed an step-by-step process on how to do this.

@satnaing
Copy link
Owner

In order to add reading time to AstroPaper, we have to tweak PostDetails a little bit since dynamic frontmatter injection and content collection API are not compatible I guess. (correct me if I'm wrong)

So, you can add reading time to AstroPaper by following these steps.

  1. Install required dependencies
npm install reading-time mdast-util-to-string
  1. Create remark-reading-time.mjs file under utils directory
// file: src/utils/remark-reading-time.mjs
import getReadingTime from "reading-time";
import { toString } from "mdast-util-to-string";

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree);
    const readingTime = getReadingTime(textOnPage);
    data.astro.frontmatter.readingTime = readingTime.text;
  };
}
  1. Add the plugin to astro.config.mjs
// file: astro.config.mjs
import { defineConfig } from "astro/config";
// other imports
import { remarkReadingTime } from "./src/utils/remark-reading-time.mjs";  // make sure your relative path is correct

// https://astro.build/config
export default defineConfig({
  site: SITE.website,
  integrations: [
    // other integrations
  ],
  markdown: {
    remarkPlugins: [
      remarkToc,
      remarkReadingTime,  // 👈🏻 our plugin
      [
        remarkCollapse,
        {
          test: "Table of contents",
        },
      ],
    ],
    // other config
  },
  vite: {
    optimizeDeps: {
      exclude: ["@resvg/resvg-js"],
    },
  },
});
  1. Add readingTime to blog schema
// file: src/content/_schemas.ts
import { z } from "astro:content";

export const blogSchema = z
  .object({
    author: z.string().optional(),
    pubDatetime: z.date(),
    readingTime: z.string().optional(), // 👈🏻 optional readingTime frontmatter
    title: z.string(),
    postSlug: z.string().optional(),
    featured: z.boolean().optional(),
    draft: z.boolean().optional(),
    tags: z.array(z.string()).default(["others"]),
    ogImage: z.string().optional(),
    description: z.string(),
  })
  .strict();

export type BlogFrontmatter = z.infer<typeof blogSchema>;

So far so good. Now it's time for a tricky part.

  1. modify src/pages/posts/[slug].astro as the following
// file: src/pages/posts/[slug].astro 

---
import { CollectionEntry, getCollection } from "astro:content";
import Posts from "@layouts/Posts.astro";
import PostDetails from "@layouts/PostDetails.astro";
import getSortedPosts from "@utils/getSortedPosts";
import getPageNumbers from "@utils/getPageNumbers";
import slugify from "@utils/slugify";
import { SITE } from "@config";
import type { BlogFrontmatter } from "@content/_schemas"; //  👈🏻 import frontmatter type

export interface Props {
  post: CollectionEntry<"blog">;
  frontmatter: BlogFrontmatter; //  👈🏻 specify frontmatter type in Props
}

export async function getStaticPaths() {
  const mapFrontmatter = new Map();
  
  // Get all posts using glob. This is to get the updated frontmatter
  const globPosts = await Astro.glob<BlogFrontmatter>(
    "../../content/blog/*.md"
  );

  // Then, set those frontmatter value in a JS Map with key value pair
  // (post-slug, frontmatter)
  globPosts.map(({ frontmatter }) => {
    mapFrontmatter.set(slugify(frontmatter), frontmatter);
  });

  const posts = await getCollection("blog", ({ data }) => !data.draft);

  const postResult = posts.map(post => ({
    params: { slug: slugify(post.data) },
    props: { post, frontmatter: mapFrontmatter.get(slugify(post.data)) }, // add extra frontmatter props
  }));

  const pagePaths = getPageNumbers(posts.length).map(pageNum => ({
    params: { slug: String(pageNum) },
  }));

  return [...postResult, ...pagePaths];
}

const { slug } = Astro.params;
const { post, frontmatter } = Astro.props;  // restructure frontmatter property

const posts = await getCollection("blog");

const sortedPosts = getSortedPosts(posts);

const totalPages = getPageNumbers(sortedPosts.length);

const currentPage =
  slug && !isNaN(Number(slug)) && totalPages.includes(Number(slug))
    ? Number(slug)
    : 0;
const lastPost = currentPage * SITE.postPerPage;
const startPost = lastPost - SITE.postPerPage;

const paginatedPosts = sortedPosts.slice(startPost, lastPost);
---

{
  post ? (
    <PostDetails post={post} frontmatter={frontmatter} /> // add frontmatter as prop to PostDetails component
  ) : (
    <Posts
      posts={paginatedPosts}
      pageNum={currentPage}
      totalPages={totalPages.length}
    />
  )
}
  1. Then, show that frontmatter.readingTime inside PostDetails page
// file: src/layouts/PostDetails

---
// other imports
import type { BlogFrontmatter } from "@content/_schemas";  //  👈🏻 import frontmatter type

export interface Props {
  post: CollectionEntry<"blog">;
  frontmatter: BlogFrontmatter;  //  👈🏻 specify frontmatter type in Props
}

const { post, frontmatter } = Astro.props; // restructure frontmatter from props

// others

---

<Layout ...>
    <p>{frontmatter.readingTime}</p>  <!-- Show readingTime anywhere you want -->
</Layout>

If you want to see the code, I've pushed a new branch and you can check that out if you want.
Moreover, do let me know if you have any other good suggestions.

Sorry for my late reply.
Hope this helps. Thanks.

@dushyanth31
Copy link
Author

Hey, thank you very much @satnaing. If you don't mind may I know, how to use it on the .md file?

@ferrarafer
Copy link

ferrarafer commented Jun 29, 2023

@satnaing how do you put the reading time on each post inside the list of posts like in posts path (index.astro)?

@satnaing
Copy link
Owner

satnaing commented Jul 3, 2023

Just a quick update!

I refactored the codes and move the reading time logic into a util function.

  1. create a new file called getPostsWithRT.ts under src/utils directory.
// file: getPostsWithRT.ts
import type { BlogFrontmatter } from "@content/_schemas";
import type { MarkdownInstance } from "astro";
import slugify from "./slugify";
import type { CollectionEntry } from "astro:content";

export const getReadingTime = async () => {
  // Get all posts using glob. This is to get the updated frontmatter
  const globPosts = import.meta.glob<MarkdownInstance<BlogFrontmatter>>(
    "../content/blog/*.md"
  );

  // Then, set those frontmatter value in a JS Map with key value pair
  const mapFrontmatter = new Map();
  const globPostsValues = Object.values(globPosts);
  await Promise.all(
    globPostsValues.map(async globPost => {
      const { frontmatter } = await globPost();
      mapFrontmatter.set(slugify(frontmatter), frontmatter.readingTime);
    })
  );

  return mapFrontmatter;
};

const getPostsWithRT = async (posts: CollectionEntry<"blog">[]) => {
  const mapFrontmatter = await getReadingTime();
  return posts.map(post => {
    post.data.readingTime = mapFrontmatter.get(slugify(post.data));
    return post;
  });
};

export default getPostsWithRT;
  1. Update getSortedPosts func if you want to include estimated reading time in places other than post details.
// file: utils/getSortedPosts
import type { CollectionEntry } from "astro:content";
import getPostsWithRT from "./getPostsWithRT";

const getSortedPosts = async (posts: CollectionEntry<"blog">[]) => { // make sure that this func must be async 
  const postsWithRT = await getPostsWithRT(posts); // add reading time 
  return postsWithRT
    .filter(({ data }) => !data.draft)
    .sort(
      (a, b) =>
        Math.floor(new Date(b.data.pubDatetime).getTime() / 1000) -
        Math.floor(new Date(a.data.pubDatetime).getTime() / 1000)
    );
};

export default getSortedPosts;

If you update this, make sure you update all files that use getSortedPosts. (simply add await in front of getSortedPosts)
Those files are

  • src/pages/index.astro
  • src/pages/posts/index.astro
  • src/pages/rss.xml.ts
  • src/pages/posts/[slug].astro

All you have to do is like this

const sortedPosts = getSortedPosts(posts); // old code
const sortedPosts = await getSortedPosts(posts); // new code
  1. Refactor getStaticPaths of /src/pages/posts/[slug].astro as the following
// file: [slug].astro
---
import  { CollectionEntry, getCollection } from "astro:content";
...

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  const postsWithRT = await getPostsWithRT(posts); // replace reading time logic with this func

  const postResult = postsWithRT.map(post => ({
    params: { slug: slugify(post.data) },
    props: { post },
  }));

  const pagePaths = getPageNumbers(posts.length).map(pageNum => ({
    params: { slug: String(pageNum) },
  }));

  return [...postResult, ...pagePaths];
}

const { slug } = Astro.params;
const { post } = Astro.props; // remove frontmatter from this

const posts = await getCollection("blog");

const sortedPosts = await getSortedPosts(posts); // make sure to await getSortedPosts
....
---
{
  post ? (
    <PostDetails post={post} /> // remove frontmatter prop
  ) : (
    <Posts
      posts={paginatedPosts}
      pageNum={currentPage}
      totalPages={totalPages.length}
    />
  )
}
  1. refactor PostDetails.astro like this
file: src/layouts/PostDetails.astro
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Tag from "@components/Tag.astro";
import Datetime from "@components/Datetime";
import type { CollectionEntry } from "astro:content";
import { slugifyStr } from "@utils/slugify";

export interface Props {
  post: CollectionEntry<"blog">;
}

const { post } = Astro.props;

const { title, author, description, ogImage, pubDatetime, tags, readingTime } =
  post.data; // we can now directly access readingTime from frontmatter

....
---

Now you can access readingTime in posts and post details


Optional!!!
Update Datetime component to display readingTime

import { LOCALE } from "@config";

export interface Props {
  datetime: string | Date;
  size?: "sm" | "lg";
  className?: string;
  readingTime?: string;
}

export default function Datetime({
  datetime,
  size = "sm",
  className,
  readingTime, // new prop
}: Props) {
  return (
    ...
      <span className={`italic ${size === "sm" ? "text-sm" : "text-base"}`}>
        <FormattedDatetime datetime={datetime} />
        <span> ({readingTime})</span> {/* display reading time */}
      </span>
    ...
  );
)

Then, pass readingTime props from its parent component
eg: Card.tsx

export default function Card({ href, frontmatter, secHeading = true }: Props) {
  const { title, pubDatetime, description, readingTime } = frontmatter;
  return (
    ...
    <Datetime datetime={pubDatetime} readingTime={readingTime} /> 
    ...
  );
}

@ferrarafer hopefully this solves your issue.
I've also updated the feat/post-reading-time branch.

@satnaing
Copy link
Owner

satnaing commented Jul 3, 2023

Hey, thank you very much @satnaing. If you don't mind may I know, how to use it on the .md file?

Hello @dushyanth31
Since this is just a markdown file, I don't think we can use JavaScript or TypeScript directly. If you want to do so, you can use mdx file format for that specific purpose.
And I think it's better to add readingTime inside parent layout components like PostDetails.astro. In this way, you don't have to specify readingTime in each article.

Another simple approach is that you can specify readingTime manually in the markdown frontmatter.
For example,
file: astro-paper-2.md

---
author: Sat Naing
pubDatetime: 2023-01-30T15:57:52.737Z
title: AstroPaper 2.0
postSlug: astro-paper-2
featured: true
ogImage: https://user-images.githubusercontent.com/53733092/215771435-25408246-2309-4f8b-a781-1f3d93bdf0ec.png
tags:
  - release
description: AstroPaper with the enhancements of Astro v2. Type-safe markdown contents, bug fixes and better dev experience etc.
readingTime: 2 min read
---
.... 

@ferrarafer
Copy link

@satnaing thanks for the update man! I resolved it during the weekend but I will take a look to this implementation during the week, probably better. Thanks a lot!

@mattppal
Copy link

mattppal commented Jul 8, 2023

Wow this is so awesome! Thank you! 🤯

One small piece you didn't explicitly state, but tripped me up for a bit: in addition to passing readingTime to Card.tsx, you also have to do so in PostDetails.astro to have the reading time displayed on the post itself 🙂

<Datetime datetime={pubDatetime} readingTime={readingTime} size="lg" className="my-2" />

@satnaing
Copy link
Owner

satnaing commented Jul 22, 2023

Hello everyone,
I'm gonna push a commit that closes this issue.
In that commit, I rearranged all the steps. Hope it helps.

Here's the link to that blog post.

Let me know if you still have some problems.

@EmilLuta
Copy link

Hi there, sorry to open this thread again, but seems current version is not working as intended. If I follow the guide, everything breaks at step 3:

remarkReadingTime, // 👈🏻 our plugin

breaks the entire website with:

TypeError: Failed to parse Markdown file "/path_to_website/src/content/blog/adding-new-post.md":
Function.prototype.toString requires that 'this' be a Function
    at Proxy.toString (<anonymous>)
    at eval (/path_to_website/src/utils/remark-reading-time.mjs:10:46)
    at wrapped (file:///path_to_website/node_modules/trough/index.js:115:27)
    at next (file:///path_to_website/node_modules/trough/index.js:65:23)
    at done (file:///path_to_website/node_modules/trough/index.js:148:7)
    at then (file:///path_to_website/node_modules/trough/index.js:158:5)
    at wrapped (file:///path_to_website/node_modules/trough/index.js:136:9)
    at next (file:///path_to_website/node_modules/trough/index.js:65:23)
    at done (file:///path_to_website/node_modules/trough/index.js:148:7)
    at then (file:///path_to_website/node_modules/trough/index.js:158:5)
    at wrapped (file:///path_to_website/node_modules/trough/index.js:136:9)
    at next (file:///path_to_website/node_modules/trough/index.js:65:23)
    at done (file:///path_to_website/node_modules/trough/index.js:148:7)
    at then (file:///path_to_website/node_modules/trough/index.js:158:5)
    at wrapped (file:///path_to_website/node_modules/trough/index.js:136:9)
    at next (file:///path_to_website/node_modules/trough/index.js:65:23)
    at Object.run (file:///path_to_website/node_modules/trough/index.js:36:5)
    at executor (file:///path_to_website/node_modules/unified/lib/index.js:321:20)
    at Function.run (file:///path_to_website/node_modules/unified/lib/index.js:312:5)
    at executor (file:///path_to_website/node_modules/unified/lib/index.js:393:17)
    at new Promise (<anonymous>)
    at Function.process (file:///path_to_website/node_modules/unified/lib/index.js:380:14)
    at renderMarkdown (file:///path_to_website/node_modules/@astrojs/markdown-remark/dist/index.js:98:26)
    at async Context.load (file:///path_to_website/node_modules/astro/dist/vite-plugin-markdown/index.js:62:30)
    at async Object.load (file:///path_to_website/node_modules/vite/dist/node/chunks/dep-e8f070e8.js:42892:32)
    at async loadAndTransform (file:///path_to_website/node_modules/vite/dist/node/chunks/dep-e8f070e8.js:53318:24)

If it helps, I'm running on MacOS. Let me know if you'd also like me to open a new ticket or if I can assist further.

Thanks a lot in advance!

@EmilLuta
Copy link

Figured out that the problem was related with to the lack of server restart. As soon as I restarted it, all's good. Works as intended, please disregard the comment. Leaving message above for anyone who stumbles on the same issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants