diff --git a/docs/content/docs/frontmatter.mdx b/docs/content/docs/frontmatter.mdx index 7ea8440b..2b7e029d 100644 --- a/docs/content/docs/frontmatter.mdx +++ b/docs/content/docs/frontmatter.mdx @@ -16,6 +16,8 @@ title: Getting Started description: A quick guide to set up your project order: 2 icon: rectangle-stack +authors: + - Jane Doe --- # Getting Started @@ -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 ` or just a name. + +```yaml +authors: + - Jane Doe + - Sam Patel +``` + +A single author can be written without the list: + +```yaml +authors: Jane Doe +``` + +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: diff --git a/examples/basic/content/docs/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx index 61fa9811..5da77c9e 100644 --- a/examples/basic/content/docs/getting-started.mdx +++ b/examples/basic/content/docs/getting-started.mdx @@ -2,6 +2,9 @@ title: Getting Started description: Quick start guide for Chronicle order: 1 +authors: + - Jane Doe + - Sam Patel --- # Getting Started diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx index cf1058c7..cac0c8e7 100644 --- a/examples/versioned/content/docs/guide.mdx +++ b/examples/versioned/content/docs/guide.mdx @@ -1,6 +1,7 @@ --- title: Guide order: 2 +authors: Jane Doe --- # Guide — latest docs diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts index e2e63bb0..09d20a99 100644 --- a/packages/chronicle/src/cli/commands/static-generate.ts +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -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; @@ -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, @@ -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 { @@ -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, diff --git a/packages/chronicle/src/components/common/author-byline.module.css b/packages/chronicle/src/components/common/author-byline.module.css new file mode 100644 index 00000000..16148679 --- /dev/null +++ b/packages/chronicle/src/components/common/author-byline.module.css @@ -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; +} diff --git a/packages/chronicle/src/components/common/author-byline.tsx b/packages/chronicle/src/components/common/author-byline.tsx new file mode 100644 index 00000000..64d73105 --- /dev/null +++ b/packages/chronicle/src/components/common/author-byline.tsx @@ -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 ` 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 ( +
+ {parsed.map((author, index) => ( + + + ))} +
+ ) +} diff --git a/packages/chronicle/src/lib/authors.test.ts b/packages/chronicle/src/lib/authors.test.ts new file mode 100644 index 00000000..c3ecb568 --- /dev/null +++ b/packages/chronicle/src/lib/authors.test.ts @@ -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 ')).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 ')).toEqual({ name: 'Jane Doe ' }) + }) + + test('falls back to the address when only an email is given', () => { + expect(parseAuthor('')).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 ', '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('') + }) +}) diff --git a/packages/chronicle/src/lib/authors.ts b/packages/chronicle/src/lib/authors.ts new file mode 100644 index 00000000..ecef11f5 --- /dev/null +++ b/packages/chronicle/src/lib/authors.ts @@ -0,0 +1,56 @@ +import type { Author } from '@/types' + +/** `Name ` — the second half only counts as an email when it looks like one. */ +const SHORTHAND = /^(.*?)\s*<([^<>]*)>$/ + +/** + * Parses one frontmatter author string. `Jane Doe ` 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() + // `` isn't an address — keep the whole string as the name. + if (!email.includes('@')) return { name: trimmed } + // `` 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() +} diff --git a/packages/chronicle/src/lib/head.tsx b/packages/chronicle/src/lib/head.tsx index f00748f5..1b213ba2 100644 --- a/packages/chronicle/src/lib/head.tsx +++ b/packages/chronicle/src/lib/head.tsx @@ -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; 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 diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 7311df2f..7ae7fc0a 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -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'; @@ -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, }; diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index c428a321..81fe6680 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -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'; @@ -26,12 +27,14 @@ export function DocsPage({ slug }: DocsPageProps) { if (isLoading || !page) return ; const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined; const markdownHref = `/${slug.join('/')}.md`; + const authors = parseAuthors(page.frontmatter.authors); return ( <> author.name)} config={config} markdownHref={markdownHref} jsonLd={{ @@ -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 }), + })), + }), }} /> { 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__); @@ -61,6 +62,11 @@ export default defineHandler(async event => { {description} )} + {authors && ( +
+ {`By ${authors}`} +
+ )} , { width: 1200, diff --git a/packages/chronicle/src/themes/default/Page.module.css b/packages/chronicle/src/themes/default/Page.module.css index 60402116..0e478188 100644 --- a/packages/chronicle/src/themes/default/Page.module.css +++ b/packages/chronicle/src/themes/default/Page.module.css @@ -206,3 +206,7 @@ margin-bottom: var(--rs-space-5); } } + +.byline { + margin-bottom: var(--rs-space-7); +} diff --git a/packages/chronicle/src/themes/default/Page.tsx b/packages/chronicle/src/themes/default/Page.tsx index ed91cb6a..4e18ec91 100644 --- a/packages/chronicle/src/themes/default/Page.tsx +++ b/packages/chronicle/src/themes/default/Page.tsx @@ -2,6 +2,7 @@ import { Flex, Headline } from '@raystack/apsara'; import { lazy, Suspense } from 'react'; +import { AuthorByline } from '@/components/common/author-byline'; import type { ThemePageProps } from '@/types'; import styles from './Page.module.css'; @@ -16,6 +17,7 @@ export function Page({ page }: ThemePageProps) { {page.frontmatter.title} )} +
{page.content}
diff --git a/packages/chronicle/src/themes/paper/Page.module.css b/packages/chronicle/src/themes/paper/Page.module.css index 498ecb89..47603dfa 100644 --- a/packages/chronicle/src/themes/paper/Page.module.css +++ b/packages/chronicle/src/themes/paper/Page.module.css @@ -260,3 +260,8 @@ .navbarLoaderWrapper { width: 30%; } + +.byline { + justify-content: center; + margin-top: var(--rs-space-5); +} diff --git a/packages/chronicle/src/themes/paper/Page.tsx b/packages/chronicle/src/themes/paper/Page.tsx index 36e6e6a1..3b1b30a2 100644 --- a/packages/chronicle/src/themes/paper/Page.tsx +++ b/packages/chronicle/src/themes/paper/Page.tsx @@ -13,6 +13,7 @@ import { IconButton, useTheme } from '@raystack/apsara'; import { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink, useLocation } from 'react-router'; import { flattenTree } from 'fumadocs-core/page-tree'; +import { AuthorByline } from '@/components/common/author-byline'; import { Breadcrumbs } from '@/components/ui/breadcrumbs'; import type { ThemePageProps } from '@/types'; import styles from './Page.module.css'; @@ -104,6 +105,7 @@ export function Page({ page, tree }: ThemePageProps) { {page.frontmatter.description && (

{page.frontmatter.description}

)} +
diff --git a/packages/chronicle/src/types/content.ts b/packages/chronicle/src/types/content.ts index 57a6ac1f..541f51c1 100644 --- a/packages/chronicle/src/types/content.ts +++ b/packages/chronicle/src/types/content.ts @@ -4,12 +4,19 @@ import type { TableOfContents } from 'fumadocs-core/toc' export type { Root, Node, Item, Folder, Separator } from 'fumadocs-core/page-tree' export type { TOCItemType, TableOfContents } from 'fumadocs-core/toc' +/** A page author, parsed from an `authors` frontmatter string. */ +export interface Author { + name: string + email?: string +} + export interface Frontmatter { title: string description?: string order?: number icon?: string lastModified?: string + authors?: string[] draft?: boolean _readingTime?: number }