Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
af315bb
implement basic reading time functionality
trueberryless Nov 10, 2024
4751d80
always override PageTitle so if showReadingTime is false, it can stil…
trueberryless Nov 10, 2024
75f494e
documentation
trueberryless Nov 10, 2024
474c820
show hour if too large
trueberryless Nov 10, 2024
a79282e
fix if readingTime is exactly modulo 60
trueberryless Nov 10, 2024
b507944
start impl tests
trueberryless Nov 11, 2024
0d4cdd6
Create new test setup
trueberryless Nov 11, 2024
a70f967
Update readingTime.test.ts
trueberryless Nov 11, 2024
af0569f
Update show-reading-time
trueberryless Nov 11, 2024
f91a4a9
Update utils.ts
trueberryless Nov 11, 2024
a974700
Update readingTime.test.ts
trueberryless Nov 11, 2024
40b00a3
Update readingTime.test.ts
trueberryless Nov 11, 2024
b79350a
Update readingTime.test.ts
trueberryless Nov 11, 2024
c979e4c
Update readingTime.test.ts
trueberryless Nov 11, 2024
79b0ffe
Update readingTime.test.ts
trueberryless Nov 11, 2024
cac051c
Update readingTime.test.ts
trueberryless Nov 11, 2024
f79c049
Update readingTime.test.ts
trueberryless Nov 11, 2024
04469b1
Update readingTime.test.ts
trueberryless Nov 11, 2024
1efff18
update tests and lint
trueberryless Nov 11, 2024
0ea1487
undo pnpm-lock
trueberryless Nov 11, 2024
8c96f09
Update configuration.md
trueberryless Nov 11, 2024
0902a29
Update frontmatter.md
trueberryless Nov 11, 2024
cf1f6ea
Merge branch 'main' into tbl-feat-reading-time
trueberryless Dec 23, 2024
440d7a9
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default defineConfig({
url: 'https://hideoo.dev',
},
},
showReadingTime: true,
}),
],
sidebar: [
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/blog/oculus-plangoremque-praeside.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/guides/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
```
26 changes: 24 additions & 2 deletions packages/starlight-blog/components/Metadata.astro
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
---
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'

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())
---

<div class="metadata not-content">
Expand All @@ -36,6 +40,15 @@ const hasAuthors = authors.length > 0
/>
) : null
}
{
showReadingTime && readingTime.showReadingTime && (
<span class="reading-time">
&nbsp;-&nbsp;
<Icon name="seti:clock" />
{formatReadingTime(readingTime.readingTime)}
</span>
)
}
</div>
{
hasAuthors ? (
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/starlight-blog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []),
Expand Down
6 changes: 6 additions & 0 deletions packages/starlight-blog/libs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})

Expand Down
29 changes: 29 additions & 0 deletions packages/starlight-blog/libs/readingTime.ts
Original file line number Diff line number Diff line change
@@ -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`
}
2 changes: 1 addition & 1 deletion packages/starlight-blog/overrides/MarkdownContent.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ if (isBlogPost) {
isBlogPost && blogEntry ? (
<>
{blogEntry.entry.data.cover && <Cover cover={blogEntry.entry.data.cover} />}
<Metadata entry={blogEntry.entry} {locale} showBadges={false} />
<Metadata entry={blogEntry.entry} {locale} showBadges={false} showReadingTime={false} />
</>
) : null
}
Expand Down
44 changes: 44 additions & 0 deletions packages/starlight-blog/overrides/PageTitle.astro
Original file line number Diff line number Diff line change
@@ -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())
}
---

<h1 id={'_top'}>{Astro.props.entry.data.title}</h1>

{
isBlogPost && readingTime.showReadingTime && (
<p>
<Icon name="seti:clock" /> {formatReadingTime(readingTime.readingTime)}
</p>
)
}

<style>
h1 {
margin-top: 1rem;
font-size: var(--sl-text-h1);
line-height: var(--sl-line-height-headings);
font-weight: 600;
color: var(--sl-color-white);
}

p > svg {
transform: translateY(2px);
margin-right: 4px;
}
</style>
1 change: 1 addition & 0 deletions packages/starlight-blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/starlight-blog/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions packages/starlight-blog/tests/unit/basics/readingTime.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineVitestConfig } from '../test'

export default defineVitestConfig({ showReadingTime: true })
2 changes: 1 addition & 1 deletion packages/starlight-blog/tests/unit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function mockBlogPosts(posts: Parameters<typeof mockBlogPost>[]) {
}
}

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',
Expand Down
Loading