diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-22 14:58:26 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-22 14:59:09 +0300 |
| commit | c234f221f9be698bc0501c60459e1ae8e67df6c3 (patch) | |
| tree | 944b4dec10fada8d93456dc5a85e6bfdb36a8b8b /website/src | |
| parent | ef85fa0c7ebb48bb59fe7593af5a67f3e02ff3d4 (diff) | |
feat(docs): publish versioned release documentation
Diffstat (limited to 'website/src')
| -rw-r--r-- | website/src/components/Banner.astro | 29 | ||||
| -rw-r--r-- | website/src/components/ConfigReference.astro | 13 | ||||
| -rw-r--r-- | website/src/components/DocsVersionSelect.astro | 84 | ||||
| -rw-r--r-- | website/src/components/Head.astro | 7 | ||||
| -rw-r--r-- | website/src/components/Header.astro | 58 | ||||
| -rw-r--r-- | website/src/components/Search.astro | 28 | ||||
| -rw-r--r-- | website/src/components/Sidebar.astro | 67 | ||||
| -rw-r--r-- | website/src/components/SiteTitle.astro | 7 | ||||
| -rw-r--r-- | website/src/docs-path.test.ts | 49 | ||||
| -rw-r--r-- | website/src/docs-path.ts | 83 |
10 files changed, 287 insertions, 138 deletions
diff --git a/website/src/components/Banner.astro b/website/src/components/Banner.astro index 8a4bad22..01098659 100644 --- a/website/src/components/Banner.astro +++ b/website/src/components/Banner.astro @@ -1,4 +1,7 @@ --- +import versions from '../data/docs-versions.json'; +import { docsRoute, docsTargetHref } from '../docs-path'; + const { entry, locale, isFallback } = Astro.locals.starlightRoute; const { banner } = entry.data; @@ -8,9 +11,24 @@ const translationNotices = { 'zh-cn': `本页面的翻译由 LLM 生成。如果你发现翻译有误,请<a href="${issuesUrl}">在 GitHub 上提交 issue</a> 告诉我们。`, }; const translationNotice = locale && !isFallback ? translationNotices[locale] : undefined; +const docs = docsRoute(Astro.url.pathname); +const archivedVersion = + docs.target !== 'stable' && docs.target !== 'preview' && docs.target !== versions.current + ? docs.target + : undefined; +const latestHref = docsTargetHref(docs, 'stable', versions.scopes); +const archivedNotices = { + root: `This page documents Herdr ${archivedVersion}. <a href="${latestHref}">Switch to the latest release.</a>`, + ja: `このページは Herdr ${archivedVersion} のドキュメントです。<a href="${latestHref}">最新版に切り替えてください。</a>`, + 'zh-cn': `本页面是 Herdr ${archivedVersion} 的文档。<a href="${latestHref}">切换到最新版本。</a>`, +}; +const archivedNotice = archivedVersion ? archivedNotices[locale ?? 'root'] : undefined; --- {banner && <div class="sl-banner" data-pagefind-ignore set:html={banner.content} />} +{archivedNotice && ( + <div class="version-notice" data-pagefind-ignore set:html={archivedNotice} /> +)} {translationNotice && ( <div class="translation-notice" data-pagefind-ignore set:html={translationNotice} /> )} @@ -31,7 +49,8 @@ const translationNotice = locale && !isFallback ? translationNotices[locale] : u color: var(--__sl-banner-text); } - .translation-notice { + .translation-notice, + .version-notice { padding: 0.5rem var(--sl-nav-pad-x); background-color: var(--sl-color-bg-nav); border-bottom: 1px solid var(--sl-color-hairline); @@ -41,8 +60,16 @@ const translationNotice = locale && !isFallback ? translationNotices[locale] : u text-align: center; text-wrap: balance; } + .version-notice { + background-color: var(--sl-color-orange-low); + border-bottom-color: var(--sl-color-orange); + color: var(--sl-color-orange-high); + } .translation-notice :global(a) { color: var(--sl-color-text-accent); } + .version-notice :global(a) { + color: var(--sl-color-orange-high); + } } </style> diff --git a/website/src/components/ConfigReference.astro b/website/src/components/ConfigReference.astro index d951f1cc..33894993 100644 --- a/website/src/components/ConfigReference.astro +++ b/website/src/components/ConfigReference.astro @@ -1,6 +1,8 @@ --- import stableReference from '../data/config-reference.json'; import previewReference from '../data/config-reference-preview.json'; +import versionReferences from '../data/config-reference-versions.json'; +import { docsVersion } from '../docs-path'; interface Props { placeholder?: string; @@ -14,9 +16,16 @@ const { keyCountLabel = 'keys', } = Astro.props; -const reference = Astro.url.pathname.includes('/docs/preview/') +const version = docsVersion(Astro.url.pathname); +const archivedReferences = versionReferences as Record<string, typeof stableReference>; +const reference = version === 'preview' ? previewReference - : stableReference; + : version === 'stable' + ? stableReference + : archivedReferences[version]; +if (!reference) { + throw new Error(`documentation version ${version} has no archived configuration reference`); +} const totalKeys = reference.sections.reduce((sum, section) => sum + section.keys.length, 0); --- diff --git a/website/src/components/DocsVersionSelect.astro b/website/src/components/DocsVersionSelect.astro new file mode 100644 index 00000000..cc6ea67f --- /dev/null +++ b/website/src/components/DocsVersionSelect.astro @@ -0,0 +1,84 @@ +--- +import versions from '../data/docs-versions.json'; +import { docsRoute, docsTargetHref } from '../docs-path'; + +const route = docsRoute(Astro.url.pathname); +const locale = route.locale ?? 'root'; +const labels = { + root: { accessible: 'Documentation version', latest: 'Latest', next: 'Unreleased' }, + ja: { accessible: 'ドキュメントのバージョン', latest: '最新', next: '未リリース' }, + 'zh-cn': { accessible: '文档版本', latest: '最新', next: '未发布' }, +}[locale]; +const scopes = versions.scopes; +--- + +{route.isDocs && ( + <label class="docs-version-select"> + <span class="sr-only">{labels.accessible}</span> + <select autocomplete="off" aria-label={labels.accessible}> + <option + value={docsTargetHref(route, 'stable', scopes)} + selected={route.target === 'stable'} + > + {labels.latest}{' '}{versions.current} + </option> + <option + value={docsTargetHref(route, 'preview', scopes)} + selected={route.target === 'preview'} + > + {labels.next} + </option> + {versions.versions + .filter((entry) => entry.version !== versions.current || route.target === versions.current) + .map((entry) => ( + <option + value={docsTargetHref(route, entry.version, scopes)} + selected={route.target === entry.version} + > + {entry.version} + </option> + ))} + </select> + </label> +)} + +<script> + for (const select of document.querySelectorAll<HTMLSelectElement>('.docs-version-select select')) { + select.addEventListener('change', () => { + globalThis.location.href = select.value; + }); + } +</script> + +<style> + .docs-version-select { + display: flex; + min-width: 0; + } + + select { + width: 9.5rem; + min-width: 0; + border: 1px solid var(--sl-color-hairline); + border-radius: 4px; + padding: 0.4rem 1.6rem 0.4rem 0.55rem; + background: var(--sl-color-bg-nav); + color: var(--sl-color-gray-2); + font: inherit; + font-size: var(--sl-text-xs); + font-weight: 700; + text-overflow: ellipsis; + } + + select:hover, + select:focus { + border-color: var(--sl-color-accent); + color: var(--sl-color-accent-high); + } + + @media (max-width: 38rem) { + select { + width: 7.5rem; + } + } +</style> diff --git a/website/src/components/Head.astro b/website/src/components/Head.astro index b1214cb1..9987173b 100644 --- a/website/src/components/Head.astro +++ b/website/src/components/Head.astro @@ -1,9 +1,10 @@ --- -import { docsChannel } from '../docs-path'; +import { docsVersion } from '../docs-path'; const { head } = Astro.locals.starlightRoute; -const channel = docsChannel(Astro.url.pathname); +const version = docsVersion(Astro.url.pathname); --- {head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)} -<meta data-pagefind-filter="channel[content]" content={channel} /> +<meta data-pagefind-filter="version[content]" content={version} /> +{version !== 'stable' && <meta name="robots" content="noindex, nofollow" />} diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro index 982efd2e..cfa3d759 100644 --- a/website/src/components/Header.astro +++ b/website/src/components/Header.astro @@ -6,19 +6,12 @@ import Search from 'virtual:starlight/components/Search'; import SiteTitle from 'virtual:starlight/components/SiteTitle'; import SocialIcons from 'virtual:starlight/components/SocialIcons'; import ThemeSelect from 'virtual:starlight/components/ThemeSelect'; +import DocsVersionSelect from './DocsVersionSelect.astro'; +import { docsRoute } from '../docs-path'; const shouldRenderSearch = config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro'; - -const pathname = Astro.url.pathname; -const isDocs = pathname === '/docs/' || pathname.startsWith('/docs/'); -const isPreview = pathname === '/docs/preview/' || pathname.startsWith('/docs/preview/'); -const stableHref = isPreview ? pathname.replace(/^\/docs\/preview\/?/, '/docs/') : pathname; -const previewHref = isPreview - ? pathname - : pathname === '/docs/' - ? '/docs/preview/' - : pathname.replace(/^\/docs\/?/, '/docs/preview/'); +const isDocs = docsRoute(Astro.url.pathname).isDocs; --- <div class="header"> @@ -26,12 +19,7 @@ const previewHref = isPreview <SiteTitle /> </div> <div class="sl-flex print:hidden search-wrapper"> - {isDocs && ( - <nav class="docs-channel-switcher" aria-label="Documentation channel"> - <a href={stableHref} aria-current={!isPreview ? 'page' : undefined}>Stable</a> - <a href={previewHref} aria-current={isPreview ? 'page' : undefined}>Preview</a> - </nav> - )} + {isDocs && <DocsVersionSelect />} {shouldRenderSearch && <Search />} </div> <div class="sl-hidden md:sl-flex print:hidden right-group"> @@ -66,38 +54,6 @@ const previewHref = isPreview min-width: 0; } - .docs-channel-switcher { - display: flex; - align-items: center; - border: 1px solid var(--sl-color-hairline); - border-radius: 4px; - overflow: hidden; - background: var(--sl-color-bg-nav); - font-size: var(--sl-text-xs); - font-weight: 700; - line-height: 1; - white-space: nowrap; - } - - .docs-channel-switcher a { - color: var(--sl-color-gray-2); - text-decoration: none; - padding: 0.45rem 0.6rem; - } - - .docs-channel-switcher a + a { - border-inline-start: 1px solid var(--sl-color-hairline); - } - - .docs-channel-switcher a[aria-current="page"] { - color: var(--sl-color-accent-high); - background: color-mix(in srgb, var(--sl-color-accent) 14%, transparent); - } - - .docs-channel-switcher a:hover { - color: var(--sl-color-accent-high); - } - .right-group, .social-icons { gap: 1rem; @@ -110,12 +66,6 @@ const previewHref = isPreview border-inline-end: 1px solid var(--sl-color-gray-5); } - @media (max-width: 38rem) { - .docs-channel-switcher { - display: none; - } - } - @media (min-width: 50rem) { :global(:root[data-has-sidebar]) { --__sidebar-pad: calc(2 * var(--sl-nav-pad-x)); diff --git a/website/src/components/Search.astro b/website/src/components/Search.astro index 2a31ea24..cceeed34 100644 --- a/website/src/components/Search.astro +++ b/website/src/components/Search.astro @@ -1,25 +1,25 @@ --- import StarlightSearch from '@astrojs/starlight/components/Search.astro'; -import { docsChannel } from '../docs-path'; +import { docsVersion } from '../docs-path'; -const channel = docsChannel(Astro.url.pathname); +const version = docsVersion(Astro.url.pathname); --- -<channel-search data-channel={channel}> +<version-search data-version={version}> <StarlightSearch /> -</channel-search> +</version-search> <script> - class ChannelSearch extends HTMLElement { + class VersionSearch extends HTMLElement { observer?: MutationObserver; connectedCallback() { - const channel = this.dataset.channel; - if (!channel) return; + const version = this.dataset.version; + if (!version) return; - const selectChannel = () => { + const selectVersion = () => { const checkbox = this.querySelector<HTMLInputElement>( - `.pagefind-ui__filter-checkbox[name="channel"][value="${channel}"]`, + `.pagefind-ui__filter-checkbox[name="version"][value="${version}"]`, ); if (!checkbox) return false; if (!checkbox.checked) checkbox.click(); @@ -27,9 +27,9 @@ const channel = docsChannel(Astro.url.pathname); return true; }; - if (selectChannel()) return; + if (selectVersion()) return; // Starlight does not expose its PagefindUI instance, so wait for Pagefind's filter control. - this.observer = new MutationObserver(selectChannel); + this.observer = new MutationObserver(selectVersion); this.observer.observe(this, { childList: true, subtree: true }); } @@ -38,15 +38,15 @@ const channel = docsChannel(Astro.url.pathname); } } - customElements.define('channel-search', ChannelSearch); + customElements.define('version-search', VersionSearch); </script> <style> - channel-search { + version-search { display: contents; } - channel-search :global(.pagefind-ui__filter-panel) { + version-search :global(.pagefind-ui__filter-panel) { display: none; } </style> diff --git a/website/src/components/Sidebar.astro b/website/src/components/Sidebar.astro index 8ded83bb..08d31f79 100644 --- a/website/src/components/Sidebar.astro +++ b/website/src/components/Sidebar.astro @@ -2,62 +2,39 @@ import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter'; import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro'; import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro'; +import versions from '../data/docs-versions.json'; +import { docsRoute, docsTargetHref } from '../docs-path'; const { sidebar } = Astro.locals.starlightRoute; const pathname = Astro.url.pathname; -const isPreview = pathname === '/docs/preview/' || pathname.startsWith('/docs/preview/'); -const channelSidebar = isPreview ? addPreviewOnlyItems(rewriteSidebar(sidebar)) : sidebar; +const route = docsRoute(pathname); +const scopedSidebar = route.target === 'stable' ? sidebar : rewriteSidebar(sidebar); -function addPreviewOnlyItems(entries) { - return entries.map((entry) => { - if (entry.type !== 'group' || entry.label !== 'Start here') return entry; - - const windowsBeta = { - type: 'link', - label: 'Windows beta', - href: '/docs/preview/windows-beta/', - badge: undefined, - isCurrent: samePath('/docs/preview/windows-beta/', pathname), - attrs: {}, - }; - const nextEntries = []; - for (const child of entry.entries) { - nextEntries.push(child); - if (child.type === 'link' && child.label === 'Install') { - nextEntries.push(windowsBeta); +function rewriteSidebar(entries) { + return entries.flatMap((entry) => { + if (entry.type === 'link') { + const linkRoute = docsRoute(entry.href); + if (!linkRoute.isDocs) return [entry]; + if (linkRoute.target !== 'stable') { + return linkRoute.target === route.target + ? [{ ...entry, isCurrent: samePath(entry.href, pathname) }] + : []; } - } - return { - ...entry, - entries: nextEntries, - }; - }); -} + const localeKey = route.locale ?? 'root'; + const availablePages = versions.scopes[route.target]?.locales[localeKey] ?? []; + if (!availablePages.includes(linkRoute.page)) return []; -function rewriteSidebar(entries) { - return entries.map((entry) => { - if (entry.type === 'link') { - const href = previewHref(entry.href); - return { - ...entry, - href, - isCurrent: samePath(href, pathname), - }; + const href = docsTargetHref({ ...linkRoute, locale: route.locale }, route.target, versions.scopes); + return [{ ...entry, href, isCurrent: samePath(href, pathname) }]; } + const rewrittenEntries = rewriteSidebar(entry.entries); - return { - ...entry, - entries: rewrittenEntries, - }; + if (rewrittenEntries.length === 0) return []; + return [{ ...entry, entries: rewrittenEntries }]; }); } -function previewHref(href) { - if (!href.startsWith('/docs/') || href.startsWith('/docs/preview/')) return href; - return href === '/docs/' ? '/docs/preview/' : href.replace(/^\/docs\//, '/docs/preview/'); -} - function samePath(left, right) { return stripTrailingSlash(left) === stripTrailingSlash(right); } @@ -68,7 +45,7 @@ function stripTrailingSlash(path) { --- <SidebarPersister> - <SidebarSublist sublist={channelSidebar} /> + <SidebarSublist sublist={scopedSidebar} /> </SidebarPersister> <div class="md:sl-hidden"> diff --git a/website/src/components/SiteTitle.astro b/website/src/components/SiteTitle.astro index 576e7635..3e5af0a3 100644 --- a/website/src/components/SiteTitle.astro +++ b/website/src/components/SiteTitle.astro @@ -1,8 +1,11 @@ --- +import { docsRoute } from '../docs-path'; + const { siteTitle, siteTitleHref, locale } = Astro.locals.starlightRoute; -const isPreview = Astro.url.pathname === '/docs/preview/' || Astro.url.pathname.startsWith('/docs/preview/'); +const route = docsRoute(Astro.url.pathname); const localePrefix = locale ? `/${locale}` : ''; -const docsHref = isPreview ? '/docs/preview/' : `${localePrefix}/docs/`; +const targetSegment = route.target === 'stable' ? '' : `/${route.target}`; +const docsHref = `${localePrefix}/docs${targetSegment}/`; --- <div class="title-row sl-flex"> diff --git a/website/src/docs-path.test.ts b/website/src/docs-path.test.ts index 26f9a701..0fadb26e 100644 --- a/website/src/docs-path.test.ts +++ b/website/src/docs-path.test.ts @@ -1,16 +1,44 @@ import { describe, expect, test } from 'bun:test'; -import { docsChannel, docsPath } from './docs-path'; +import { docsPath, docsRoute, docsTargetHref, docsVersion } from './docs-path'; -describe('docsChannel', () => { +const scopes = { + stable: { locales: { root: ['', 'install'], ja: ['', 'install'] } }, + preview: { locales: { root: ['', 'install', 'new-page'], ja: ['', 'install'] } }, + '0.7.4': { locales: { root: ['', 'install'], ja: [''] } }, +}; + +describe('docsRoute', () => { + test.each([ + ['/docs/', { isDocs: true, locale: undefined, target: 'stable', page: '' }], + ['/ja/docs/install/', { isDocs: true, locale: 'ja', target: 'stable', page: 'install' }], + ['/docs/preview/new-page/', { isDocs: true, locale: undefined, target: 'preview', page: 'new-page' }], + ['/zh-cn/docs/preview/', { isDocs: true, locale: 'zh-cn', target: 'preview', page: '' }], + ['/docs/0.7.4/install/', { isDocs: true, locale: undefined, target: '0.7.4', page: 'install' }], + ['/ja/docs/0.7.4/', { isDocs: true, locale: 'ja', target: '0.7.4', page: '' }], + ['/blog/', { isDocs: false, locale: undefined, target: 'stable', page: '' }], + ])('parses %s', (pathname, expected) => { + expect(docsRoute(pathname)).toEqual(expected); + }); +}); + +describe('docsVersion', () => { test.each([ ['/docs/', 'stable'], - ['/ja/docs/install/', 'stable'], - ['/zh-cn/docs/', 'stable'], - ['/docs/preview/', 'preview'], ['/ja/docs/preview/install/', 'preview'], - ['/zh-cn/docs/preview/', 'preview'], - ])('maps %s to %s', (pathname, expected) => { - expect(docsChannel(pathname)).toBe(expected); + ['/zh-cn/docs/0.7.4/', '0.7.4'], + ])('maps %s', (pathname, version) => { + expect(docsVersion(pathname)).toBe(version); + }); +}); + +describe('docsTargetHref', () => { + test.each([ + ['/docs/install/', 'preview', '/docs/preview/install/'], + ['/ja/docs/install/', '0.7.4', '/ja/docs/0.7.4/'], + ['/docs/0.7.4/install/', 'stable', '/docs/install/'], + ['/docs/preview/new-page/', '0.7.4', '/docs/0.7.4/'], + ])('maps %s to %s', (pathname, target, expected) => { + expect(docsTargetHref(docsRoute(pathname), target, scopes)).toBe(expected); }); }); @@ -25,7 +53,10 @@ describe('docsPath', () => { ['preview/install.mdx', 'docs/preview/install'], ['preview/ja/index.mdx', 'ja/docs/preview'], ['preview/ja/install.mdx', 'ja/docs/preview/install'], - ['preview/zh-cn/install.mdx', 'zh-cn/docs/preview/install'], + ['_versions/0.7.4/index.mdx', 'docs/0.7.4'], + ['_versions/0.7.4/install.mdx', 'docs/0.7.4/install'], + ['_versions/0.7.4/ja/index.mdx', 'ja/docs/0.7.4'], + ['_versions/0.7.4/zh-cn/install.mdx', 'zh-cn/docs/0.7.4/install'], ])('maps %s to %s', (entry, expected) => { expect(docsPath({ entry })).toBe(expected); }); diff --git a/website/src/docs-path.ts b/website/src/docs-path.ts index be022a4b..53ace41d 100644 --- a/website/src/docs-path.ts +++ b/website/src/docs-path.ts @@ -1,20 +1,87 @@ const docsLocales = new Set(['ja', 'zh-cn']); +const docsVersionPattern = /^\d+\.\d+\.\d+$/; -export function docsChannel(pathname: string) { - return /^\/(?:ja\/|zh-cn\/)?docs\/preview(?:\/|$)/.test(pathname) ? 'preview' : 'stable'; +export type DocsTarget = 'stable' | 'preview' | string; + +export interface DocsRoute { + isDocs: boolean; + locale: string | undefined; + target: DocsTarget; + page: string; +} + +export interface DocsScope { + locales: Record<string, string[]>; +} + +export function docsRoute(pathname: string): DocsRoute { + const segments = pathname.split('/').filter(Boolean); + let locale: string | undefined; + + if (segments[0] && docsLocales.has(segments[0])) { + locale = segments.shift(); + } + + if (segments.shift() !== 'docs') { + return { isDocs: false, locale, target: 'stable', page: '' }; + } + + let target: DocsTarget = 'stable'; + if (segments[0] === 'preview') { + target = 'preview'; + segments.shift(); + } else if (segments[0] && docsVersionPattern.test(segments[0])) { + target = segments.shift()!; + } + + return { + isDocs: true, + locale, + target, + page: segments.join('/'), + }; +} + +export function docsVersion(pathname: string) { + return docsRoute(pathname).target; +} + +export function docsTargetHref( + route: DocsRoute, + target: DocsTarget, + scopes?: Record<string, DocsScope>, +) { + if (!route.isDocs) throw new Error('cannot build a documentation target from a non-docs route'); + + const localeKey = route.locale ?? 'root'; + const availablePages = scopes?.[target]?.locales[localeKey]; + const page = availablePages?.includes(route.page) === false ? '' : route.page; + const prefix = route.locale ? `/${route.locale}` : ''; + const targetSegment = target === 'stable' ? '' : `/${target}`; + return `${prefix}/docs${targetSegment}${page ? `/${page}` : ''}/`; } export function docsPath({ entry }: { entry: string }) { const slug = entry.replace(/\.(md|mdx|markdown|mdown|mkdn|mkd|mdwn)$/i, ''); const normalized = slug.replace(/\/index$/, ''); const segments = normalized.split('/'); - const localeIndex = segments[0] === 'preview' ? 1 : 0; - const locale = segments[localeIndex]; - if (locale && docsLocales.has(locale)) { - segments.splice(localeIndex, 1); - return `${locale}/docs${segments.length > 0 ? `/${segments.join('/')}` : ''}`; + let targetSegment = ''; + if (segments[0] === 'preview') { + targetSegment = 'preview'; + segments.shift(); + } else if (segments[0] === '_versions' && segments[1] && docsVersionPattern.test(segments[1])) { + segments.shift(); + targetSegment = segments.shift()!; + } + + let locale: string | undefined; + if (segments[0] && docsLocales.has(segments[0])) { + locale = segments.shift(); } - return normalized === 'index' ? 'docs' : `docs/${normalized}`; + const page = segments.join('/'); + const prefix = locale ? `${locale}/` : ''; + const target = targetSegment ? `/${targetSegment}` : ''; + return `${prefix}docs${target}${page && page !== 'index' ? `/${page}` : ''}`; } |
