diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index f30de11..069ce18 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -28,6 +28,7 @@ export default defineConfig({
url: 'https://hideoo.dev',
},
},
+ showReadingTime: true,
}),
],
sidebar: [
diff --git a/docs/src/content/docs/blog/oculus-plangoremque-praeside.md b/docs/src/content/docs/blog/oculus-plangoremque-praeside.md
index 7a902a1..3475abc 100644
--- a/docs/src/content/docs/blog/oculus-plangoremque-praeside.md
+++ b/docs/src/content/docs/blog/oculus-plangoremque-praeside.md
@@ -5,6 +5,7 @@ excerpt: Etiam sit amet purus sit amet eros bibendum sagittis. Aliquam viverra e
tags:
- Lorem
- Ipsum
+readingTime: 69
---
## Pretioque semper malo
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index c690592..cc99751 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -62,6 +62,14 @@ The number of blog posts to display per page in the blog post list.
The number of recent blog posts to display in the blog sidebar.
+### `showReadingTime`
+
+**Type:** `boolean`
+**Default:** `false`
+
+Whether to show an automatically calculated reading time for each blog post.
+The automatically calculated reading time can be overridden by setting the [`readingTime` frontmatter](/guides/frontmatter#readingtime).
+
### `authors`
**Type:** [`StarlightBlogAuthorsConfig`](#author-configuration)
diff --git a/docs/src/content/docs/guides/frontmatter.md b/docs/src/content/docs/guides/frontmatter.md
index 679e650..3006112 100644
--- a/docs/src/content/docs/guides/frontmatter.md
+++ b/docs/src/content/docs/guides/frontmatter.md
@@ -171,3 +171,18 @@ type CoverConfig =
light: string
}
```
+
+### `readingTime`
+
+**Type:** `number`
+
+Manually set a reading time for the blog post which is shown below the title and on all lists of blogs.
+
+If the [`showReadingTime` configuration](/configuration#showreadingtime) is set to `true` this number gets calculated automatically and can be overridden by setting this value.
+If it is set to `false` only blog posts with this frontmatter field will show a reading time.
+
+```md
+---
+readingTime: 10
+---
+```
diff --git a/packages/starlight-blog/components/Metadata.astro b/packages/starlight-blog/components/Metadata.astro
index a8027c7..8be2c68 100644
--- a/packages/starlight-blog/components/Metadata.astro
+++ b/packages/starlight-blog/components/Metadata.astro
@@ -1,8 +1,9 @@
---
-import { Badge } from '@astrojs/starlight/components'
+import { Badge, Icon } from '@astrojs/starlight/components'
import { getBlogEntryMetadata, type StarlightBlogEntry } from '../libs/content'
import type { Locale } from '../libs/i18n'
+import { formatReadingTime, getReadingTime } from '../libs/readingTime'
import Author from './Author.astro'
@@ -10,12 +11,15 @@ interface Props {
entry: StarlightBlogEntry
locale: Locale
showBadges?: boolean
+ showReadingTime?: boolean
}
-const { entry, locale, showBadges = true } = Astro.props
+const { entry, locale, showBadges = true, showReadingTime = true } = Astro.props
const { authors, date, lastUpdated } = getBlogEntryMetadata(entry, locale)
const hasAuthors = authors.length > 0
+
+const readingTime = getReadingTime(entry, entry.body.toString())
---
@@ -36,6 +40,15 @@ const hasAuthors = authors.length > 0
/>
) : null
}
+ {
+ showReadingTime && readingTime.showReadingTime && (
+
+ -
+
+ {formatReadingTime(readingTime.readingTime)}
+
+ )
+ }
{
hasAuthors ? (
@@ -75,6 +88,15 @@ const hasAuthors = authors.length > 0
color: var(--sl-color-gray-3);
}
+ .reading-time {
+ color: var(--sl-color-gray-3);
+ }
+
+ .reading-time svg {
+ transform: translateY(2px);
+ margin-right: 4px;
+ }
+
.authors {
display: flex;
flex-wrap: wrap;
diff --git a/packages/starlight-blog/index.ts b/packages/starlight-blog/index.ts
index 7150918..2af41b0 100644
--- a/packages/starlight-blog/index.ts
+++ b/packages/starlight-blog/index.ts
@@ -36,6 +36,7 @@ export default function starlightBlogPlugin(userConfig?: StarlightBlogUserConfig
...overrideStarlightComponent(starlightConfig.components, logger, 'MarkdownContent'),
...overrideStarlightComponent(starlightConfig.components, logger, 'Sidebar'),
...overrideStarlightComponent(starlightConfig.components, logger, 'ThemeSelect'),
+ ...overrideStarlightComponent(starlightConfig.components, logger, 'PageTitle'),
},
head: [
...(starlightConfig.head ?? []),
diff --git a/packages/starlight-blog/libs/config.ts b/packages/starlight-blog/libs/config.ts
index ab3c53d..a8658be 100644
--- a/packages/starlight-blog/libs/config.ts
+++ b/packages/starlight-blog/libs/config.ts
@@ -47,6 +47,12 @@ const configSchema = z
* When using the object form, the keys must be BCP-47 tags (e.g. `en`, `ar`, or `zh-CN`).
*/
title: z.union([z.string(), z.record(z.string())]).default('Blog'),
+ /**
+ * Whether to show the reading time in the blog post list and on the blog post page.
+ *
+ * @default false
+ */
+ showReadingTime: z.boolean().default(false),
})
.default({})
diff --git a/packages/starlight-blog/libs/readingTime.ts b/packages/starlight-blog/libs/readingTime.ts
new file mode 100644
index 0000000..12b5126
--- /dev/null
+++ b/packages/starlight-blog/libs/readingTime.ts
@@ -0,0 +1,29 @@
+import config from 'virtual:starlight-blog-config'
+
+import type { StarlightBlogEntry } from './content'
+
+export interface ReadingTimeResult {
+ showReadingTime: boolean
+ readingTime?: number
+}
+
+export function calculateReadingTime(markdownContent: string): number {
+ return Math.ceil(markdownContent.split(' ').length / 200)
+}
+
+export function getReadingTime(userConfig: StarlightBlogEntry, markdownContent: string): ReadingTimeResult {
+ if (!config.showReadingTime && userConfig.data.readingTime === undefined) {
+ return { showReadingTime: false }
+ }
+
+ const readingTime = userConfig.data.readingTime ?? calculateReadingTime(markdownContent)
+ return { showReadingTime: true, readingTime }
+}
+
+export function formatReadingTime(readingTime: number): string {
+ return readingTime < 60
+ ? `${readingTime} min`
+ : readingTime % 60 === 0
+ ? `${readingTime / 60} h`
+ : `${Math.floor(readingTime / 60)}h ${readingTime % 60}min`
+}
diff --git a/packages/starlight-blog/overrides/MarkdownContent.astro b/packages/starlight-blog/overrides/MarkdownContent.astro
index 59642f2..38ece8d 100644
--- a/packages/starlight-blog/overrides/MarkdownContent.astro
+++ b/packages/starlight-blog/overrides/MarkdownContent.astro
@@ -24,7 +24,7 @@ if (isBlogPost) {
isBlogPost && blogEntry ? (
<>
{blogEntry.entry.data.cover && }
-
+
>
) : null
}
diff --git a/packages/starlight-blog/overrides/PageTitle.astro b/packages/starlight-blog/overrides/PageTitle.astro
new file mode 100644
index 0000000..ea52ef3
--- /dev/null
+++ b/packages/starlight-blog/overrides/PageTitle.astro
@@ -0,0 +1,44 @@
+---
+import { Icon } from '@astrojs/starlight/components'
+import type { Props } from '@astrojs/starlight/props'
+
+import { getBlogEntry, type StarlightBlogEntryPaginated } from '../libs/content'
+import { isAnyBlogPostPage } from '../libs/page'
+import { formatReadingTime, getReadingTime, type ReadingTimeResult } from '../libs/readingTime'
+
+const { locale, slug, entry } = Astro.props
+
+const isBlogPost = isAnyBlogPostPage(slug)
+let blogEntry: StarlightBlogEntryPaginated | undefined = undefined
+let readingTime: ReadingTimeResult | undefined = undefined
+
+if (isBlogPost) {
+ blogEntry = await getBlogEntry(slug, locale)
+ readingTime = getReadingTime(blogEntry.entry, entry.body.toString())
+}
+---
+
+{Astro.props.entry.data.title}
+
+{
+ isBlogPost && readingTime.showReadingTime && (
+
+ {formatReadingTime(readingTime.readingTime)}
+
+ )
+}
+
+
diff --git a/packages/starlight-blog/package.json b/packages/starlight-blog/package.json
index f0ba4fe..dba67ee 100644
--- a/packages/starlight-blog/package.json
+++ b/packages/starlight-blog/package.json
@@ -10,6 +10,7 @@
"./overrides/MarkdownContent.astro": "./overrides/MarkdownContent.astro",
"./overrides/Sidebar.astro": "./overrides/Sidebar.astro",
"./overrides/ThemeSelect.astro": "./overrides/ThemeSelect.astro",
+ "./overrides/PageTitle.astro": "./overrides/PageTitle.astro",
"./routes/Authors.astro": "./routes/Authors.astro",
"./routes/Blog.astro": "./routes/Blog.astro",
"./routes/Tags.astro": "./routes/Tags.astro",
diff --git a/packages/starlight-blog/schema.ts b/packages/starlight-blog/schema.ts
index f8e72ae..cd05d6b 100644
--- a/packages/starlight-blog/schema.ts
+++ b/packages/starlight-blog/schema.ts
@@ -77,6 +77,11 @@ export const blogEntrySchema = ({ image }: SchemaContext) =>
* Featured blog posts are displayed in a dedicated sidebar group above recent blog posts.
*/
featured: z.boolean().optional(),
+ /**
+ * The time it takes to read the blog post in minutes.
+ * If not provided, the reading time will be inferred from the blog post content.
+ */
+ readingTime: z.number().optional(),
})
export function blogSchema(context: SchemaContext) {
diff --git a/packages/starlight-blog/tests/unit/basics/readingTime.test.ts b/packages/starlight-blog/tests/unit/basics/readingTime.test.ts
new file mode 100644
index 0000000..8c8e823
--- /dev/null
+++ b/packages/starlight-blog/tests/unit/basics/readingTime.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test } from 'vitest'
+
+import { calculateReadingTime, getReadingTime, formatReadingTime } from '../../../libs/readingTime'
+import { mockBlogPost } from '../utils'
+
+describe('calculateReadingTime', () => {
+ test('devide without remainder', () => {
+ expect(calculateReadingTime(`apple `.repeat(200).trim())).toBe(1)
+ expect(calculateReadingTime(`apple `.repeat(400).trim())).toBe(2)
+ })
+
+ test('devide with remainder', () => {
+ expect(calculateReadingTime(`apple `.repeat(201).trim())).toBe(2)
+ expect(calculateReadingTime(`apple `.repeat(450).trim())).toBe(3)
+ })
+
+ test('devide with empty content', () => {
+ expect(calculateReadingTime('')).toBe(1)
+ })
+})
+
+describe('getReadingTime', () => {
+ const postWithoutTime = mockBlogPost('post-1.md', { title: 'Home Page', date: new Date('2023-08-24') })
+ const postWithTime = mockBlogPost('post-2.md', { title: 'Home Page', date: new Date('2023-08-25'), readingTime: 12 })
+
+ test('do not show reading time', () => {
+ expect(getReadingTime(postWithoutTime, `apple `.repeat(200).trim())).toStrictEqual({ showReadingTime: false })
+ })
+
+ test('show reading time, set by frontmatter', () => {
+ expect(getReadingTime(postWithTime, `apple `.repeat(200).trim())).toStrictEqual({
+ showReadingTime: true,
+ readingTime: 12,
+ })
+ })
+})
+
+describe('formatReadingTime', () => {
+ test('get minutes', () => {
+ expect(formatReadingTime(0)).toBe('0 min')
+ expect(formatReadingTime(1)).toBe('1 min')
+ })
+
+ test('get hours', () => {
+ expect(formatReadingTime(60)).toBe('1 h')
+ expect(formatReadingTime(120)).toBe('2 h')
+ })
+
+ test('get minutes and hours', () => {
+ expect(formatReadingTime(61)).toBe('1h 1min')
+ expect(formatReadingTime(231)).toBe('3h 51min')
+ })
+})
diff --git a/packages/starlight-blog/tests/unit/show-reading-time/readingTime.test.ts b/packages/starlight-blog/tests/unit/show-reading-time/readingTime.test.ts
new file mode 100644
index 0000000..e800384
--- /dev/null
+++ b/packages/starlight-blog/tests/unit/show-reading-time/readingTime.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from 'vitest'
+
+import { getReadingTime } from '../../../libs/readingTime'
+import { mockBlogPost } from '../utils'
+
+describe('getReadingTime', () => {
+ const postWithoutTime = mockBlogPost('post-1.md', { title: 'Home Page', date: new Date('2023-08-24') })
+ const postWithTime = mockBlogPost('post-2.md', { title: 'Home Page', date: new Date('2023-08-25'), readingTime: 12 })
+
+ test('show reading time, calculated automatically', () => {
+ expect(getReadingTime(postWithoutTime, `apple `.repeat(200).trim())).toStrictEqual({
+ showReadingTime: true,
+ readingTime: 1,
+ })
+ })
+
+ test('show reading time, overridden by frontmatter', () => {
+ expect(getReadingTime(postWithTime, `apple `.repeat(200).trim())).toStrictEqual({
+ showReadingTime: true,
+ readingTime: 12,
+ })
+ })
+})
diff --git a/packages/starlight-blog/tests/unit/show-reading-time/vitest.config.ts b/packages/starlight-blog/tests/unit/show-reading-time/vitest.config.ts
new file mode 100644
index 0000000..fd8afce
--- /dev/null
+++ b/packages/starlight-blog/tests/unit/show-reading-time/vitest.config.ts
@@ -0,0 +1,3 @@
+import { defineVitestConfig } from '../test'
+
+export default defineVitestConfig({ showReadingTime: true })
diff --git a/packages/starlight-blog/tests/unit/utils.ts b/packages/starlight-blog/tests/unit/utils.ts
index 601d112..f9d5efe 100644
--- a/packages/starlight-blog/tests/unit/utils.ts
+++ b/packages/starlight-blog/tests/unit/utils.ts
@@ -15,7 +15,7 @@ export async function mockBlogPosts(posts: Parameters[]) {
}
}
-function mockBlogPost(docsFilePath: string, entry: StarlightBlogEntryData): StarlightBlogEntry {
+export function mockBlogPost(docsFilePath: string, entry: StarlightBlogEntryData): StarlightBlogEntry {
return {
id: `blog/${slug(docsFilePath.replace(/\.[^.]+$/, '').replace(/\/index$/, ''))}`,
collection: 'docs',