Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions docs/content/docs/frontmatter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ title: Getting Started
description: A quick guide to set up your project
order: 2
icon: rectangle-stack
authors:
- Jane Doe <jane@example.com>
---

# Getting Started
Expand Down Expand Up @@ -86,6 +88,28 @@ Optional date string indicating when the page was last updated.
lastModified: "2026-03-30"
```

### authors

Optional list of the people who wrote the page. Each entry is a plain string —
either `Name <email>` or just a name.

```yaml
authors:
- Jane Doe <jane@example.com>
- Sam Patel
```

A single author can be written without the list:

```yaml
authors: Jane Doe <jane@example.com>
```

Authors appear as a byline under the page title, in the page's `Article`
structured data, and on the generated social card. When an email is given, the
byline links to it with `mailto:`. The avatar beside each name is drawn from the
author's initials — no image is fetched.

## Navigation Ordering

Sidebar navigation is determined by:
Expand Down
3 changes: 3 additions & 0 deletions examples/basic/content/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
title: Getting Started
description: Quick start guide for Chronicle
order: 1
authors:
- Jane Doe <jane@example.com>
- Sam Patel
---

# Getting Started
Expand Down
1 change: 1 addition & 0 deletions examples/versioned/content/docs/guide.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: Guide
order: 2
authors: Jane Doe <jane@example.com>
---

# Guide — latest docs
Expand Down
8 changes: 8 additions & 0 deletions packages/chronicle/src/cli/commands/static-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isAnimatedImage } from '@/lib/image-animation';
import { getAssetVersion } from '@/lib/asset-version';
import type { VersionContext } from '@/lib/version-source';
import type { Frontmatter, PageNavLink } from '@/types';
import { normalizeAuthorList, parseAuthors } from '@/lib/authors';

export interface StaticGenerateOptions {
projectRoot: string;
Expand Down Expand Up @@ -166,6 +167,7 @@ async function scanContentDir(
order: fm.order as number | undefined,
icon: fm.icon as string | undefined,
lastModified: fm.lastModified as string | undefined,
authors: normalizeAuthorList(fm.authors),
draft: fm.draft as boolean | undefined,
},
rawContent: content,
Expand Down Expand Up @@ -631,6 +633,9 @@ async function generateOgImages(
for (const page of pages) {
const title = page.frontmatter.title;
const description = page.frontmatter.description ?? '';
const authors = parseAuthors(page.frontmatter.authors)
.map(author => author.name)
.join(', ');
const slugKey = page.slugs.join(',') || 'index';

try {
Expand Down Expand Up @@ -663,6 +668,9 @@ async function generateOgImages(
description
? h('div', { style: { fontSize: 24, color: '#999', lineHeight: 1.4 } }, description)
: null,
authors
? h('div', { style: { fontSize: 22, color: '#777', marginTop: 24 } }, `By ${authors}`)
: null,
),
{
width: 1200,
Expand Down
23 changes: 23 additions & 0 deletions packages/chronicle/src/components/common/author-byline.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.byline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rs-space-4);
}

.author {
display: inline-flex;
align-items: center;
gap: var(--rs-space-2);
}

.name {
font-size: var(--rs-font-size-small);
color: var(--rs-color-foreground-base-secondary);
text-decoration: none;
}

a.name:hover {
color: var(--rs-color-foreground-base-primary);
text-decoration: underline;
}
39 changes: 39 additions & 0 deletions packages/chronicle/src/components/common/author-byline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import { Avatar, getAvatarColor } from '@raystack/apsara'
import { authorInitials, parseAuthors } from '@/lib/authors'
import styles from './author-byline.module.css'

interface AuthorBylineProps {
/** Raw `authors` frontmatter entries — `Name <email>` or a bare name. */
authors?: string[]
className?: string
}

/** Avatar-and-name byline for the authors declared in a page's frontmatter. */
export function AuthorByline({ authors, className }: AuthorBylineProps) {
const parsed = parseAuthors(authors)
if (parsed.length === 0) return null

return (
<div className={className ? `${styles.byline} ${className}` : styles.byline}>
{parsed.map((author, index) => (
<span className={styles.author} key={`${author.name}-${index}`}>
<Avatar
size={2}
fallback={authorInitials(author.name)}
color={getAvatarColor(author.name)}
aria-hidden='true'
/>
{author.email ? (
<a className={styles.name} href={`mailto:${author.email}`}>
{author.name}
</a>
) : (
<span className={styles.name}>{author.name}</span>
)}
</span>
))}
</div>
)
}
70 changes: 70 additions & 0 deletions packages/chronicle/src/lib/authors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import { authorInitials, parseAuthor, parseAuthors } from './authors';

describe('parseAuthor', () => {
test('splits the shorthand into name and email', () => {
expect(parseAuthor('Jane Doe <jane@example.com>')).toEqual({ name: 'Jane Doe', email: 'jane@example.com' })
})

test('accepts a bare name', () => {
expect(parseAuthor('Jane Doe')).toEqual({ name: 'Jane Doe' })
})

test('trims surrounding and inner whitespace', () => {
expect(parseAuthor(' Jane Doe < jane@example.com > ')).toEqual({
name: 'Jane Doe',
email: 'jane@example.com',
})
})

test('keeps angle brackets that do not hold an email in the name', () => {
expect(parseAuthor('Jane Doe <not an email>')).toEqual({ name: 'Jane Doe <not an email>' })
})

test('falls back to the address when only an email is given', () => {
expect(parseAuthor('<jane@example.com>')).toEqual({ name: 'jane@example.com', email: 'jane@example.com' })
})

test('returns null for an empty string', () => {
expect(parseAuthor(' ')).toBeNull()
})
})

describe('parseAuthors', () => {
test('parses a list', () => {
expect(parseAuthors(['Jane Doe <jane@example.com>', 'Sam Patel'])).toEqual([
{ name: 'Jane Doe', email: 'jane@example.com' },
{ name: 'Sam Patel' },
])
})

test('accepts a lone string', () => {
expect(parseAuthors('Jane Doe')).toEqual([{ name: 'Jane Doe' }])
})

test('drops empty and non-string entries', () => {
expect(parseAuthors(['Jane Doe', '', 42, null, { name: 'Nope' }])).toEqual([{ name: 'Jane Doe' }])
})

test('returns an empty list when the field is missing', () => {
expect(parseAuthors(undefined)).toEqual([])
})
})

describe('authorInitials', () => {
test('takes the first and last initial', () => {
expect(authorInitials('Jane Doe')).toBe('JD')
})

test('takes one initial from a single word', () => {
expect(authorInitials('Jane')).toBe('J')
})

test('skips middle names', () => {
expect(authorInitials('jane q public')).toBe('JP')
})

test('returns an empty string for a blank name', () => {
expect(authorInitials(' ')).toBe('')
})
})
56 changes: 56 additions & 0 deletions packages/chronicle/src/lib/authors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Author } from '@/types'

/** `Name <email>` — the second half only counts as an email when it looks like one. */
const SHORTHAND = /^(.*?)\s*<([^<>]*)>$/

/**
* Parses one frontmatter author string. `Jane Doe <jane@example.com>` splits into
* name and email; anything else is taken literally as a name, so a contributor can
* write `authors: [Jane Doe]` without ceremony.
*/
export function parseAuthor(value: string): Author | null {
const trimmed = value.trim()
if (!trimmed) return null

const match = SHORTHAND.exec(trimmed)
if (!match) return { name: trimmed }

const name = match[1].trim()
const email = match[2].trim()
// `<not an email>` isn't an address — keep the whole string as the name.
if (!email.includes('@')) return { name: trimmed }
// `<jane@example.com>` alone has no name to show, so the address stands in.
if (!name) return { name: email, email }
return { name, email }
}

/**
* Normalizes the `authors` frontmatter field into parsed authors. Accepts a list of
* strings, or a lone string for the common single-author case.
*/
export function parseAuthors(value: unknown): Author[] {
const list = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
return list
.filter((entry): entry is string => typeof entry === 'string')
.map(parseAuthor)
.filter((author): author is Author => author !== null)
}

/**
* Keeps the `authors` frontmatter field as written, accepting a lone string for the
* single-author case. Use `parseAuthors` when you need the parsed form.
*/
export function normalizeAuthorList(value: unknown): string[] | undefined {
if (typeof value === 'string') return [value]
if (!Array.isArray(value)) return undefined
const entries = value.filter((entry): entry is string => typeof entry === 'string')
return entries.length > 0 ? entries : undefined
}

/** Up to two letters for an avatar fallback: `Jane Doe` → `JD`, `Jane` → `J`. */
export function authorInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const last = words.length > 1 ? words[words.length - 1][0] : ''
return `${words[0][0]}${last}`.toUpperCase()
}
5 changes: 4 additions & 1 deletion packages/chronicle/src/lib/head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import type { ChronicleConfig } from '@/types';
export interface HeadProps {
title: string;
description?: string;
/** Author names, already parsed out of frontmatter. */
authors?: string[];
config: ChronicleConfig;
jsonLd?: Record<string, unknown>;
markdownHref?: string;
}

export function Head({ title, description: pageDescription, config, jsonLd, markdownHref }: HeadProps) {
export function Head({ title, description: pageDescription, authors, config, jsonLd, markdownHref }: HeadProps) {
const { pathname } = useLocation();
const description = pageDescription || config.site.description;
const fullTitle = `${title} | ${config.site.title}`;
const ogParams = new URLSearchParams({ title });
if (description) ogParams.set('description', description);
if (authors?.length) ogParams.set('authors', authors.join(', '));
const siteUrl = config.url ? config.url.replace(/\/$/, '') : null;
const canonical = siteUrl ? `${siteUrl}${pathname}` : null;
const ogImage = siteUrl
Expand Down
2 changes: 2 additions & 0 deletions packages/chronicle/src/lib/source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { normalizeAuthorList } from './authors';
import { loader } from 'fumadocs-core/source';
import { flattenTree } from 'fumadocs-core/page-tree';
import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
Expand Down Expand Up @@ -274,6 +275,7 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri
order: d.order as number | undefined,
icon: d.icon as string | undefined,
lastModified: d.lastModified as string | undefined,
authors: normalizeAuthorList(d.authors),
draft: d.draft as boolean | undefined,
_readingTime: d._readingTime as number | undefined,
};
Expand Down
10 changes: 10 additions & 0 deletions packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Navigate } from 'react-router';
import { StatusCodes } from 'http-status-codes';
import { parseAuthors } from '@/lib/authors';
import { Head } from '@/lib/head';
import { usePageContext } from '@/lib/page-context';
import { resolveDocsRedirect } from '@/lib/tree-utils';
Expand All @@ -26,12 +27,14 @@ export function DocsPage({ slug }: DocsPageProps) {
if (isLoading || !page) return <Skeleton />;
const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
const markdownHref = `/${slug.join('/')}.md`;
const authors = parseAuthors(page.frontmatter.authors);

return (
<>
<Head
title={page.frontmatter.title}
description={page.frontmatter.description}
authors={authors.map(author => author.name)}
config={config}
markdownHref={markdownHref}
jsonLd={{
Expand All @@ -41,6 +44,13 @@ export function DocsPage({ slug }: DocsPageProps) {
description: page.frontmatter.description,
...(pageUrl && { url: pageUrl }),
...(page.frontmatter.lastModified && { dateModified: new Date(page.frontmatter.lastModified).toISOString() }),
...(authors.length > 0 && {
author: authors.map(author => ({
'@type': 'Person',
name: author.name,
...(author.email && { email: author.email }),
})),
}),
}}
/>
<Page
Expand Down
6 changes: 6 additions & 0 deletions packages/chronicle/src/server/routes/og.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default defineHandler(async event => {
const config = loadConfig();
const title = event.url.searchParams.get('title') ?? config.site.title;
const description = event.url.searchParams.get('description') ?? '';
const authors = event.url.searchParams.get('authors') ?? '';
const siteName = config.site.title;

if (!fontData) fontData = await loadFont(__CHRONICLE_PACKAGE_ROOT__);
Expand Down Expand Up @@ -61,6 +62,11 @@ export default defineHandler(async event => {
{description}
</div>
)}
{authors && (
<div style={{ fontSize: 22, color: '#777', marginTop: 24 }}>
{`By ${authors}`}
</div>
)}
</div>,
{
width: 1200,
Expand Down
4 changes: 4 additions & 0 deletions packages/chronicle/src/themes/default/Page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,7 @@
margin-bottom: var(--rs-space-5);
}
}

.byline {
margin-bottom: var(--rs-space-7);
}
Loading
Loading