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',