diff --git a/site/src/pages/docs/framework/[framework]/[...slug].astro b/site/src/pages/docs/framework/[framework]/[...slug].astro index 13af45e8..9e1a2ffe 100644 --- a/site/src/pages/docs/framework/[framework]/[...slug].astro +++ b/site/src/pages/docs/framework/[framework]/[...slug].astro @@ -15,7 +15,7 @@ import DocsLayout from '@/layouts/Docs.astro'; import type { SupportedFramework } from '@/types/docs'; import { SUPPORTED_FRAMEWORKS } from '@/types/docs'; import { filterSidebar, getAdjacentGuides, getAllGuideSlugs, getSectionsForGuide } from '@/utils/docs/sidebar'; -import { getDocTitle } from '@/utils/docs/title'; +import { getDocTitle, isCodeIdentifier } from '@/utils/docs/title'; import { createTechArticleSchema } from '@/utils/jsonLd/schemas'; export async function getStaticPaths() { @@ -91,9 +91,6 @@ const jsonLdSchema = createTechArticleSchema({ readingTime: remarkPluginFrontmatter.readingTimeMinutes, articleSection: sections.length > 0 ? sections.join(' > ') : undefined, }); - -// Leaving this in in case we change our minds -// const isCamelOrPascalCase = (str: string) => /[a-z][A-Z]/.test(str); --- @@ -148,8 +145,7 @@ const jsonLdSchema = createTechArticleSchema({ as="h1" class={clsx( "mt-0 mb-4 text-h25 @lg:text-h2", - // leaving this in in case we change our minds - // isCamelOrPascalCase(getDocTitle(doc, framework)) && "normal-case", + isCodeIdentifier(getDocTitle(doc, framework)) && "normal-case", )} > {getDocTitle(doc, framework)} diff --git a/site/src/utils/docs/__tests__/title.test.ts b/site/src/utils/docs/__tests__/title.test.ts index 82c00778..7eda35bb 100644 --- a/site/src/utils/docs/__tests__/title.test.ts +++ b/site/src/utils/docs/__tests__/title.test.ts @@ -1,6 +1,6 @@ import type { CollectionEntry } from 'astro:content'; import { describe, expect, it } from 'vitest'; -import { getDocTitle } from '../title'; +import { getDocTitle, isCodeIdentifier } from '../title'; describe('getDocTitle', () => { // Mock fixtures @@ -128,3 +128,40 @@ describe('getDocTitle', () => { }); }); }); + +describe('isCodeIdentifier', () => { + it('should detect PascalCase', () => { + expect(isCodeIdentifier('PlaybackRateButton')).toBe(true); + expect(isCodeIdentifier('MuteButton')).toBe(true); + expect(isCodeIdentifier('TimeDisplay')).toBe(true); + }); + + it('should detect camelCase', () => { + expect(isCodeIdentifier('usePlayerContext')).toBe(true); + expect(isCodeIdentifier('createPlayer')).toBe(true); + expect(isCodeIdentifier('playbackRate')).toBe(true); + }); + + it('should detect kebab-case', () => { + expect(isCodeIdentifier('playback-rate-button')).toBe(true); + expect(isCodeIdentifier('mute-button')).toBe(true); + expect(isCodeIdentifier('video-player')).toBe(true); + }); + + it('should not match natural-language titles', () => { + expect(isCodeIdentifier('Getting Started')).toBe(false); + expect(isCodeIdentifier('Installation')).toBe(false); + expect(isCodeIdentifier('Basic Concepts')).toBe(false); + expect(isCodeIdentifier('How to Use')).toBe(false); + }); + + it('should not match single lowercase words', () => { + expect(isCodeIdentifier('player')).toBe(false); + expect(isCodeIdentifier('installation')).toBe(false); + }); + + it('should not match single PascalCase words', () => { + expect(isCodeIdentifier('Player')).toBe(false); + expect(isCodeIdentifier('Slider')).toBe(false); + }); +}); diff --git a/site/src/utils/docs/title.ts b/site/src/utils/docs/title.ts index ba779db8..5a952dda 100644 --- a/site/src/utils/docs/title.ts +++ b/site/src/utils/docs/title.ts @@ -12,3 +12,14 @@ import type { SupportedFramework } from '@/types/docs'; export function getDocTitle(doc: CollectionEntry<'docs'>, framework: SupportedFramework): string { return doc.data.frameworkTitle?.[framework] ?? doc.data.title; } + +const CAMEL_OR_PASCAL_CASE = /[a-z][A-Z]/; +const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/; + +/** + * Detect whether a title is a code identifier that should preserve its casing + * instead of being uppercased. Matches PascalCase, camelCase, and kebab-case. + */ +export function isCodeIdentifier(str: string): boolean { + return CAMEL_OR_PASCAL_CASE.test(str) || KEBAB_CASE.test(str); +} diff --git a/site/src/utils/og/render-og-image.tsx b/site/src/utils/og/render-og-image.tsx index d80754ed..c74d12c0 100644 --- a/site/src/utils/og/render-og-image.tsx +++ b/site/src/utils/og/render-og-image.tsx @@ -1,6 +1,8 @@ import { Resvg } from '@resvg/resvg-js'; import satori from 'satori'; +import { isCodeIdentifier } from '@/utils/docs/title'; + // --------------------------------------------------------------------------- // Configuration — tune these values to adjust OG image appearance // --------------------------------------------------------------------------- @@ -121,8 +123,9 @@ export async function renderOgImage(options: { title?: string; size: OgSize }): const { width, height, topMargin } = SIZES[size]; const fontData = await loadFont(); - // Uppercase, then truncate if needed - let displayTitle = title?.toUpperCase(); + // Uppercase (unless the title is a code identifier like PlaybackRateButton), + // then truncate if needed + let displayTitle = title && isCodeIdentifier(title) ? title : title?.toUpperCase(); if (displayTitle && displayTitle.length > MAX_CHAR_LIMIT) { const truncated = displayTitle.slice(0, MAX_CHAR_LIMIT); const lastSpace = truncated.lastIndexOf(' ');