From b5177cc9d6c425c125e820f94f940b51658d3dd3 Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Fri, 10 Jul 2026 22:17:57 -0400 Subject: [PATCH 1/2] feat: chrome branding, groups sidebar, language switcher Add config-driven brandingConfig and chromeConfig so product instances can set logo, copyright, social links, and sidebar skin without forking components. Replace hardcoded right-rail attribution with SiteAttribution. - LanguageSwitcher + command-palette locale actions when multi-locale - sidebarVariant tree | groups (GroupsSidebar) - Exclude variants/ from public doc tree generation - Document instance vs framework and OpenAPI in FRAMEWORK.md --- FRAMEWORK.md | 32 +++- scripts/generate-doc-tree.mjs | 7 +- shared/documentation-config.js | 49 +++++- .../CommandPalette/useSearchLogic.ts | 71 ++++++++- src/components/chrome/LanguageSwitcher.tsx | 110 +++++++++++++ src/components/chrome/SiteAttribution.tsx | 83 ++++++++++ src/components/docs/DocumentationPage.tsx | 79 +++------ .../sidebar/GroupsSidebar.module.css | 105 ++++++++++++ src/components/sidebar/GroupsSidebar.tsx | 150 ++++++++++++++++++ src/components/sidebar/SidebarNav.tsx | 25 +++ src/constants/social.tsx | 16 +- src/lib/chrome.ts | 37 +++++ src/types/documentation-config.d.ts | 54 +++++++ 13 files changed, 749 insertions(+), 69 deletions(-) create mode 100644 src/components/chrome/LanguageSwitcher.tsx create mode 100644 src/components/chrome/SiteAttribution.tsx create mode 100644 src/components/sidebar/GroupsSidebar.module.css create mode 100644 src/components/sidebar/GroupsSidebar.tsx create mode 100644 src/components/sidebar/SidebarNav.tsx create mode 100644 src/lib/chrome.ts diff --git a/FRAMEWORK.md b/FRAMEWORK.md index d77445d..fc28f62 100644 --- a/FRAMEWORK.md +++ b/FRAMEWORK.md @@ -36,6 +36,9 @@ Generated output lands in `public/docs-content/` (and per-collection indexes). T | OpenAPI explorer (Scalar) | Yes | | Edit on GitHub footer | Yes | | Version / i18n scaffolding | Yes | +| Language switcher UI + palette actions | Yes — `chromeConfig.languageSwitcher` (auto when multi-locale) | +| Branding / attribution chrome | Yes — `brandingConfig` + `SiteAttribution` (no hardcoded credit) | +| Sidebar variants | Yes — `chromeConfig.sidebarVariant`: `tree` \| `groups` | | Tree sync | `npm run check:docs-tree` | | Starter CLI | `npm create papers` | | DESIGN.md design book (`/design`) | Yes — `designBookConfig` + `npm run generate:design-book` | @@ -47,10 +50,30 @@ In `shared/documentation-config.js`: ```js export const versionConfig = { current: '1.0', versions: ['1.0'], enabled: true }; -export const i18nConfig = { enabled: true, defaultLocale: 'en', locales: ['en', 'fr'] }; +export const i18nConfig = { + enabled: true, + defaultLocale: 'en', + locales: ['en', 'fr'], + labels: { en: 'EN', fr: 'FR' }, +}; +export const chromeConfig = { + languageSwitcher: { enabled: 'auto', placement: ['sidebar', 'command-palette'] }, + sidebarVariant: 'groups', // or 'tree' +}; +export const brandingConfig = { + productName: 'papers', + logo: null, // or { src: '/images/logo.png', alt: '…' } + copyright: { holder: 'Your Org' }, + attribution: null, // or { text: 'Built with papers', href: '…' } + social: [{ name: 'GitHub', href: 'https://github.com/…', icon: 'mingcute:github-fill' }], +}; ``` -Add variant content under `src/docs/content/variants/`. +Add locale overlays under `src/docs/content/variants/locales/{locale}/…` (same paths as default). The nav tree **excludes** `variants/` — locales are selected via chrome / palette, not as a separate folder in the sidebar. + +## OpenAPI + +Already shipped: set `openapiConfig.enabled` and point `specs[].url` at YAML/JSON under `public/` (or remote). Interactive Scalar explorer at `/docs/{pagePath}`. ## Lazy feature invariants @@ -59,6 +82,11 @@ Add variant content under `src/docs/content/variants/`. Enforced by `src/framework/lazyFeatureBoundaries.ts` and architecture tests. +## Instance vs framework + +- **Framework (this repo):** routing, chrome, OpenAPI viewer, generators, sample template docs. +- **Product instances** (e.g. companion docs apps): own Markdown content + `brandingConfig` / i18n locales only. Do not land product copy in papers. + ## Near-term improvements 1. SSR prerender for first paint diff --git a/scripts/generate-doc-tree.mjs b/scripts/generate-doc-tree.mjs index 285ca6d..dbf915c 100644 --- a/scripts/generate-doc-tree.mjs +++ b/scripts/generate-doc-tree.mjs @@ -149,7 +149,12 @@ function isExcludedFile(relativePath) { } function isDirectoryExcluded(relativeDir) { - return EXCLUDED_PUBLIC_DOC_PATHS.has(toPosix(relativeDir)); + const posix = toPosix(relativeDir); + if (EXCLUDED_PUBLIC_DOC_PATHS.has(posix)) return true; + // Locale/version overlays are not separate nav trees — selected via chrome/i18n only + if (posix === 'variants' || posix.startsWith('variants/')) return true; + if (posix === 'generated' || posix.startsWith('generated/')) return true; + return false; } function titleizeSegment(segment) { diff --git a/shared/documentation-config.js b/shared/documentation-config.js index 2bbf2f0..2cd7e16 100644 --- a/shared/documentation-config.js +++ b/shared/documentation-config.js @@ -41,11 +41,58 @@ export const versionConfig = { enabled: false, }; -/** @type {{ enabled: boolean, defaultLocale: string, locales: string[] }} */ +/** @type {{ enabled: boolean, defaultLocale: string, locales: string[], labels?: Record }} */ export const i18nConfig = { enabled: false, defaultLocale: 'en', locales: ['en'], + labels: { + en: 'EN', + }, +}; + +/** + * Site chrome — language switcher placement + sidebar skin. + * @type {{ + * languageSwitcher: { enabled: 'auto' | boolean, placement: Array<'sidebar' | 'command-palette'> }, + * sidebarVariant: 'tree' | 'groups', + * }} + */ +export const chromeConfig = { + languageSwitcher: { + enabled: 'auto', + placement: ['sidebar', 'command-palette'], + }, + /** groups = section headers + page list; tree = classic file tree */ + sidebarVariant: 'tree', +}; + +/** + * Instance branding — product mark, copyright, optional attribution, social links. + * Set attribution to null to hide the credit line. + * @type {{ + * productName: string, + * logo?: { src: string, alt: string } | null, + * copyright: { holder: string, year?: number, notice?: string }, + * attribution: { text: string, href?: string } | null, + * social: Array<{ name: string, href: string, icon: string }>, + * }} + */ +export const brandingConfig = { + productName: 'papers', + logo: null, + copyright: { + holder: 'papers', + notice: 'MIT License.', + }, + attribution: null, + social: [ + { + name: 'GitHub', + href: 'https://github.com/thomasjvu/papers', + icon: 'mingcute:github-fill', + }, + ], }; /** @type {import('./documentation-config.js').OpenApiConfig} */ diff --git a/src/components/CommandPalette/useSearchLogic.ts b/src/components/CommandPalette/useSearchLogic.ts index 37b29b1..293ae73 100644 --- a/src/components/CommandPalette/useSearchLogic.ts +++ b/src/components/CommandPalette/useSearchLogic.ts @@ -1,13 +1,22 @@ import { Icon } from '@iconify/react'; import { useMemo, createElement, useState, useEffect, useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { documentationTree } from '../../data/documentation'; import { useDebounce } from '../../hooks/useDebounce'; import { usePagefind } from '../../hooks/usePagefind'; import { useTheme } from '../../providers/ThemeProvider'; import type { FileItem } from '../../types/documentation'; -import { buildCanonicalDocsPath } from '../../../shared/docsRouting.js'; +import { + buildCanonicalDocsPath, + parseDocsRoutePath, +} from '../../../shared/docsRouting.js'; import { homepageConfig } from '../../../shared/documentation-config.js'; +import { + getLocaleLabel, + i18nConfig, + languageSwitcherShows, +} from '../../lib/chrome'; import type { SearchResultType } from './SearchResult'; import { combineSearchResults } from './searchUtils'; @@ -23,6 +32,11 @@ const FAQ_ITEMS = [ answer: 'Use Cmd + K (Mac) or Ctrl + K (Windows/Linux) to open this command palette, then type your search query.', }, + { + question: 'How do I change the language?', + answer: + 'When multiple locales are enabled in i18nConfig, use the language control in the sidebar or open this command palette and type "language" or a locale label to switch while staying on the same page.', + }, { question: 'How can I contribute to the documentation?', answer: @@ -36,7 +50,7 @@ const FAQ_ITEMS = [ { question: 'How do I navigate between pages?', answer: - 'Use Shift + Left/Right Arrow on doc pages, the file tree on the left sidebar, the interactive mindmap on the right, or the Previous/Next buttons at the bottom of each page. You can also use this command palette to quickly jump to any page.', + 'Use Shift + Left/Right Arrow on doc pages, the sidebar navigation, the interactive mindmap on the right, or the Previous/Next buttons at the bottom of each page. You can also use this command palette to quickly jump to any page.', }, ]; @@ -116,6 +130,15 @@ export const useSearchLogic = (query: string) => { }; }, [debouncedQuery, pagefindAvailable, pagefindSearch]); + const navigate = useNavigate(); + const location = useLocation(); + const routeContext = useMemo(() => { + const slug = location.pathname.startsWith('/docs') + ? location.pathname.replace(/^\/docs\/?/, '') + : location.pathname.replace(/^\/+|\/+$/g, ''); + return parseDocsRoutePath(slug); + }, [location.pathname]); + const preferenceCommands = useMemo(() => { const commands: SearchResultType[] = [ { @@ -149,8 +172,45 @@ export const useSearchLogic = (query: string) => { }); }); + if (languageSwitcherShows('command-palette')) { + const activeLocale = routeContext.activeLocale || i18nConfig.defaultLocale; + const docPath = routeContext.docPath || 'index'; + for (const locale of i18nConfig.locales || []) { + if (locale === activeLocale) continue; + const label = getLocaleLabel(locale); + commands.push({ + title: `Switch to ${label}`, + path: `locale:${locale}`, + type: 'action', + description: `Change documentation language to ${label}`, + action: () => { + navigate( + buildCanonicalDocsPath(docPath, { + version: routeContext.activeVersion, + locale, + }), + ); + }, + icon: createElement(Icon, { + icon: 'mingcute:translate-2-line', + className: 'w-5 h-5', + }), + }); + } + } + return commands; - }, [isDarkMode, prefersReducedMotion, setFontFamily, toggleDarkMode, toggleReducedMotion]); + }, [ + isDarkMode, + prefersReducedMotion, + setFontFamily, + toggleDarkMode, + toggleReducedMotion, + navigate, + routeContext.activeLocale, + routeContext.activeVersion, + routeContext.docPath, + ]); const searchIndex = useMemo(() => { const results: SearchResultType[] = [...preferenceCommands]; @@ -181,7 +241,10 @@ export const useSearchLogic = (query: string) => { if (item.type === 'file') { results.push({ title: item.name.replace(/\.md$/, ''), - path: buildCanonicalDocsPath(item.path), + path: buildCanonicalDocsPath(item.path, { + version: routeContext.activeVersion, + locale: routeContext.activeLocale, + }), type: 'page', description: parentPath, icon: createElement(Icon, { icon: 'mingcute:file-line', className: 'w-5 h-5' }), diff --git a/src/components/chrome/LanguageSwitcher.tsx b/src/components/chrome/LanguageSwitcher.tsx new file mode 100644 index 0000000..0a31dc8 --- /dev/null +++ b/src/components/chrome/LanguageSwitcher.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +import { buildCanonicalDocsPath, parseDocsRoutePath } from '../../../shared/docsRouting.js'; +import { DEFAULT_DOCUMENT_PATH } from '../../lib/navigation'; +import { + getLocaleLabel, + i18nConfig, + languageSwitcherShows, +} from '../../lib/chrome'; + +type LanguageSwitcherProps = { + variant?: 'pills' | 'menu'; + className?: string; + /** Current logical doc path (without locale prefix). */ + docPath?: string; +}; + +/** + * Papers language switcher — only renders when i18n is multi-locale and chrome allows it. + */ +export default function LanguageSwitcher({ + variant = 'pills', + className = '', + docPath, +}: LanguageSwitcherProps) { + const location = useLocation(); + const docsRouteSlug = location.pathname.startsWith('/docs') + ? location.pathname.replace(/^\/docs\/?/, '') + : location.pathname.replace(/^\/+|\/+$/g, ''); + const routeContext = useMemo(() => parseDocsRoutePath(docsRouteSlug), [docsRouteSlug]); + const activeLocale = routeContext.activeLocale || i18nConfig.defaultLocale; + const logicalPath = docPath || routeContext.docPath || DEFAULT_DOCUMENT_PATH; + + useEffect(() => { + if (typeof document === 'undefined' || !activeLocale) return; + document.documentElement.lang = activeLocale; + }, [activeLocale]); + + if (!languageSwitcherShows('sidebar')) { + return null; + } + + const locales = i18nConfig.locales || []; + + if (variant === 'menu') { + return ( + + ); + } + + return ( +
+ {locales.map((code) => { + const active = activeLocale === code; + const href = buildCanonicalDocsPath(logicalPath, { + version: routeContext.activeVersion, + locale: code, + }); + return ( + + {getLocaleLabel(code)} + + ); + })} +
+ ); +} diff --git a/src/components/chrome/SiteAttribution.tsx b/src/components/chrome/SiteAttribution.tsx new file mode 100644 index 0000000..b524211 --- /dev/null +++ b/src/components/chrome/SiteAttribution.tsx @@ -0,0 +1,83 @@ +import { Icon } from '@iconify/react'; + +import { brandingConfig, getCopyrightLine } from '../../lib/chrome'; + +type SiteAttributionProps = { + className?: string; + density?: 'compact' | 'full'; +}; + +/** + * Configurable footer chrome — social icons, copyright, optional attribution. + * Instances set brandingConfig; leave attribution null to hide a credit line. + */ +export default function SiteAttribution({ + className = '', + density = 'compact', +}: SiteAttributionProps) { + const social = brandingConfig.social ?? []; + const attribution = brandingConfig.attribution; + const copyright = getCopyrightLine(); + + return ( +
+ {social.length > 0 && ( +
+ {social.map((link) => ( + + + + ))} +
+ )} + +
+

+ {copyright} +

+ {attribution ? ( +

+ {attribution.href ? ( + + {attribution.text} + + ) : ( + attribution.text + )} +

+ ) : null} +
+
+ ); +} diff --git a/src/components/docs/DocumentationPage.tsx b/src/components/docs/DocumentationPage.tsx index 726683a..d154fcf 100644 --- a/src/components/docs/DocumentationPage.tsx +++ b/src/components/docs/DocumentationPage.tsx @@ -14,12 +14,13 @@ import { prefetchDocument } from '../../lib/content'; import ContentRenderer from '../ContentRenderer'; import DocsVariantSelector from '../DocsVariantSelector'; import DocumentationGraph from '../DocumentationGraph/OptimizedDocumentationGraph'; -import FileTree from '../FileTree'; +import LanguageSwitcher from '../chrome/LanguageSwitcher'; +import SiteAttribution from '../chrome/SiteAttribution'; import fileTreeStyles from '../FileTree.module.css'; +import SidebarNav from '../sidebar/SidebarNav'; import SettingsMenu from '../SettingsMenu'; -import { socialLinks } from '../../constants/social'; -import { UI_CLASSES } from '../../constants/ui'; import { getMobileTogglePositionClasses, uiConfig } from '../../config/ui'; +import { brandingConfig } from '../../lib/chrome'; import TableOfContents from './TableOfContents'; import { buildCanonicalDocsPath, parseDocsRoutePath } from '../../../shared/docsRouting.js'; @@ -43,49 +44,6 @@ function getInitialRightSidebarState(): boolean { return saved === 'true'; } -function RightRailFooter() { - return ( -
-
- {socialLinks.map((link) => ( - -
{link.icon}
-
- ))} -
- -
-

- Design by{' '} - - Ultima - -

-
-
- ); -} - const DocumentationPage = React.memo( ({ initialContent, @@ -375,13 +333,27 @@ const DocumentationPage = React.memo( fontFamily: 'var(--mono-font)', }} > - P + {brandingConfig.logo?.src ? ( + + ) : ( + (brandingConfig.productName || siteName).charAt(0).toUpperCase() + )} - {siteName} + {brandingConfig.productName || siteName} @@ -416,13 +388,14 @@ const DocumentationPage = React.memo( -
+
+
- @@ -651,7 +624,7 @@ const DocumentationPage = React.memo(
- +
diff --git a/src/components/sidebar/GroupsSidebar.module.css b/src/components/sidebar/GroupsSidebar.module.css new file mode 100644 index 0000000..7113ca9 --- /dev/null +++ b/src/components/sidebar/GroupsSidebar.module.css @@ -0,0 +1,105 @@ +.nav { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 0.15rem 0.15rem 0.5rem; +} + +.group { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.groupHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.35rem 0.5rem; + border: none; + border-radius: 0.4rem; + background: transparent; + cursor: pointer; + text-align: left; + color: var(--muted-color); + font-family: var(--mono-font); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + transition: color 0.12s ease, background-color 0.12s ease; +} + +.groupHeader:hover { + color: var(--text-color); + background: rgba(var(--primary-color-rgb), 0.06); +} + +.groupTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex-shrink: 0; + opacity: 0.7; +} + +.pages { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding-left: 0.15rem; +} + +.link { + display: block; + padding: 0.42rem 0.65rem; + border-radius: 0.45rem; + color: var(--text-color); + font-family: var(--body-font); + font-size: 0.84rem; + font-weight: 500; + line-height: 1.35; + text-decoration: none; + transition: background-color 0.12s ease, color 0.12s ease; +} + +.link:hover { + background: rgba(var(--primary-color-rgb), 0.08); + color: var(--text-color); +} + +.linkActive { + background: rgba(var(--primary-color-rgb), 0.14); + color: var(--text-color); + font-weight: 600; +} + +.subgroup { + margin-top: 0.35rem; + margin-left: 0.35rem; + padding-left: 0.45rem; + border-left: 1px solid var(--border-unified); +} + +.subgroupTitle { + margin: 0.15rem 0 0.2rem; + padding: 0 0.5rem; + color: var(--muted-color); + font-family: var(--mono-font); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.rootLinks { + display: flex; + flex-direction: column; + gap: 0.1rem; +} diff --git a/src/components/sidebar/GroupsSidebar.tsx b/src/components/sidebar/GroupsSidebar.tsx new file mode 100644 index 0000000..1cedd2c --- /dev/null +++ b/src/components/sidebar/GroupsSidebar.tsx @@ -0,0 +1,150 @@ +import { Icon } from '@iconify/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +import type { FileItem } from '../../types/documentation'; +import { buildCanonicalDocsPath, parseDocsRoutePath } from '../../../shared/docsRouting.js'; +import safeLocalStorage from '../../utils/storage'; + +import styles from './GroupsSidebar.module.css'; + +type GroupsSidebarProps = { + items: FileItem[]; + onSelect: (item: FileItem) => void; + onPrefetch?: (path: string) => void; + currentPath?: string; + defaultOpenAll?: boolean; +}; + +const STORAGE_KEY = 'papers_groups_sidebar_expanded'; + +function pageLabel(item: FileItem): string { + return item.name.replace(/\.(md|mdx)$/i, ''); +} + +function collectExpandedDefaults(items: FileItem[], openAll: boolean): Record { + const map: Record = {}; + for (const item of items) { + if (item.type === 'directory') { + map[item.path] = openAll || item.expanded !== false; + if (item.children) { + Object.assign(map, collectExpandedDefaults(item.children, openAll)); + } + } + } + return map; +} + +function readStoredExpanded(): Record { + try { + const raw = safeLocalStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +export default function GroupsSidebar({ + items, + onSelect, + onPrefetch, + currentPath, + defaultOpenAll = true, +}: GroupsSidebarProps) { + const location = useLocation(); + const docsRouteSlug = location.pathname.startsWith('/docs') + ? location.pathname.replace(/^\/docs\/?/, '') + : location.pathname.replace(/^\/+|\/+$/g, ''); + const routeContext = useMemo(() => parseDocsRoutePath(docsRouteSlug), [docsRouteSlug]); + + const rootFiles = useMemo(() => items.filter((item) => item.type === 'file'), [items]); + const groups = useMemo(() => items.filter((item) => item.type === 'directory'), [items]); + + const [expanded, setExpanded] = useState>(() => ({ + ...collectExpandedDefaults(items, defaultOpenAll), + ...readStoredExpanded(), + })); + + useEffect(() => { + safeLocalStorage.setItem(STORAGE_KEY, JSON.stringify(expanded)); + }, [expanded]); + + const toggleGroup = useCallback((path: string) => { + setExpanded((prev) => ({ ...prev, [path]: !prev[path] })); + }, []); + + const hrefFor = useCallback( + (docPath: string) => + buildCanonicalDocsPath(docPath, { + version: routeContext.activeVersion, + locale: routeContext.activeLocale, + }), + [routeContext.activeLocale, routeContext.activeVersion], + ); + + const renderFileLink = (item: FileItem) => { + const active = currentPath === item.path; + return ( + onSelect(item)} + onMouseEnter={() => onPrefetch?.(item.path)} + onFocus={() => onPrefetch?.(item.path)} + aria-current={active ? 'page' : undefined} + > + {pageLabel(item)} + + ); + }; + + const renderGroupBody = (item: FileItem, depth = 0) => { + if (!item.children?.length) return null; + const files = item.children.filter((child) => child.type === 'file'); + const dirs = item.children.filter((child) => child.type === 'directory'); + + return ( +
+ {files.map((file) => renderFileLink(file))} + {dirs.map((dir) => ( +
+

{pageLabel(dir)}

+ {renderGroupBody(dir, depth + 1)} +
+ ))} +
+ ); + }; + + return ( + + ); +} diff --git a/src/components/sidebar/SidebarNav.tsx b/src/components/sidebar/SidebarNav.tsx new file mode 100644 index 0000000..506754d --- /dev/null +++ b/src/components/sidebar/SidebarNav.tsx @@ -0,0 +1,25 @@ +import FileTree from '../FileTree'; +import type { FileItem } from '../../types/documentation'; +import { getSidebarVariant } from '../../lib/chrome'; + +import GroupsSidebar from './GroupsSidebar'; + +type SidebarNavProps = { + items: FileItem[]; + onSelect: (item: FileItem) => void; + onPrefetch?: (path: string) => void; + currentPath?: string; + defaultOpenAll?: boolean; +}; + +/** + * Papers sidebar router — chromeConfig.sidebarVariant: + * - tree: classic file-tree + * - groups: section headers + flat page list + */ +export default function SidebarNav(props: SidebarNavProps) { + if (getSidebarVariant() === 'groups') { + return ; + } + return ; +} diff --git a/src/constants/social.tsx b/src/constants/social.tsx index 8b75b97..6fb83f5 100644 --- a/src/constants/social.tsx +++ b/src/constants/social.tsx @@ -1,11 +1,11 @@ import { Icon } from '@iconify/react'; +import { createElement } from 'react'; -const githubHref = import.meta.env.VITE_GITHUB_URL || 'https://github.com/phantasy-bot/agent'; +import { brandingConfig } from '@app-shared/documentation-config.js'; -export const socialLinks = [ - { - name: 'GitHub', - href: githubHref, - icon: , - }, -]; +/** Derived from brandingConfig for Navigation and legacy consumers. */ +export const socialLinks = (brandingConfig.social ?? []).map((link) => ({ + name: link.name, + href: link.href, + icon: createElement(Icon, { icon: link.icon, width: 24, height: 24 }), +})); diff --git a/src/lib/chrome.ts b/src/lib/chrome.ts new file mode 100644 index 0000000..9057b95 --- /dev/null +++ b/src/lib/chrome.ts @@ -0,0 +1,37 @@ +import { + brandingConfig, + chromeConfig, + i18nConfig, +} from '@app-shared/documentation-config.js'; + +export function isLanguageSwitcherEnabled(): boolean { + const flag = chromeConfig.languageSwitcher?.enabled ?? 'auto'; + if (flag === true) return true; + if (flag === false) return false; + return Boolean(i18nConfig.enabled && (i18nConfig.locales?.length ?? 0) > 1); +} + +export function languageSwitcherPlacement(): Array<'sidebar' | 'command-palette'> { + return chromeConfig.languageSwitcher?.placement ?? ['sidebar', 'command-palette']; +} + +export function languageSwitcherShows(placement: 'sidebar' | 'command-palette'): boolean { + return isLanguageSwitcherEnabled() && languageSwitcherPlacement().includes(placement); +} + +export function getLocaleLabel(locale: string): string { + return i18nConfig.labels?.[locale] || locale.toUpperCase(); +} + +export function getSidebarVariant(): 'tree' | 'groups' { + return chromeConfig.sidebarVariant === 'groups' ? 'groups' : 'tree'; +} + +export function getCopyrightLine(): string { + const year = brandingConfig.copyright?.year ?? new Date().getFullYear(); + const holder = brandingConfig.copyright?.holder || brandingConfig.productName || 'Docs'; + const notice = brandingConfig.copyright?.notice; + return notice ? `© ${year} ${holder}. ${notice}` : `© ${year} ${holder}`; +} + +export { brandingConfig, chromeConfig, i18nConfig }; diff --git a/src/types/documentation-config.d.ts b/src/types/documentation-config.d.ts index 8a6fc33..e85d0a1 100644 --- a/src/types/documentation-config.d.ts +++ b/src/types/documentation-config.d.ts @@ -64,6 +64,31 @@ declare module '@app-shared/documentation-config.js' { enabled: boolean; defaultLocale: string; locales: string[]; + labels?: Record; + } + + export interface ChromeConfig { + languageSwitcher: { + enabled: 'auto' | boolean; + placement: Array<'sidebar' | 'command-palette'>; + }; + sidebarVariant: 'tree' | 'groups'; + } + + export interface BrandingConfig { + productName: string; + logo?: { src: string; alt: string } | null; + copyright: { + holder: string; + year?: number; + notice?: string; + }; + attribution: { text: string; href?: string } | null; + social: Array<{ + name: string; + href: string; + icon: string; + }>; } export interface OpenApiSpecConfig { @@ -93,6 +118,8 @@ declare module '@app-shared/documentation-config.js' { export const documentationTree: FileItem[]; export const versionConfig: VersionConfig; export const i18nConfig: I18nConfig; + export const chromeConfig: ChromeConfig; + export const brandingConfig: BrandingConfig; export const openapiConfig: OpenApiConfig; export const designBookConfig: DesignBookConfig; export const frameworkDocPaths: string[]; @@ -165,6 +192,31 @@ declare module '*/shared/documentation-config.js' { enabled: boolean; defaultLocale: string; locales: string[]; + labels?: Record; + } + + export interface ChromeConfig { + languageSwitcher: { + enabled: 'auto' | boolean; + placement: Array<'sidebar' | 'command-palette'>; + }; + sidebarVariant: 'tree' | 'groups'; + } + + export interface BrandingConfig { + productName: string; + logo?: { src: string; alt: string } | null; + copyright: { + holder: string; + year?: number; + notice?: string; + }; + attribution: { text: string; href?: string } | null; + social: Array<{ + name: string; + href: string; + icon: string; + }>; } export interface OpenApiSpecConfig { @@ -194,6 +246,8 @@ declare module '*/shared/documentation-config.js' { export const documentationTree: FileItem[]; export const versionConfig: VersionConfig; export const i18nConfig: I18nConfig; + export const chromeConfig: ChromeConfig; + export const brandingConfig: BrandingConfig; export const openapiConfig: OpenApiConfig; export const designBookConfig: DesignBookConfig; export const frameworkDocPaths: string[]; From ec54f2c95f5b8af7cac5f725aabae6805cc51b8a Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Fri, 10 Jul 2026 23:21:29 -0400 Subject: [PATCH 2/2] fix(docs): restore bottom prev/next and GitHub edit/source chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure page bottom into prev/next then edit·issue·source in document flow. Remove absolute/faded footer and the mobile rule that hid edit links. Add architecture regression coverage for ContentRenderer bottom chrome. --- src/components/ContentRenderer.tsx | 10 +- src/components/DocPageFooter.tsx | 11 +- src/docs/content/user-guide/basic-usage.md | 9 +- src/globals.css | 158 +++++++++++++++------ test/architecture.test.ts | 36 +++++ 5 files changed, 168 insertions(+), 56 deletions(-) diff --git a/src/components/ContentRenderer.tsx b/src/components/ContentRenderer.tsx index 7eaa106..4e8e0bd 100644 --- a/src/components/ContentRenderer.tsx +++ b/src/components/ContentRenderer.tsx @@ -123,7 +123,8 @@ const ContentRenderer = memo(function ContentRenderer({ className="doc-page-bottom doc-page-bottom--anchored mt-auto w-full max-w-4xl mx-auto border-t pt-4 pb-6 px-6 md:pb-8 md:px-8 lg:pb-12 lg:px-12" style={{ borderColor: 'var(--border-unified)' }} > -
+ {/* Row 1: adjacent docs — always in document flow under the article */} +
+ + + {/* Row 2: edit / issue / source (GitHub) — in flow, not absolute-positioned */} + diff --git a/src/components/DocPageFooter.tsx b/src/components/DocPageFooter.tsx index be57b3c..7daa7b1 100644 --- a/src/components/DocPageFooter.tsx +++ b/src/components/DocPageFooter.tsx @@ -3,8 +3,12 @@ type DocPageFooterProps = { sourcePath?: string; }; +/** + * Bottom-of-page GitHub affordances for every doc. + * Renders nothing when VITE_GITHUB_URL is unset (links would be broken). + */ export default function DocPageFooter({ path, sourcePath }: DocPageFooterProps) { - const githubUrl = import.meta.env.VITE_GITHUB_URL; + const githubUrl = import.meta.env.VITE_GITHUB_URL?.trim(); if (!githubUrl) { return null; } @@ -19,12 +23,13 @@ export default function DocPageFooter({ path, sourcePath }: DocPageFooterProps) return (