diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 7b5352d9..fd04a40c 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -363,6 +363,39 @@ analytics: | `enabled` | `boolean` | Enable/disable analytics | `false` | | `googleAnalytics.measurementId` | `string` | Google Analytics measurement ID | — | +### authors + +Optional registry of the people who write your docs. Pages reference an entry by +its key in their `authors` frontmatter, so the details live in one place. + +```yaml +authors: + jane: + name: Jane Doe + bio: Writes about distributed systems. + avatar: /team/jane.png + url: https://github.com/jane + email: jane@example.com +``` + +| Field | Type | Description | Default | +|-------|------|-------------|---------| +| `name` | `string` | **Required.** Display name | — | +| `bio` | `string` | Short description shown on the author's page | — | +| `avatar` | `string` | Image path for the avatar; initials are used without it | — | +| `url` | `string` | Profile link, shown on the author's page | — | +| `email` | `string` | Contact address, shown on the author's page with `mailto:` | — | + +```yaml +# page.mdx frontmatter +authors: [jane] +``` + +A frontmatter string that matches no key is still valid — it renders as a plain +name, so occasional contributors need no registry entry. See +[Frontmatter](/docs/frontmatter) for the field itself, and browse the people +writing a site at `/authors`. + ### telemetry Prometheus metrics export via OpenTelemetry. Served on a separate port. diff --git a/docs/content/docs/frontmatter.mdx b/docs/content/docs/frontmatter.mdx index 2b7e029d..28c47f02 100644 --- a/docs/content/docs/frontmatter.mdx +++ b/docs/content/docs/frontmatter.mdx @@ -105,10 +105,22 @@ A single author can be written without the list: 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. +Authors appear as a byline on the page, in the page's `Article` structured data, +and on the generated social card. Every author also gets a page at +`/authors/` listing everything they wrote, and byline names link there. Any +`url` or `email` an author has is shown on that page rather than in the byline. + +A byline shows two authors at most, collapsing the rest into a `+N` counter that +names them on hover. The avatar beside each name is drawn from the author's +initials unless the author has an `avatar` in the +[authors registry](/docs/configuration). + +Registry keys work here too, and bring the author's bio, avatar, and profile +link along with them: + +```yaml +authors: [jane] +``` ## Navigation Ordering diff --git a/examples/basic/chronicle.yaml b/examples/basic/chronicle.yaml index b56727ea..be890c65 100644 --- a/examples/basic/chronicle.yaml +++ b/examples/basic/chronicle.yaml @@ -13,6 +13,13 @@ content: label: Docs icon: /icons/docs.svg +authors: + jane: + name: Jane Doe + bio: Writes about the parts of the system nobody else wants to document. + url: https://github.com/jane + email: jane@example.com + theme: name: default diff --git a/examples/basic/content/docs/components.mdx b/examples/basic/content/docs/components.mdx index cfa63945..94d62759 100644 --- a/examples/basic/content/docs/components.mdx +++ b/examples/basic/content/docs/components.mdx @@ -2,6 +2,7 @@ title: Components description: Live demo of Chronicle's built-in MDX components order: 3 +authors: [jane] --- # Components diff --git a/examples/basic/content/docs/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx index 5da77c9e..69f16e5a 100644 --- a/examples/basic/content/docs/getting-started.mdx +++ b/examples/basic/content/docs/getting-started.mdx @@ -3,7 +3,7 @@ title: Getting Started description: Quick start guide for Chronicle order: 1 authors: - - Jane Doe + - jane - Sam Patel --- diff --git a/examples/versioned/chronicle.yaml b/examples/versioned/chronicle.yaml index 2a1c054a..c529ff53 100644 --- a/examples/versioned/chronicle.yaml +++ b/examples/versioned/chronicle.yaml @@ -2,6 +2,13 @@ site: title: Versioned Example description: Multi-content + multi-version sample +authors: + jane: + name: Jane Doe + bio: Writes about the parts of the system nobody else wants to document. + url: https://github.com/jane + email: jane@example.com + theme: name: paper diff --git a/examples/versioned/content/dev/api.mdx b/examples/versioned/content/dev/api.mdx index 82668e13..ec45b23a 100644 --- a/examples/versioned/content/dev/api.mdx +++ b/examples/versioned/content/dev/api.mdx @@ -1,6 +1,7 @@ --- title: API notes order: 2 +authors: [jane, Sam Patel, Ana Ruiz ] --- # Dev API notes — latest diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx index cac0c8e7..30d3ba21 100644 --- a/examples/versioned/content/docs/guide.mdx +++ b/examples/versioned/content/docs/guide.mdx @@ -1,7 +1,7 @@ --- title: Guide order: 2 -authors: Jane Doe +authors: [jane] --- # Guide — latest docs diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts index 09d20a99..10760b2b 100644 --- a/packages/chronicle/src/cli/commands/static-generate.ts +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -24,7 +24,8 @@ 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'; +import { buildAuthorIndex } from '@/lib/author-index'; +import { normalizeAuthorList, resolveAuthors } from '@/lib/authors'; export interface StaticGenerateOptions { projectRoot: string; @@ -439,6 +440,21 @@ async function generatePageDataFiles( } } +/** `/data/authors.json` — what `/api/authors` serves in server mode. */ +async function generateAuthorsData( + pages: ScannedPage[], + config: ChronicleConfig, + outputDir: string, +): Promise { + const index = buildAuthorIndex( + pages.map(page => ({ url: page.url, frontmatter: page.frontmatter })), + config, + ); + const dataDir = path.join(outputDir, 'data'); + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, 'authors.json'), JSON.stringify(index)); +} + async function generateSearchIndex( pages: ScannedPage[], config: ChronicleConfig, @@ -633,7 +649,7 @@ async function generateOgImages( for (const page of pages) { const title = page.frontmatter.title; const description = page.frontmatter.description ?? ''; - const authors = parseAuthors(page.frontmatter.authors) + const authors = resolveAuthors(page.frontmatter.authors, config) .map(author => author.name) .join(', '); const slugKey = page.slugs.join(',') || 'index'; @@ -1054,6 +1070,9 @@ export async function generateStaticSite(options: StaticGenerateOptions): Promis console.log(chalk.gray(' Generating page data files...')); await generatePageDataFiles(pages, navMap, outputDir); + console.log(chalk.gray(' Generating authors data...')); + await generateAuthorsData(pages, config, outputDir); + console.log(chalk.gray(' Generating search index...')); await generateSearchIndex(pages, config, outputDir, projectRoot); diff --git a/packages/chronicle/src/components/common/author-byline.module.css b/packages/chronicle/src/components/common/author-byline.module.css index 16148679..4405cdf0 100644 --- a/packages/chronicle/src/components/common/author-byline.module.css +++ b/packages/chronicle/src/components/common/author-byline.module.css @@ -1,5 +1,5 @@ .byline { - display: flex; + display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--rs-space-4); @@ -21,3 +21,32 @@ a.name:hover { color: var(--rs-color-foreground-base-primary); text-decoration: underline; } + +.more { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-tertiary); + cursor: default; +} + +/* Inherits the surrounding meta line's font, size, and color. */ +.inlineByline { + display: inline-flex; + align-items: center; + gap: var(--rs-space-4); + font: inherit; + letter-spacing: inherit; + color: inherit; +} + +.inlineByline .more { + font: inherit; + letter-spacing: inherit; + color: inherit; +} + +.inlineByline .name { + font: inherit; + letter-spacing: inherit; + color: inherit; + text-transform: uppercase; +} diff --git a/packages/chronicle/src/components/common/author-byline.tsx b/packages/chronicle/src/components/common/author-byline.tsx index 64d73105..4c145bc2 100644 --- a/packages/chronicle/src/components/common/author-byline.tsx +++ b/packages/chronicle/src/components/common/author-byline.tsx @@ -1,39 +1,71 @@ 'use client' import { Avatar, getAvatarColor } from '@raystack/apsara' -import { authorInitials, parseAuthors } from '@/lib/authors' +import { Link } from 'react-router' +import type { Author } from '@/types' +import { authorInitials, resolveAuthors } from '@/lib/authors' +import { usePageContext } from '@/lib/page-context' +import { authorPageUrl } from '@/lib/route-resolver' import styles from './author-byline.module.css' +/** Beyond this, the remainder is collapsed into a `+N` counter. */ +const MAX_VISIBLE = 2 + interface AuthorBylineProps { /** Raw `authors` frontmatter entries — `Name ` or a bare name. */ authors?: string[] + /** + * `block` stands on its own under a page title; `inline` sits in a meta line + * beside other text and inherits its typography. + */ + variant?: 'block' | 'inline' 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) +/** + * The name, linked to the author's page. Their `url` and `email` are left to that + * page rather than competing with it here. + */ +function AuthorName({ author, href }: { author: Author; href: string | null }) { + if (!href) return {author.name} + return ( + + {author.name} + + ) +} + +/** Byline for the authors declared in a page's frontmatter. */ +export function AuthorByline({ authors, variant = 'block', className }: AuthorBylineProps) { + const { config, version } = usePageContext() + const parsed = resolveAuthors(authors, config) if (parsed.length === 0) return null + const wrapper = variant === 'inline' ? styles.inlineByline : styles.byline + const shown = parsed.slice(0, MAX_VISIBLE) + const hidden = parsed.slice(MAX_VISIBLE) + return ( -
- {parsed.map((author, index) => ( - + + {shown.map((author, index) => ( + ))} -
+ {hidden.length > 0 && ( + author.name).join(', ')}> + {`+${hidden.length}`} + + )} + ) } diff --git a/packages/chronicle/src/lib/author-index.test.ts b/packages/chronicle/src/lib/author-index.test.ts new file mode 100644 index 00000000..1cc21391 --- /dev/null +++ b/packages/chronicle/src/lib/author-index.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'bun:test'; +import type { ChronicleConfig, Frontmatter } from '@/types'; +import { buildAuthorIndex, findAuthor } from './author-index'; + +const config = { + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'guides', label: 'Guides' }, + ], + authors: { + jane: { name: 'Jane Doe', bio: 'Writes about systems.', avatar: '/team/jane.png' }, + }, +} as unknown as ChronicleConfig; + +function page(url: string, frontmatter: Partial & { title: string }) { + return { url, frontmatter: frontmatter as Frontmatter } +} + +describe('buildAuthorIndex', () => { + test('groups pages under each author', () => { + const index = buildAuthorIndex( + [ + page('/docs/a', { title: 'A', authors: ['jane'] }), + page('/docs/b', { title: 'B', authors: ['jane', 'Sam Patel'] }), + ], + config, + ) + + expect(index.authors.map(a => [a.slug, a.pages.length])).toEqual([ + ['jane', 2], + ['sam-patel', 1], + ]) + }) + + test('carries the registry profile onto the summary', () => { + const index = buildAuthorIndex([page('/docs/a', { title: 'A', authors: ['jane'] })], config) + expect(index.authors[0]).toMatchObject({ + slug: 'jane', + name: 'Jane Doe', + bio: 'Writes about systems.', + avatar: '/team/jane.png', + }) + }) + + test('labels each page with its content dir', () => { + const index = buildAuthorIndex( + [ + page('/docs/a', { title: 'A', authors: ['jane'] }), + page('/guides/b', { title: 'B', authors: ['jane'] }), + ], + config, + ) + expect(index.authors[0].pages.map(p => [p.dir, p.dirLabel])).toEqual([ + ['docs', 'Docs'], + ['guides', 'Guides'], + ]) + }) + + test('falls back to the dir name when it has no configured label', () => { + const index = buildAuthorIndex([page('/blog/a', { title: 'A', authors: ['jane'] })], config) + expect(index.authors[0].pages[0].dirLabel).toBe('blog') + }) + + test('sorts pages newest first, undated last, then by title', () => { + const index = buildAuthorIndex( + [ + page('/docs/old', { title: 'Old', authors: ['jane'], lastModified: '2026-01-01' }), + page('/docs/zeta', { title: 'Zeta', authors: ['jane'] }), + page('/docs/new', { title: 'New', authors: ['jane'], lastModified: '2026-06-01' }), + page('/docs/alpha', { title: 'Alpha', authors: ['jane'] }), + ], + config, + ) + expect(index.authors[0].pages.map(p => p.title)).toEqual(['New', 'Old', 'Alpha', 'Zeta']) + }) + + test('sorts authors by name', () => { + const index = buildAuthorIndex( + [page('/docs/a', { title: 'A', authors: ['Zoe Adams', 'Adam Zeal'] })], + config, + ) + expect(index.authors.map(a => a.name)).toEqual(['Adam Zeal', 'Zoe Adams']) + }) + + test('skips pages that declare no authors', () => { + const index = buildAuthorIndex([page('/docs/a', { title: 'A' })], config) + expect(index.authors).toEqual([]) + }) + + test('keeps a registry key and a same-named literal separate', () => { + const index = buildAuthorIndex( + [ + page('/docs/a', { title: 'A', authors: ['jane'] }), + page('/docs/b', { title: 'B', authors: ['Jane Doe'] }), + ], + config, + ) + expect(index.authors.map(a => a.slug)).toEqual(['jane', 'jane-doe']) + }) +}) + +describe('findAuthor', () => { + test('looks an author up by slug', () => { + const index = buildAuthorIndex([page('/docs/a', { title: 'A', authors: ['jane'] })], config) + expect(findAuthor(index, 'jane')?.name).toBe('Jane Doe') + expect(findAuthor(index, 'nobody')).toBeUndefined() + }) +}) diff --git a/packages/chronicle/src/lib/author-index.ts b/packages/chronicle/src/lib/author-index.ts new file mode 100644 index 00000000..938260b8 --- /dev/null +++ b/packages/chronicle/src/lib/author-index.ts @@ -0,0 +1,86 @@ +import { resolveAuthors } from './authors' +import type { Author, ChronicleConfig, Frontmatter } from '@/types' + +/** One page written by an author, as listed on that author's page. */ +export interface AuthorPageEntry { + url: string + title: string + description?: string + /** Content dir the page lives in, so listings can group by section. */ + dir: string + /** `label` of the matching content entry, falling back to the dir name. */ + dirLabel: string + lastModified?: string +} + +export interface AuthorSummary extends Author { + pages: AuthorPageEntry[] +} + +/** The shape served by `/api/authors` and `/data/authors.json`. */ +export interface AuthorIndex { + authors: AuthorSummary[] +} + +interface IndexablePage { + url: string + frontmatter: Frontmatter +} + +function contentDirOf(url: string): string { + return url.replace(/^\//, '').split('/')[0] ?? '' +} + +/** Newest first; pages without a date sort after dated ones, then by title. */ +function byRecency(a: AuthorPageEntry, b: AuthorPageEntry): number { + if (a.lastModified && b.lastModified) { + const diff = new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime() + if (diff !== 0) return diff + } else if (a.lastModified) { + return -1 + } else if (b.lastModified) { + return 1 + } + return a.title.localeCompare(b.title) +} + +/** + * Groups pages by the authors declared in their frontmatter. Authors are keyed by + * slug, so a registry key and a bare name never collide, and the first page that + * names an author supplies the profile shown for them. + */ +export function buildAuthorIndex(pages: IndexablePage[], config: ChronicleConfig): AuthorIndex { + const dirLabels = new Map((config.content ?? []).map(entry => [entry.dir, entry.label])) + const summaries = new Map() + + for (const page of pages) { + const authors = resolveAuthors(page.frontmatter.authors, config) + if (authors.length === 0) continue + + const dir = contentDirOf(page.url) + const entry: AuthorPageEntry = { + url: page.url, + title: page.frontmatter.title, + ...(page.frontmatter.description && { description: page.frontmatter.description }), + dir, + dirLabel: dirLabels.get(dir) ?? dir, + ...(page.frontmatter.lastModified && { lastModified: page.frontmatter.lastModified }), + } + + for (const author of authors) { + const existing = summaries.get(author.slug) + if (existing) existing.pages.push(entry) + else summaries.set(author.slug, { ...author, pages: [entry] }) + } + } + + const authors = [...summaries.values()] + .map(author => ({ ...author, pages: author.pages.sort(byRecency) })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return { authors } +} + +export function findAuthor(index: AuthorIndex, slug: string): AuthorSummary | undefined { + return index.authors.find(author => author.slug === slug) +} diff --git a/packages/chronicle/src/lib/authors.test.ts b/packages/chronicle/src/lib/authors.test.ts index c3ecb568..da6e0e9b 100644 --- a/packages/chronicle/src/lib/authors.test.ts +++ b/packages/chronicle/src/lib/authors.test.ts @@ -1,28 +1,37 @@ import { describe, expect, test } from 'bun:test'; -import { authorInitials, parseAuthor, parseAuthors } from './authors'; +import type { ChronicleConfig } from '@/types'; +import { + authorInitials, + parseAuthor, + parseAuthors, + resolveAuthor, + resolveAuthors, + slugifyAuthorName, +} from './authors'; describe('parseAuthor', () => { test('splits the shorthand into name and email', () => { - expect(parseAuthor('Jane Doe ')).toEqual({ name: 'Jane Doe', email: 'jane@example.com' }) + expect(parseAuthor('Jane Doe ')).toEqual({ slug: 'jane-doe', name: 'Jane Doe', email: 'jane@example.com' }) }) test('accepts a bare name', () => { - expect(parseAuthor('Jane Doe')).toEqual({ name: 'Jane Doe' }) + expect(parseAuthor('Jane Doe')).toEqual({ slug: 'jane-doe', name: 'Jane Doe' }) }) test('trims surrounding and inner whitespace', () => { expect(parseAuthor(' Jane Doe < jane@example.com > ')).toEqual({ + slug: 'jane-doe', 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 ' }) + expect(parseAuthor('Jane Doe ')).toEqual({ slug: 'jane-doe-not-an-email', 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' }) + expect(parseAuthor('')).toEqual({ slug: 'jane-example-com', name: 'jane@example.com', email: 'jane@example.com' }) }) test('returns null for an empty string', () => { @@ -33,17 +42,17 @@ describe('parseAuthor', () => { describe('parseAuthors', () => { test('parses a list', () => { expect(parseAuthors(['Jane Doe ', 'Sam Patel'])).toEqual([ - { name: 'Jane Doe', email: 'jane@example.com' }, - { name: 'Sam Patel' }, + { slug: 'jane-doe', name: 'Jane Doe', email: 'jane@example.com' }, + { slug: 'sam-patel', name: 'Sam Patel' }, ]) }) test('accepts a lone string', () => { - expect(parseAuthors('Jane Doe')).toEqual([{ name: 'Jane Doe' }]) + expect(parseAuthors('Jane Doe')).toEqual([{ slug: 'jane-doe', name: 'Jane Doe' }]) }) test('drops empty and non-string entries', () => { - expect(parseAuthors(['Jane Doe', '', 42, null, { name: 'Nope' }])).toEqual([{ name: 'Jane Doe' }]) + expect(parseAuthors(['Jane Doe', '', 42, null, { name: 'Nope' }])).toEqual([{ slug: 'jane-doe', name: 'Jane Doe' }]) }) test('returns an empty list when the field is missing', () => { @@ -68,3 +77,63 @@ describe('authorInitials', () => { expect(authorInitials(' ')).toBe('') }) }) + +const configWithRegistry = { + authors: { + jane: { + name: 'Jane Doe', + bio: 'Writes about distributed systems.', + avatar: '/team/jane.png', + url: 'https://github.com/jane', + }, + }, +} as unknown as ChronicleConfig; + +describe('slugifyAuthorName', () => { + test('lowercases and joins words with dashes', () => { + expect(slugifyAuthorName('Jane Doe')).toBe('jane-doe') + }) + + test('collapses punctuation and trims stray dashes', () => { + expect(slugifyAuthorName(" Ana-María O'Brien, Jr. ")).toBe('ana-mar-a-o-brien-jr') + }) +}) + +describe('resolveAuthor', () => { + test('expands a registry key into its profile', () => { + expect(resolveAuthor('jane', configWithRegistry)).toEqual({ + slug: 'jane', + name: 'Jane Doe', + bio: 'Writes about distributed systems.', + avatar: '/team/jane.png', + url: 'https://github.com/jane', + }) + }) + + test('falls back to the shorthand for a name that is not a key', () => { + expect(resolveAuthor('Sam Patel ', configWithRegistry)).toEqual({ + slug: 'sam-patel', + name: 'Sam Patel', + email: 'sam@example.com', + }) + }) + + test('works without a registry', () => { + expect(resolveAuthor('jane')).toEqual({ slug: 'jane', name: 'jane' }) + }) +}) + +describe('resolveAuthors', () => { + test('mixes registry keys and literal names', () => { + expect(resolveAuthors(['jane', 'Sam Patel'], configWithRegistry)).toEqual([ + { + slug: 'jane', + name: 'Jane Doe', + bio: 'Writes about distributed systems.', + avatar: '/team/jane.png', + url: 'https://github.com/jane', + }, + { slug: 'sam-patel', name: 'Sam Patel' }, + ]) + }) +}) diff --git a/packages/chronicle/src/lib/authors.ts b/packages/chronicle/src/lib/authors.ts index ecef11f5..134ea035 100644 --- a/packages/chronicle/src/lib/authors.ts +++ b/packages/chronicle/src/lib/authors.ts @@ -1,8 +1,16 @@ -import type { Author } from '@/types' +import type { Author, ChronicleConfig } from '@/types' /** `Name ` — the second half only counts as an email when it looks like one. */ const SHORTHAND = /^(.*?)\s*<([^<>]*)>$/ +/** URL-safe identifier for an author who has no registry entry. */ +export function slugifyAuthorName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + /** * Parses one frontmatter author string. `Jane Doe ` splits into * name and email; anything else is taken literally as a name, so a contributor can @@ -13,15 +21,38 @@ export function parseAuthor(value: string): Author | null { if (!trimmed) return null const match = SHORTHAND.exec(trimmed) - if (!match) return { name: trimmed } + if (!match) return withSlug({ 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 } + if (!email.includes('@')) return withSlug({ name: trimmed }) // `` alone has no name to show, so the address stands in. - if (!name) return { name: email, email } - return { name, email } + if (!name) return withSlug({ name: email, email }) + return withSlug({ name, email }) +} + +function withSlug(author: Omit): Author { + return { slug: slugifyAuthorName(author.name), ...author } +} + +/** + * Resolves one frontmatter author string against the config registry. A string that + * names a registry key carries that entry's full profile — bio, avatar, links — + * while anything else falls back to the `Name ` shorthand. + */ +export function resolveAuthor(value: string, config?: ChronicleConfig): Author | null { + const key = value.trim() + const entry = key ? config?.authors?.[key] : undefined + if (!entry) return parseAuthor(value) + return { ...entry, slug: key } +} + +/** Resolves the whole `authors` frontmatter field against the registry. */ +export function resolveAuthors(value: unknown, config?: ChronicleConfig): Author[] { + return toAuthorStrings(value) + .map(entry => resolveAuthor(entry, config)) + .filter((author): author is Author => author !== null) } /** @@ -29,13 +60,16 @@ export function parseAuthor(value: string): Author | null { * 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') + return toAuthorStrings(value) .map(parseAuthor) .filter((author): author is Author => author !== null) } +function toAuthorStrings(value: unknown): string[] { + const list = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] + return list.filter((entry): entry is string => typeof entry === 'string') +} + /** * Keeps the `authors` frontmatter field as written, accepting a lone string for the * single-author case. Use `parseAuthors` when you need the parsed form. diff --git a/packages/chronicle/src/lib/data-urls.ts b/packages/chronicle/src/lib/data-urls.ts index 6584b844..fff6d28e 100644 --- a/packages/chronicle/src/lib/data-urls.ts +++ b/packages/chronicle/src/lib/data-urls.ts @@ -18,6 +18,10 @@ export function specsUrl(versionDir: string | null): string { : '/api/specs'; } +export function authorsUrl(): string { + return isStaticMode() ? '/data/authors.json' : '/api/authors'; +} + export function searchIndexUrl(): string { return '/data/search.json'; } diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index bb11a508..d1c3b7f9 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -11,6 +11,7 @@ import { useLocation, useNavigate } from 'react-router'; import type { ApiSpec } from '@/lib/openapi'; import { resolveRoute, resolveContentRootRedirect, RouteType } from '@/lib/route-resolver'; import { isStaticMode } from '@/lib/static-mode'; +import type { AuthorIndex } from '@/lib/author-index'; import { pageDataUrl, specsUrl } from '@/lib/data-urls'; import type { VersionContext } from '@/lib/version-source'; import { LATEST_CONTEXT } from '@/lib/version-source'; @@ -21,6 +22,8 @@ import { isLocalImage, isSvg, buildOptimizedUrl, splitVersion, webpUrl, DEFAULT_ export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; interface PageContextValue { + /** Author index embedded by the server for /authors routes; null elsewhere. */ + authorIndex: AuthorIndex | null; config: ChronicleConfig; tree: Root; page: Page | null; @@ -45,6 +48,7 @@ export function usePageContext(): PageContextValue { }, tree: { name: 'root', children: [] } as Root, page: null, + authorIndex: null, isLoading: false, errorStatus: null, errorMessage: null, @@ -60,6 +64,7 @@ interface PageProviderProps { initialTree: Root; initialPage: Page | null; initialApiSpecs: ApiSpec[]; + initialAuthorIndex?: AuthorIndex | null; initialVersion: VersionContext; loadMdx: MdxLoader; children: ReactNode; @@ -71,6 +76,7 @@ function getInitialErrorStatus(page: Page | null, config: ChronicleConfig, pathn if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) return null; if (route.type === RouteType.Redirect) return null; if (route.type === RouteType.DocsIndex) return null; + if (isAuthorRoute(route)) return null; return 404; } @@ -79,6 +85,7 @@ export function PageProvider({ initialTree, initialPage, initialApiSpecs, + initialAuthorIndex = null, initialVersion, loadMdx, children @@ -229,7 +236,7 @@ export function PageProvider({ return ( {children} diff --git a/packages/chronicle/src/lib/route-resolver.test.ts b/packages/chronicle/src/lib/route-resolver.test.ts index d1bc9d57..1c80cd49 100644 --- a/packages/chronicle/src/lib/route-resolver.test.ts +++ b/packages/chronicle/src/lib/route-resolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { type ChronicleConfig, chronicleConfigSchema } from '@/types' -import { resolveRoute, RouteType } from './route-resolver' +import { authorPageUrl, resolveRoute, RouteType } from './route-resolver' import { LATEST_CONTEXT } from './version-source' function singleContent(): ChronicleConfig { @@ -171,3 +171,73 @@ describe('resolveRoute — edge cases', () => { }) }) }) + +function authorsAsContentDir(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'authors', label: 'Authors' }], + }) +} + +describe('resolveRoute — authors', () => { + test('/authors is the author index', () => { + expect(resolveRoute('/authors', singleContent())).toEqual({ + type: RouteType.AuthorIndex, + version: LATEST_CONTEXT, + }) + }) + + test('/authors/ is one author', () => { + expect(resolveRoute('/authors/jane', singleContent())).toEqual({ + type: RouteType.AuthorPage, + version: LATEST_CONTEXT, + authorSlug: 'jane', + }) + }) + + test('a deeper path under /authors falls through to docs', () => { + expect(resolveRoute('/authors/jane/extra', singleContent())).toEqual({ + type: RouteType.DocsPage, + version: LATEST_CONTEXT, + slug: ['authors', 'jane', 'extra'], + }) + }) + + test('resolves under a version prefix', () => { + expect(resolveRoute('/v1/authors', versioned())).toEqual({ + type: RouteType.AuthorIndex, + version: { dir: 'v1', urlPrefix: '/v1' }, + }) + }) + + test('a content dir named authors keeps serving its own pages', () => { + expect(resolveRoute('/authors', authorsAsContentDir())).toEqual({ + type: RouteType.DocsPage, + version: LATEST_CONTEXT, + slug: ['authors'], + }) + expect(resolveRoute('/authors/jane', authorsAsContentDir())).toEqual({ + type: RouteType.DocsPage, + version: LATEST_CONTEXT, + slug: ['authors', 'jane'], + }) + }) +}) + +describe('authorPageUrl', () => { + test('points at the author page', () => { + expect(authorPageUrl('jane', singleContent(), LATEST_CONTEXT)).toBe('/authors/jane') + }) + + test('keeps the version prefix', () => { + expect(authorPageUrl('jane', versioned(), { dir: 'v1', urlPrefix: '/v1' })).toBe('/v1/authors/jane') + }) + + test('encodes the slug', () => { + expect(authorPageUrl('ana ruiz', singleContent(), LATEST_CONTEXT)).toBe('/authors/ana%20ruiz') + }) + + test('is null when a content dir owns the authors segment', () => { + expect(authorPageUrl('jane', authorsAsContentDir(), LATEST_CONTEXT)).toBeNull() + }) +}) diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts index 882fb998..df034d0c 100644 --- a/packages/chronicle/src/lib/route-resolver.ts +++ b/packages/chronicle/src/lib/route-resolver.ts @@ -9,6 +9,8 @@ export const RouteType = { DocsPage: 'docs-page', ApiIndex: 'api-index', ApiPage: 'api-page', + AuthorIndex: 'author-index', + AuthorPage: 'author-page', } as const export type RouteType = (typeof RouteType)[keyof typeof RouteType] @@ -19,6 +21,13 @@ export type Route = | { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] } | { type: typeof RouteType.ApiIndex; version: VersionContext } | { type: typeof RouteType.ApiPage; version: VersionContext; slug: string[] } + | { type: typeof RouteType.AuthorIndex; version: VersionContext } + | { type: typeof RouteType.AuthorPage; version: VersionContext; authorSlug: string } + +/** True for `/authors` and `/authors/`. */ +export function isAuthorRoute(route: Route): boolean { + return route.type === RouteType.AuthorIndex || route.type === RouteType.AuthorPage +} function contentDirsFor( config: ChronicleConfig, @@ -32,6 +41,29 @@ function contentDirsFor( ) } +const AUTHORS_SEGMENT = 'authors' + +/** + * Author routes are skipped when a content dir is literally named `authors`, so a + * site that already publishes pages there keeps serving them. + */ +function hasAuthorRoutes(config: ChronicleConfig, version: VersionContext): boolean { + return !contentDirsFor(config, version).includes(AUTHORS_SEGMENT) +} + +/** + * Url of an author's page, or null when author routes are unavailable because a + * content dir owns the `authors` segment. + */ +export function authorPageUrl( + slug: string, + config: ChronicleConfig, + version: VersionContext, +): string | null { + if (!hasAuthorRoutes(config, version)) return null + return `${version.urlPrefix}/${AUTHORS_SEGMENT}/${encodeURIComponent(slug)}` +} + function isLandingEnabled( config: ChronicleConfig, version: VersionContext, @@ -66,6 +98,12 @@ export function resolveRoute( return { type: RouteType.ApiPage, version, slug } } + if (remainder[0] === AUTHORS_SEGMENT && hasAuthorRoutes(config, version)) { + const rest = remainder.slice(1) + if (rest.length === 0) return { type: RouteType.AuthorIndex, version } + if (rest.length === 1) return { type: RouteType.AuthorPage, version, authorSlug: rest[0] } + } + if (remainder.length === 0) { if (isLandingEnabled(config, version)) { return { type: RouteType.DocsIndex, version } diff --git a/packages/chronicle/src/pages/AuthorsPage.module.css b/packages/chronicle/src/pages/AuthorsPage.module.css new file mode 100644 index 00000000..56529db5 --- /dev/null +++ b/packages/chronicle/src/pages/AuthorsPage.module.css @@ -0,0 +1,185 @@ +.root { + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: var(--rs-space-13) var(--rs-space-7) var(--rs-space-9); + display: flex; + flex-direction: column; + gap: var(--rs-space-9); +} + +.title { + font-family: var(--rs-font-title); + font-size: var(--rs-font-size-t4); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-t4); + letter-spacing: var(--rs-letter-spacing-t1); + color: var(--rs-color-foreground-base-primary); + margin: 0; +} + +.empty { + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-regular); +} + +.authorList, +.pageList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--rs-space-3); +} + +.authorCard { + display: flex; + align-items: center; + gap: var(--rs-space-5); + padding: var(--rs-space-5); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-3); + text-decoration: none; + color: inherit; +} + +.authorCard:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.authorText { + display: flex; + flex-direction: column; + gap: var(--rs-space-1); + flex: 1; + min-width: 0; +} + +.authorName { + font-size: var(--rs-font-size-regular); + color: var(--rs-color-foreground-base-primary); +} + +.authorBio { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-tertiary); +} + +.count { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-tertiary); + white-space: nowrap; +} + +.profile { + display: flex; + flex-direction: column; + gap: var(--rs-space-4); +} + +.identity { + display: flex; + align-items: center; + gap: var(--rs-space-5); +} + +.bio { + margin: 0; + font-size: var(--rs-font-size-regular); + color: var(--rs-color-foreground-base-secondary); +} + +.links { + display: flex; + flex-wrap: wrap; + gap: var(--rs-space-5); +} + +.link { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-accent-primary); + text-decoration: none; +} + +.link:hover { + text-decoration: underline; +} + +.group { + display: flex; + flex-direction: column; + gap: var(--rs-space-5); +} + +.groupTitle { + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + letter-spacing: var(--rs-letter-spacing-t1); + text-transform: uppercase; + color: var(--rs-color-foreground-base-tertiary); + margin: 0; +} + +.pageCard { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + padding: var(--rs-space-5); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-3); + text-decoration: none; + color: inherit; +} + +.pageCard:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.pageTitle { + font-size: var(--rs-font-size-regular); + color: var(--rs-color-foreground-base-primary); +} + +.pageDescription { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-tertiary); +} + +.pageDate { + font-size: var(--rs-font-size-mini); + color: var(--rs-color-foreground-base-tertiary); +} + +/* + * Paper renders its pages on a sheet over the neutral backdrop (see + * themes/paper/Page.module.css .content). Author pages bypass that theme's Page, + * so they take the same treatment here. + */ +.root[data-page-theme='paper'] { + font-family: var(--paper-font-body); + background: var(--rs-color-background-base-primary); + border-left: 1px solid var(--rs-color-border-base-primary); + border-right: 1px solid var(--rs-color-border-base-primary); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.08), + 0 4px 12px rgba(0, 0, 0, 0.04); + min-height: calc(100vh - var(--rs-space-12)); + margin: var(--rs-space-12) auto var(--rs-space-9); + padding: var(--rs-space-13) var(--rs-space-9) var(--rs-space-9); +} + +.root[data-page-theme='paper'] .title, +.root[data-page-theme='paper'] .bio, +.root[data-page-theme='paper'] .authorName, +.root[data-page-theme='paper'] .pageTitle { + font-family: var(--paper-font-body); +} + +.root[data-page-theme='paper'] .groupTitle, +.root[data-page-theme='paper'] .count, +.root[data-page-theme='paper'] .pageDate, +.root[data-page-theme='paper'] .link { + font-family: var(--paper-font-mono); + letter-spacing: var(--rs-letter-spacing-t1); +} diff --git a/packages/chronicle/src/pages/AuthorsPage.tsx b/packages/chronicle/src/pages/AuthorsPage.tsx new file mode 100644 index 00000000..47b853ed --- /dev/null +++ b/packages/chronicle/src/pages/AuthorsPage.tsx @@ -0,0 +1,167 @@ +import { Avatar } from '@raystack/apsara'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router'; +import type { AuthorIndex, AuthorSummary } from '@/lib/author-index'; +import { authorInitials } from '@/lib/authors'; +import { authorsUrl } from '@/lib/data-urls'; +import { Head } from '@/lib/head'; +import { usePageContext } from '@/lib/page-context'; +import { NotFound } from '@/pages/NotFound'; +import styles from './AuthorsPage.module.css'; + +/** + * Loads the author index once per mount. The index covers every page in the site, + * so it is fetched rather than embedded in each page's data. + */ +function useAuthorIndex(): { index: AuthorIndex | null; isLoading: boolean } { + // Present when the server rendered this route, so the first paint needs no fetch. + const { authorIndex } = usePageContext(); + const [index, setIndex] = useState(authorIndex); + const [isLoading, setIsLoading] = useState(authorIndex === null); + + useEffect(() => { + if (authorIndex) return; + let cancelled = false; + fetch(authorsUrl()) + .then(res => (res.ok ? res.json() : { authors: [] })) + .then(data => { + if (!cancelled) setIndex(data as AuthorIndex); + }) + .catch(() => { + if (!cancelled) setIndex({ authors: [] }); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [authorIndex]); + + return { index, isLoading }; +} + +function AuthorAvatar({ author, size }: { author: AuthorSummary; size: 3 | 6 }) { + return ( + + ); +} + +function pageCountLabel(count: number): string { + return count === 1 ? '1 page' : `${count} pages`; +} + +/** `/authors` — every author found in the content. */ +export function AuthorsPage() { + const { config } = usePageContext(); + const { index, isLoading } = useAuthorIndex(); + const authors = index?.authors ?? []; + + return ( + <> + +
+

Authors

+ {isLoading ? null : authors.length === 0 ? ( +

No pages declare an author yet.

+ ) : ( +
    + {authors.map(author => ( +
  • + + + + {author.name} + {author.bio && {author.bio}} + + {pageCountLabel(author.pages.length)} + +
  • + ))} +
+ )} +
+ + ); +} + +interface AuthorDetailPageProps { + authorSlug: string; +} + +/** `/authors/` — one author's profile and everything they wrote. */ +export function AuthorDetailPage({ authorSlug }: AuthorDetailPageProps) { + const { config } = usePageContext(); + const { index, isLoading } = useAuthorIndex(); + + if (isLoading) return null; + + const author = index?.authors.find(entry => entry.slug === authorSlug); + if (!author) return ; + + const groups = new Map(); + for (const page of author.pages) { + const existing = groups.get(page.dirLabel); + if (existing) existing.push(page); + else groups.set(page.dirLabel, [page]); + } + + return ( + <> + +
+
+
+ +

{author.name}

+
+ {author.bio &&

{author.bio}

} + {(author.url || author.email) && ( +
+ {author.url && ( + + {author.url.replace(/^https?:\/\//, '')} + + )} + {author.email && ( + + {author.email} + + )} +
+ )} +
+ + {[...groups.entries()].map(([dirLabel, pages]) => ( +
+

{dirLabel}

+
    + {pages.map(page => ( +
  • + + {page.title} + {page.description && {page.description}} + {page.lastModified && ( + + )} + +
  • + ))} +
+
+ ))} +
+ + ); +} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 81fe6680..5636f205 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -1,6 +1,6 @@ import { Navigate } from 'react-router'; import { StatusCodes } from 'http-status-codes'; -import { parseAuthors } from '@/lib/authors'; +import { resolveAuthors } from '@/lib/authors'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; import { resolveDocsRedirect } from '@/lib/tree-utils'; @@ -27,7 +27,7 @@ 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); + const authors = resolveAuthors(page.frontmatter.authors, config); return ( <> diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 4531c072..fd8b7e14 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -4,7 +4,7 @@ import { Navigate, useLocation } from 'react-router'; import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider'; import { SearchDialog, SearchProvider } from '@/components/ui/search'; import { usePageContext } from '@/lib/page-context'; -import { resolveRoute, RouteType } from '@/lib/route-resolver'; +import { isAuthorRoute, resolveRoute, RouteType } from '@/lib/route-resolver'; import type { ChronicleConfig } from '@/types'; import { getThemeConfig } from '@/themes/registry'; import styles from './App.module.css'; @@ -14,6 +14,8 @@ const ApiPage = lazy(() => import('@/pages/ApiPage').then(m => ({ default: m.Api const DocsLayout = lazy(() => import('@/pages/DocsLayout').then(m => ({ default: m.DocsLayout }))); const DocsPage = lazy(() => import('@/pages/DocsPage').then(m => ({ default: m.DocsPage }))); const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage }))); +const AuthorsPage = lazy(() => import('@/pages/AuthorsPage').then(m => ({ default: m.AuthorsPage }))); +const AuthorDetailPage = lazy(() => import('@/pages/AuthorsPage').then(m => ({ default: m.AuthorDetailPage }))); export function App() { const { pathname } = useLocation(); @@ -30,6 +32,7 @@ export function App() { const apiSlug = route.type === RouteType.ApiPage ? route.slug : []; const docsSlug = route.type === RouteType.DocsPage ? route.slug : []; const isLanding = route.type === RouteType.DocsIndex; + const isAuthors = isAuthorRoute(route); return ( + ) : isAuthors ? ( + + {route.type === RouteType.AuthorPage ? ( + + ) : ( + + )} + ) : ( {isLanding ? : } diff --git a/packages/chronicle/src/server/api/authors.ts b/packages/chronicle/src/server/api/authors.ts new file mode 100644 index 00000000..751c9632 --- /dev/null +++ b/packages/chronicle/src/server/api/authors.ts @@ -0,0 +1,16 @@ +import { defineHandler } from 'nitro'; +import { buildAuthorIndex } from '@/lib/author-index'; +import { loadConfig } from '@/lib/config'; +import { extractFrontmatter, getPages } from '@/lib/source'; + +export default defineHandler(async () => { + const config = loadConfig(); + const pages = await getPages(); + + return Response.json( + buildAuthorIndex( + pages.map(page => ({ url: page.url, frontmatter: extractFrontmatter(page) })), + config, + ), + ); +}); diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index 73a89dc1..c5982a45 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -14,6 +14,7 @@ import { mdxComponents } from '@/components/mdx'; import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; import { prefetchSearchSuggestions, queryClient } from '@/lib/preload'; +import type { AuthorIndex } from '@/lib/author-index'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source'; import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types'; @@ -31,6 +32,8 @@ interface EmbeddedData { originalPath: string | null; prev: PageNavLink | null; next: PageNavLink | null; + /** Only embedded for /authors routes. */ + authorIndex?: AuthorIndex | null; } const defaultConfig: ChronicleConfig = { @@ -110,6 +113,7 @@ async function hydrate() { initialTree={tree} initialPage={page} initialApiSpecs={apiSpecs} + initialAuthorIndex={embedded?.authorIndex ?? null} initialVersion={version} loadMdx={loadMdxModule} > diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index 0d6bf5a0..dd6fd460 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -6,10 +6,11 @@ import { StaticRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { mdxComponents } from '@/components/mdx'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; +import { buildAuthorIndex } from '@/lib/author-index'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; -import { resolveRoute, RouteType } from '@/lib/route-resolver'; -import { getPage, getPageTree, isDraft, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages } from '@/lib/source'; +import { isAuthorRoute, resolveRoute, RouteType } from '@/lib/route-resolver'; +import { getPage, getPageTree, isDraft, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, getPageImages, getPages } from '@/lib/source'; import { getFirstApiUrl } from '@/lib/api-routes'; import { StatusCodes } from 'http-status-codes'; import { resolvePageAndSlug, resolveDocsRedirect, compactTree } from '@/lib/tree-utils'; @@ -29,6 +30,7 @@ import serverAssets from './entry-server?assets=ssr'; import docsLayoutAssets from '@/pages/DocsLayout?assets=client'; import docsPageAssets from '@/pages/DocsPage?assets=client'; import landingPageAssets from '@/pages/LandingPage?assets=client'; +import authorsPageAssets from '@/pages/AuthorsPage?assets=client'; import apiLayoutAssets from '@/pages/ApiLayout?assets=client'; import apiPageAssets from '@/pages/ApiPage?assets=client'; @@ -110,6 +112,7 @@ export default { }); } + const isAuthorsRoute = isAuthorRoute(route); const nav = page ? await getPageNav(resolvedSlug) : { prev: null, next: null }; const relativePath = page ? getRelativePath(page) : null; @@ -133,8 +136,16 @@ export default { } : null; + const authorIndex = isAuthorsRoute + ? buildAuthorIndex( + (await getPages()).map(p => ({ url: p.url, frontmatter: extractFrontmatter(p) })), + config, + ) + : null; + const embeddedData = { config, + ...(authorIndex && { authorIndex }), tree, slug: resolvedSlug, version: route.version, @@ -148,9 +159,11 @@ export default { const routeAssets = isApiRoute ? [apiLayoutAssets, apiPageAssets] - : route.type === RouteType.DocsIndex - ? [docsLayoutAssets, landingPageAssets] - : [docsLayoutAssets, docsPageAssets]; + : isAuthorsRoute + ? [docsLayoutAssets, authorsPageAssets] + : route.type === RouteType.DocsIndex + ? [docsLayoutAssets, landingPageAssets] + : [docsLayoutAssets, docsPageAssets]; // Dev needs serverAssets: client CSS is collected per-module there. In // production the client build is a single stylesheet (cssCodeSplit: // false) that already contains everything in import order — the SSR @@ -208,6 +221,7 @@ export default { initialTree={tree} initialPage={pageData} initialApiSpecs={apiSpecs} + initialAuthorIndex={authorIndex} initialVersion={route.version} loadMdx={async () => ({ content: null, toc: [] })} > diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index ca84daf6..42784b24 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -24,6 +24,7 @@ import { Breadcrumbs } from '@/components/ui/breadcrumbs'; import { getLandingEntries } from '@/lib/config'; import { getActiveContentDir } from '@/lib/navigation'; import { usePageContext } from '@/lib/page-context'; +import { isAuthorRoute, resolveRoute } from '@/lib/route-resolver'; import type { Node, Root } from 'fumadocs-core/page-tree'; import { NodeType } from '@/lib/tree-utils'; import type { ThemeLayoutProps } from '@/types'; @@ -75,6 +76,8 @@ export function Layout({ const scrollRef = useRef(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const isApiRoute = pathname === '/apis' || pathname.startsWith('/apis/'); + // Author pages aren't MDX, so they have no `.md` for the AI menu to hand over. + const isAuthorsRoute = isAuthorRoute(resolveRoute(pathname, config)); const isApiBase = (basePath: string) => pathname === basePath || pathname.startsWith(`${basePath}/`); const docNav = page ?? { prev: null, next: null }; @@ -286,7 +289,7 @@ export function Layout({ {isApiRoute && } {isApiRoute && } - + {!isAuthorsRoute && }
diff --git a/packages/chronicle/src/themes/paper/Page.module.css b/packages/chronicle/src/themes/paper/Page.module.css index 47603dfa..f3295321 100644 --- a/packages/chronicle/src/themes/paper/Page.module.css +++ b/packages/chronicle/src/themes/paper/Page.module.css @@ -81,6 +81,35 @@ margin: 0 auto; } +.articleMeta { + display: flex; + align-items: center; + justify-content: center; + gap: var(--rs-space-3); + font-family: var(--paper-font-mono); + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-regular); + line-height: 1.67; + letter-spacing: var(--rs-letter-spacing-t1); + color: var(--rs-color-foreground-base-tertiary); + margin-bottom: var(--rs-space-5); +} + +.metaDivider { + color: var(--rs-color-border-base-primary); +} + +/* Inherit the meta line's face rather than the byline's own sizing. */ +.articleMeta a { + font: inherit; + letter-spacing: inherit; + color: inherit; +} + +.articleMeta a:hover { + color: var(--rs-color-foreground-base-primary); +} + .readingTime { display: block; font-family: var(--paper-font-mono); @@ -260,8 +289,3 @@ .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 3b1b30a2..d04811cd 100644 --- a/packages/chronicle/src/themes/paper/Page.tsx +++ b/packages/chronicle/src/themes/paper/Page.tsx @@ -98,14 +98,21 @@ export function Page({ page, tree }: ThemePageProps) {
- {page.frontmatter._readingTime && ( - {page.frontmatter._readingTime}min Read + {(page.frontmatter._readingTime || page.frontmatter.authors?.length) && ( +
+ {page.frontmatter._readingTime && ( + {page.frontmatter._readingTime}min Read + )} + {page.frontmatter._readingTime && page.frontmatter.authors?.length ? ( + + ) : null} + +
)}

{page.frontmatter.title}

{page.frontmatter.description && (

{page.frontmatter.description}

)} -
diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 1c7b6136..cb5371c5 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -119,6 +119,15 @@ const versionSchema = z.object({ api: z.array(apiSchema).optional(), }) +/** Profile details for an author referenced by key from a page's frontmatter. */ +const authorSchema = z.object({ + name: z.string().min(1), + bio: z.string().optional(), + avatar: z.string().optional(), + url: z.string().optional(), + email: z.string().optional(), +}) + const allUnique = (items: T[], key: (item: T) => string): boolean => uniqBy(items, key).length === items.length @@ -148,6 +157,7 @@ export const chronicleConfigSchema = z logo: logoSchema.optional(), theme: themeSchema.optional(), navigation: navigationSchema.optional(), + authors: z.record(z.string().min(1), authorSchema).optional(), search: searchSchema.optional(), api: z.array(apiSchema).optional(), redirects: z.array(redirectSchema).optional(), diff --git a/packages/chronicle/src/types/content.ts b/packages/chronicle/src/types/content.ts index 541f51c1..90819b0f 100644 --- a/packages/chronicle/src/types/content.ts +++ b/packages/chronicle/src/types/content.ts @@ -4,10 +4,18 @@ 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. */ +/** + * A page author. Built either from an `authors` frontmatter string or, when that + * string names a key in the config registry, from that entry's full profile. + */ export interface Author { + /** Stable identifier: the registry key, or the name slugified. */ + slug: string name: string email?: string + bio?: string + avatar?: string + url?: string } export interface Frontmatter {