kbot import

This commit is contained in:
babayaga 2025-08-20 11:16:21 +02:00
parent d993f45cc8
commit e9570bde94
54 changed files with 5999 additions and 562 deletions

View File

@ -3,11 +3,13 @@
"version": "0.5.6",
"type": "module",
"scripts": {
"dev": "tsc -p . --watch"
"dev": "tsc -p . --watch",
"build": "tsc -p . "
},
"exports": {
".": "./dist/index.js",
"./base/*": "./dist/base/*",
"./config/*": "./dist/config/*",
"./components/*": "./src/components/*"
},
"files": [
@ -17,6 +19,7 @@
"dependencies": {
"@astrojs/compiler": "^2.12.2",
"@astrojs/react": "^4.3.0",
"@polymech/cache": "file:../../../polymech-mono/packages/cache",
"@polymech/cad": "file:../../../polymech-mono/packages/cad",
"@polymech/commons": "file:../../../polymech-mono/packages/commons",
"@polymech/fs": "file:../../../polymech-mono/packages/fs",

View File

@ -10,7 +10,7 @@ import { createLogger } from '@polymech/log'
import { parse, IProfile } from '@polymech/commons/profile'
import { translate } from "@/base/i18n.js"
import { renderMarkup } from "@/model/component.js"
import { renderMarkup } from "../model/component.js"
import {
LOGGING_NAMESPACE,

View File

@ -0,0 +1,30 @@
export const enum TemplateContext {
COMMON = 'common',
HOWTO = 'howto',
DIRECTORY = 'directory',
MARKETPLACE = 'marketplace'
}
export interface TemplateContextConfig {
path: string;
description: string;
}
export const TEMPLATE_CONTEXTS: Record<TemplateContext, TemplateContextConfig> = {
[TemplateContext.COMMON]: {
path: './src/config/templates/common.json',
description: 'Common language and utility templates'
},
[TemplateContext.HOWTO]: {
path: './src/config/templates/howto.json',
description: 'Tutorial and guide related templates'
},
[TemplateContext.DIRECTORY]: {
path: './src/config/templates/directory.json',
description: 'Directory and listing related templates'
},
[TemplateContext.MARKETPLACE]: {
path: './src/config/templates/marketplace.json',
description: 'Marketplace and commerce related templates'
}
};

View File

@ -0,0 +1,133 @@
import { IKBotTask } from "@polymech/kbot-d";
import { sync as read } from "@polymech/fs/read";
import { sync as exists } from "@polymech/fs/exists";
import { z } from "zod";
import { logger } from "./index.js";
import { OptionsSchema } from "@polymech/kbot-d"
const InstructionSchema = z.object({
flag: z.string(),
text: z.string()
});
const InstructionSetSchema = z.record(z.array(InstructionSchema));
export interface TemplateProps extends IKBotTask {
language?: string;
clazz?: string;
cache?: boolean;
disabled?: boolean;
template?: string;
renderer?: string;
}
const TemplateConfigSchema = z.object({
router: z.string().optional(),
_router: z.string().optional(),
model: z.string(),
preferences: z.string(),
mode: z.string(),
filters: z.string().optional(),
variables: z.record(z.string()).optional()
});
type TemplateConfig = z.infer<typeof TemplateConfigSchema>;
const LLMConfigSchema = z.object({
options: z.record(OptionsSchema()),
instructions: InstructionSetSchema.optional(),
defaults: z.record(z.array(z.string())).optional()
});
type LLMConfig = z.infer<typeof LLMConfigSchema>;
export const enum TemplateContext {
COMMONS = 'commons',
HOWTO = 'howto',
MARKETPLACE = 'marketplace',
DIRECTORY = 'directory'
}
// Default configuration
export const DEFAULT_CONFIG: LLMConfig = {
options: {},
instructions: {},
defaults: {}
};
const getConfigPath = (context: TemplateContext): string => {
return `./src/config/templates/${context}.json`;
};
export const load = (context: TemplateContext = TemplateContext.COMMONS): LLMConfig => {
const configPath = getConfigPath(context);
if (exists(configPath)) {
try {
const content = read(configPath, 'json') || {};
return LLMConfigSchema.parse(content)
} catch (error) {
logger.error(`Error loading ${context} config:`, error);
}
} else {
logger.error(`Config file ${configPath} not found`);
}
return DEFAULT_CONFIG;
};
export const buildPrompt = (
instructions: z.infer<typeof InstructionSetSchema>,
defaults: Record<string, string[]>
): string => {
const getInstructions = (category: string, flags: string[]) => {
const set = instructions[category] || [];
return set.filter(x => flags.includes(x.flag)).map(x => x.text);
};
const merged = Object.keys(instructions).reduce((acc, category) => ({
...acc,
[category]: defaults[category] ?? []
}), {} as Record<string, string[]>);
return Object.entries(merged)
.flatMap(([category, flags]) => getInstructions(category, flags))
.join("\n");
};
const PromptSchema = z.object({
template: z.string(),
variables: z.record(z.string()).optional(),
format: z.enum(['text', 'json', 'markdown', 'schema']).default('text')
});
type Prompt = z.infer<typeof PromptSchema>;
const PromptRegistrySchema = z.record(PromptSchema);
type PromptRegistry = z.infer<typeof PromptRegistrySchema>;
const createTemplate = (config: LLMConfig, name: string, defaults: Partial<TemplateConfig>) => {
return (opts: Partial<TemplateConfig> = {}) => {
const template = config.options[name] || defaults;
const prompt = buildPrompt(
config.instructions || {},
config.defaults || {}
);
const merged = {
...template,
...opts,
prompt: template.prompt || prompt
};
return merged
}
};
export const createTemplates = (context: TemplateContext = TemplateContext.COMMONS) => {
const config = load(context);
return Object.keys(config.options).reduce((acc, name) => ({
...acc,
[name]: createTemplate(config, name, {})
}), {});
};
export type { TemplateConfig, LLMConfig, Prompt, PromptRegistry }
export {
InstructionSchema,
InstructionSetSchema,
TemplateConfigSchema,
LLMConfigSchema,
PromptSchema,
PromptRegistrySchema
};

View File

@ -0,0 +1,105 @@
import { get_cached_object, set_cached_object, rm_cached_object } from "@polymech/cache"
import { run, OptionsSchema } from "@polymech/kbot-d";
import { resolveVariables } from "@polymech/commons/variables"
import { } from "@polymech/core/objects"
import { logger, env } from "./index.js"
import { removeEmptyObjects } from "@/base/objects.js"
import { LLM_CACHE } from "@/config/config.js"
import {
TemplateProps,
TemplateContext,
createTemplates
} from "./kbot-templates.js";
export interface Props extends TemplateProps {
context?: TemplateContext;
}
export const filter = async (content: string, tpl: string = 'howto', opts: Props = {}) => {
if (!content || content.length < 20) {
return content;
}
const context = opts.context || TemplateContext.COMMONS;
const templates = createTemplates(context);
if (!templates[tpl]) {
return content;
}
const template = typeof templates[tpl] === 'function' ? templates[tpl]() : templates[tpl];
const options = getFilterOptions(content, template, opts);
const cache_key_obj = {
content,
tpl,
context,
...options,
filters: [],
tools: []
};
const ca_options = JSON.parse(JSON.stringify(removeEmptyObjects(cache_key_obj)));
let cached
try {
cached = await get_cached_object({ ca_options }, 'kbot') as { content: string }
} catch (e) {
logger.error(`Failed to get cached object for ${content.substring(0, 20)}`, e);
}
if (cached) {
if (LLM_CACHE) {
return cached.content;
} else {
rm_cached_object({ ca_options }, 'kbot')
}
}
logger.info(`kbot: template:${tpl} : context:${context} @ ${options.model}`)
const result = await run(options);
if (!result || !result[0]) {
logger.error(`No result for ${content.substring(0, 20)}`)
return content;
}
if (template.format === 'json') {
try {
const jsonResult = JSON.parse(result[0] as string);
await set_cached_object(content, ca_options, { content: jsonResult }, 'kbot');
return jsonResult;
} catch (e) {
logger.error('Failed to parse JSON response:', e);
return result[0];
}
}
await set_cached_object({ ca_options }, 'kbot', { content: result[0] }, {})
logger.info(`kbot-result: template:${tpl} : context:${context} @ ${options.model} : ${result[0]}`)
return result[0] as string;
};
export const template_filter = async (text: string, template: string, context: TemplateContext = TemplateContext.COMMONS) => {
if (!text || text.length < 20) {
return text;
}
const templates = createTemplates(context);
if (!templates[template]) {
logger.warn(`No template found for ${template}`);
return text;
}
const templateConfig = templates[template]();
const resolvedTemplate = Object.fromEntries(
Object.entries(templateConfig).map(([key, value]) => [
key,
typeof value === 'string' ? resolveVariables(value, true) : value
])
);
const resolvedText = resolveVariables(text, true);
const ret = await filter(resolvedText, template, {
context,
...resolvedTemplate,
prompt: `${resolvedTemplate.prompt}\n\nText to process:\n${resolvedText}`,
variables: env().variables
});
return ret;
};
export const getFilterOptions = (content: string, template: any, opts: Props = {}) => {
return OptionsSchema().parse({
...template,
prompt: `${template.prompt || ""} : ${content}`,
...opts,
});
};

View File

@ -0,0 +1,157 @@
---
interface Props {
currentPath: string;
collection?: string;
title?: string;
separator?: string;
showHome?: boolean;
}
const {
currentPath,
collection = '',
title = '',
separator = '/',
showHome = true
} = Astro.props;
// Parse the current path to generate breadcrumb items
function generateBreadcrumbs(path: string, collection: string, pageTitle?: string) {
const segments = path.split('/').filter(segment => segment !== '');
const breadcrumbs: Array<{ label: string; href?: string; isLast?: boolean }> = [];
// Add home if enabled
if (showHome) {
breadcrumbs.push({ label: 'Home', href: '/' });
}
// Build path segments
let currentHref = '';
segments.forEach((segment, index) => {
currentHref += `/${segment}`;
const isLast = index === segments.length - 1;
// Format segment label
let label = segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Use page title for the last segment if provided
if (isLast && pageTitle) {
label = pageTitle;
}
breadcrumbs.push({
label,
href: isLast ? undefined : currentHref + '/',
isLast
});
});
return breadcrumbs;
}
const breadcrumbs = generateBreadcrumbs(currentPath, collection, title);
---
<nav class="breadcrumb" aria-label="Breadcrumb navigation">
<ol class="breadcrumb-list">
{breadcrumbs.map((crumb, index) => (
<li class="breadcrumb-item">
{crumb.href ? (
<a
href={crumb.href}
class="breadcrumb-link"
aria-label={`Navigate to ${crumb.label}`}
>
{crumb.label}
</a>
) : (
<span class="breadcrumb-current" aria-current="page">
{crumb.label}
</span>
)}
{index < breadcrumbs.length - 1 && (
<span class="breadcrumb-separator" aria-hidden="true">
{separator}
</span>
)}
</li>
))}
</ol>
</nav>
<style>
.breadcrumb {
margin-bottom: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid #e5e7eb;
}
.breadcrumb-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.breadcrumb-link {
color: #6b7280;
text-decoration: none;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.breadcrumb-link:hover {
color: #374151;
background-color: #f3f4f6;
text-decoration: underline;
}
.breadcrumb-link:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.breadcrumb-current {
color: #111827;
font-weight: 500;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
.breadcrumb-separator {
color: #9ca3af;
font-size: 0.875rem;
user-select: none;
}
/* Mobile responsiveness */
@media (max-width: 640px) {
.breadcrumb {
padding: 0.5rem 0;
}
.breadcrumb-link,
.breadcrumb-current {
font-size: 0.8125rem;
padding: 0.125rem 0.25rem;
}
.breadcrumb-list {
gap: 0.125rem;
}
}
</style>

View File

@ -1,9 +1,7 @@
---
import { Img } from "imagetools/components";
import Translate from "./i18n.astro"
import { translate } from "@/base/i18n";
import { createMarkdownComponent, markdownToHtml } from "@/base/index.js";
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js"
@ -172,7 +170,7 @@ const locale = Astro.currentLocale || "en";
x-show="open"
x-transition
:class="{ 'lightbox': !lightboxLoaded }"
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center lightbox"
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center lightbox z-50"
>
<div
class="relative max-w-full max-h-full"

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,6 @@ const {
const content = await Astro.slots.render('default')
const translatedText = await translate(content, I18N_SOURCE_LANGUAGE, language, rest)
---
<div data-widget="polymech.i18n" class={clazz}>
<p data-widget="polymech.i18n" class={clazz}>
{translatedText}
</div>
</p>

View File

@ -0,0 +1,122 @@
---
// Simple mobile toggle for sidebar
import Translate from "@polymech/astro-base/components/i18n.astro";
---
<button
class="mobile-sidebar-toggle md:hidden"
aria-label="Toggle navigation menu"
data-sidebar-toggle
>
<span class="hamburger-icon">
<span></span>
<span></span>
<span></span>
</span>
</button>
<script>
// Enhanced mobile sidebar functionality
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.querySelector('[data-sidebar-toggle]') as HTMLButtonElement;
const sidebar = document.querySelector('.sidebar-wrapper') as HTMLElement;
if (!toggle || !sidebar) return;
let startX = 0;
let startY = 0;
let isScrolling = false;
// Toggle sidebar open/close
function toggleSidebar(forceClose = false) {
if (!sidebar || !toggle) return;
if (forceClose) {
sidebar.classList.remove('mobile-open');
} else {
sidebar.classList.toggle('mobile-open');
}
const isOpen = sidebar.classList.contains('mobile-open');
toggle.setAttribute('aria-expanded', isOpen.toString());
// Prevent body scroll when sidebar is open
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
// Toggle button click
toggle.addEventListener('click', () => toggleSidebar());
// Close sidebar when clicking outside (on backdrop)
sidebar.addEventListener('click', (e) => {
if (e.target === sidebar) {
toggleSidebar(true);
}
});
// Close sidebar when clicking any navigation link
sidebar.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target && (target.matches?.('a[href]') || target.closest?.('a[href]'))) {
toggleSidebar(true);
}
});
// Close with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebar.classList.contains('mobile-open')) {
toggleSidebar(true);
}
});
// Touch events for swipe to close
sidebar.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isScrolling = false;
}, { passive: true });
sidebar.addEventListener('touchmove', (e: TouchEvent) => {
if (!startX || !startY) return;
const diffX = startX - e.touches[0].clientX;
const diffY = startY - e.touches[0].clientY;
// Determine if user is scrolling vertically
if (Math.abs(diffY) > Math.abs(diffX)) {
isScrolling = true;
return;
}
// Prevent default horizontal scrolling
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault();
}
}, { passive: false });
sidebar.addEventListener('touchend', (e: TouchEvent) => {
if (!startX || isScrolling) return;
const endX = e.changedTouches[0].clientX;
const diffX = startX - endX;
// Swipe left to close (minimum 50px swipe)
if (diffX > 50 && sidebar.classList.contains('mobile-open')) {
toggleSidebar(true);
}
// Reset
startX = 0;
startY = 0;
isScrolling = false;
}, { passive: true });
// Clean up body overflow on page unload
window.addEventListener('beforeunload', () => {
document.body.style.overflow = '';
});
});
</script>

View File

@ -0,0 +1,50 @@
---
import SidebarGroup from './SidebarGroup.astro';
import TableOfContentsWithScroll from './TableOfContentsWithScroll.astro';
import { processSidebarGroup, getCurrentPath } from './utils';
import type { SidebarGroup as SidebarGroupType } from './types';
import type { MarkdownHeading } from 'astro';
import Translate from "@polymech/astro-base/components/i18n.astro";
import SidebarPersister from './SidebarPersister.astro';
interface Props {
config: SidebarGroupType[];
currentUrl?: URL | string;
headings?: MarkdownHeading[];
pageNavigation?: SidebarGroupType[];
}
const { config, currentUrl, headings = [], pageNavigation = [] } = Astro.props;
const currentPath = currentUrl ? getCurrentPath(currentUrl) : '';
// Process all sidebar groups
const processedGroups = await Promise.all(
config.map(group => processSidebarGroup(group, currentPath))
);
// Process page-level navigation
const processedPageNav = await Promise.all(
pageNavigation.map(group => processSidebarGroup({...group, isPageLevel: true}, currentPath))
);
---
<nav class="sidebar-nav" aria-label="Site navigation">
<div class="sidebar-content">
{/* Page-level navigation first */}
{processedPageNav.map((group) => (
<SidebarGroup group={group} />
))}
{/* Global navigation */}
{processedGroups.map((group) => (
<SidebarGroup group={group} />
))}
{/* Table of contents */}
{headings && headings.length > 0 && (
<TableOfContentsWithScroll headings={headings} />
)}
</div>
</nav>
<SidebarPersister />

View File

@ -0,0 +1,140 @@
---
import type { SidebarGroup } from './types';
import Translate from "@polymech/astro-base/components/i18n.astro";
interface Props {
group: SidebarGroup;
isNested?: boolean;
}
const { group, isNested = false } = Astro.props;
---
<div class={`sidebar-group ${isNested ? 'nested' : ''} ${group.isSubGroup ? 'sub-group' : ''} ${group.isPageLevel ? 'page-level' : ''}`}>
{group.items && group.items.length > 0 && (
<>
{/* Group title - always show for top-level, and for sub-groups */}
{(!isNested || group.isSubGroup) && (
<div class="sidebar-group-header">
{group.isSubGroup ? (
<details class="sidebar-subgroup-details" open={!group.collapsed}>
<summary class="sidebar-subgroup-title">
<span><Translate>{group.label}</Translate></span>
<span class="caret">▶</span>
</summary>
<div class="sidebar-subgroup-content">
<Astro.self group={{...group, isSubGroup: false}} isNested={true} />
</div>
</details>
) : (
<h3 class="sidebar-group-title">
<Translate>{group.label}</Translate>
</h3>
)}
</div>
)}
{/* Render items only if this is not a sub-group (sub-groups render recursively) */}
{!group.isSubGroup && (
<ul class="sidebar-links">
{group.items.map((item) => (
<li>
{'href' in item ? (
<a
href={item.href}
class={`sidebar-link ${item.isCurrent ? 'current' : ''}`}
aria-current={item.isCurrent ? 'page' : undefined}
>
<Translate>{item.label}</Translate>
</a>
) : (
<Astro.self group={item} isNested={true} />
)}
</li>
))}
</ul>
)}
</>
)}
</div>
<style>
.sidebar-subgroup-details {
margin-bottom: 0.5rem;
}
.sidebar-subgroup-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background-color: #f9fafb;
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.sidebar-subgroup-title:hover {
background-color: #f3f4f6;
color: #111827;
}
.sidebar-subgroup-title .caret {
font-size: 0.75rem;
transition: transform 0.2s ease;
}
.sidebar-subgroup-details[open] .sidebar-subgroup-title .caret {
transform: rotate(90deg);
}
.sidebar-subgroup-content {
margin-top: 0.5rem;
margin-left: 0.75rem;
padding-left: 0.75rem;
border-left: 2px solid #e5e7eb;
}
.sidebar-subgroup-content .sidebar-links {
margin-top: 0.25rem;
}
.sub-group {
margin-bottom: 0.75rem;
}
.nested .sidebar-group-title {
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
/* Page-level navigation styling */
.page-level {
border-bottom: 2px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.page-level .sidebar-group-title {
color: #3b82f6; /* blue-500 */
font-size: 0.8rem;
border-bottom: 1px solid #dbeafe; /* blue-100 */
}
.page-level .sidebar-link {
color: #1e40af; /* blue-800 */
font-weight: 500;
background-color: #f0f9ff; /* blue-50 */
border-radius: 0.375rem;
margin-bottom: 0.25rem;
}
.page-level .sidebar-link:hover {
background-color: #dbeafe; /* blue-100 */
color: #1d4ed8; /* blue-700 */
}
</style>

View File

@ -0,0 +1,99 @@
---
---
<script is:inline>
function simpleHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash.toString(36);
}
function getSidebarItems() {
return document.querySelectorAll('.sidebar-group');
}
function getElementHash(element) {
const labelEl = element.querySelector(
':scope > h3.sidebar-group-title, :scope > .sidebar-group-header > details > summary'
);
const label = labelEl?.textContent?.trim() || '';
// Get direct child links (only exist in non-collapsible groups)
const childLinks = Array.from(
element.querySelectorAll(':scope > ul.sidebar-links > li > a.sidebar-link')
);
const childLinkHashes = childLinks.map((link) => {
const href = link.getAttribute('href') || '';
const linkText = link.textContent?.trim() || '';
return simpleHash(`${href}:${linkText}`);
});
// Get direct child groups from both possible locations
const childGroupsInList = Array.from(
element.querySelectorAll(':scope > ul.sidebar-links > li > .sidebar-group')
);
const childGroupInDetails = Array.from(
element.querySelectorAll(
':scope > .sidebar-group-header > details > .sidebar-subgroup-content > .sidebar-group'
)
);
const childGroups = [...childGroupsInList, ...childGroupInDetails];
const childGroupHashes = childGroups.map(getElementHash);
return simpleHash(`${label}:${childLinkHashes.join('')}:${childGroupHashes.join('')}`);
}
function storeSidebarState() {
const items = getSidebarItems();
Array.from(items).forEach(item => {
const details = item.querySelector('details');
if (details) {
const hash = getElementHash(item);
const key = `sidebar-group-${hash}`;
const value = String(details.open);
sessionStorage.setItem(key, value);
}
});
}
function restoreSidebarState() {
const items = getSidebarItems();
Array.from(items).forEach(item => {
const hash = getElementHash(item);
const key = `sidebar-group-${hash}`;
const storedState = sessionStorage.getItem(key);
const details = item.querySelector('details');
if (details && storedState !== null) {
details.open = storedState === 'true';
}
});
}
function initializeSidebar() {
restoreSidebarState();
const detailsElements = document.querySelectorAll('.sidebar-group details');
detailsElements.forEach(details => {
details.addEventListener('toggle', storeSidebarState);
});
}
// Try multiple event listeners to ensure we catch the page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeSidebar);
} else {
// DOM is already loaded
initializeSidebar();
}
// Also listen for astro page loads (if using view transitions)
document.addEventListener('astro:page-load', initializeSidebar);
window.addEventListener('beforeunload', storeSidebarState);
</script>

View File

@ -0,0 +1,86 @@
---
import type { MarkdownHeading } from 'astro';
import { generateToC } from './utils/generateToC.js';
import TableOfContentsList from './TableOfContents/TableOfContentsList.astro';
import Translate from "@polymech/astro-base/components/i18n.astro";
interface Props {
headings: MarkdownHeading[];
title?: string;
minHeadingLevel?: number;
maxHeadingLevel?: number;
}
const {
headings,
title = "toc.on-this-page",
minHeadingLevel = 2,
maxHeadingLevel = 4
} = Astro.props;
// Generate TOC from headings
const toc = generateToC(headings, {
minHeadingLevel,
maxHeadingLevel,
title
});
---
{toc && toc.length > 0 && (
<div class="toc-container">
<h3 class="toc-title">
<Translate>{title}</Translate>
</h3>
<nav class="toc-nav">
<TableOfContentsList toc={toc} />
</nav>
</div>
)}
<style>
.toc-container {
border-top: 1px solid #e5e7eb; /* border-gray-200 */
padding-top: 1rem; /* pt-4 */
margin-top: 1.5rem; /* mt-6 */
}
.toc-title {
font-size: 0.75rem; /* text-xs */
font-weight: 600; /* font-semibold */
color: #6b7280; /* text-gray-500 */
text-transform: uppercase;
letter-spacing: 0.05em; /* tracking-wider */
margin-bottom: 0.75rem; /* mb-3 */
}
.toc-nav {
font-size: 0.875rem; /* text-sm */
}
/* Override Starlight styles for our clean theme */
:global(.toc-nav ul) {
display: flex;
flex-direction: column;
gap: 0.25rem; /* space-y-1 */
}
:global(.toc-nav a) {
display: block;
padding: 0.25rem 0.75rem; /* px-3 py-1 */
color: #4b5563; /* text-gray-600 */
border-radius: 0.375rem; /* rounded-md */
transition: all 0.2s ease;
text-decoration: none;
}
:global(.toc-nav a:hover) {
color: #111827; /* text-gray-900 */
background-color: #f3f4f6; /* bg-gray-100 */
}
:global(.toc-nav a[aria-current="true"]) {
color: #1d4ed8; /* text-blue-700 */
background-color: #eff6ff; /* bg-blue-50 */
font-weight: 500; /* font-medium */
}
</style>

View File

@ -0,0 +1,110 @@
---
import type { TocItem } from '../utils/generateToC.js';
interface Props {
toc: TocItem[];
depth?: number;
isMobile?: boolean;
}
const { toc, isMobile = false, depth = 0 } = Astro.props;
// Calculate tree indicators for each item
function getTreeIndicators(depth: number): string {
if (depth === 0) return '';
return '│ '.repeat(depth - 1) + '├─ ';
}
---
<ul class:list={['toc-tree', { isMobile }]}>
{
toc.map((heading, index) => {
const isLast = index === toc.length - 1;
const treePrefix = depth > 0 ? (isLast ? '└─ ' : '├─ ') : '';
const parentPrefix = depth > 0 ? '│ '.repeat(depth - 1) : '';
return (
<li class="toc-item" data-depth={heading.depth}>
<a href={'#' + heading.slug} class="toc-link">
{depth > 0 && (
<span class="tree-indicator" aria-hidden="true">
{parentPrefix}{treePrefix}
</span>
)}
<span class="toc-text">{heading.text}</span>
</a>
{heading.children.length > 0 && (
<Astro.self toc={heading.children} depth={depth + 1} isMobile={isMobile} />
)}
</li>
)
})
}
</ul>
<style define:vars={{ depth }}>
.toc-tree {
padding: 0;
list-style: none;
margin: 0;
}
.toc-item {
margin: 0;
padding: 0;
}
.toc-link {
display: flex;
align-items: center;
padding: 0.125rem 0.5rem; /* py-0.5 px-2 */
line-height: 1.4;
color: #6b7280; /* text-gray-500 */
text-decoration: none;
font-size: 0.8125rem; /* text-sm */
transition: color 0.15s ease;
border-radius: 0.25rem;
margin-bottom: 0.125rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace; /* Use monospace for tree alignment */
}
.tree-indicator {
color: #9ca3af; /* text-gray-400 */
font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace;
font-size: 0.75rem;
line-height: 1;
white-space: pre;
flex-shrink: 0;
margin-right: 0.25rem;
}
.toc-text {
font-family: ui-sans-serif, system-ui, sans-serif; /* Reset to normal font for text */
flex: 1;
min-width: 0; /* Allow text to wrap */
word-break: break-word;
}
.toc-link:hover {
color: #111827; /* text-gray-900 */
background-color: #f9fafb; /* bg-gray-50 */
}
.toc-link[aria-current='true'] {
color: #1d4ed8; /* text-blue-700 */
background-color: #eff6ff; /* bg-blue-50 */
font-weight: 500;
}
.toc-link[aria-current='true'] .tree-indicator {
color: #60a5fa; /* text-blue-400 */
}
/* Depth-based indentation as fallback */
.toc-item[data-depth="1"] .toc-link { padding-left: 0.5rem; }
.toc-item[data-depth="2"] .toc-link { padding-left: 0.75rem; }
.toc-item[data-depth="3"] .toc-link { padding-left: 1rem; }
.toc-item[data-depth="4"] .toc-link { padding-left: 1.25rem; }
.toc-item[data-depth="5"] .toc-link { padding-left: 1.5rem; }
.toc-item[data-depth="6"] .toc-link { padding-left: 1.75rem; }
</style>

View File

@ -0,0 +1,113 @@
import { PAGE_TITLE_ID } from '../../constants';
export class StarlightTOC extends HTMLElement {
private _current = this.querySelector<HTMLAnchorElement>('a[aria-current="true"]');
private minH = parseInt(this.dataset.minH || '2', 10);
private maxH = parseInt(this.dataset.maxH || '3', 10);
protected set current(link: HTMLAnchorElement) {
if (link === this._current) return;
if (this._current) this._current.removeAttribute('aria-current');
link.setAttribute('aria-current', 'true');
this._current = link;
}
private onIdle = (cb: IdleRequestCallback) =>
(window.requestIdleCallback || ((cb) => setTimeout(cb, 1)))(cb);
constructor() {
super();
this.onIdle(() => this.init());
}
private init = (): void => {
/** All the links in the table of contents. */
const links = [...this.querySelectorAll('a')];
/** Test if an element is a table-of-contents heading. */
const isHeading = (el: Element): el is HTMLHeadingElement => {
if (el instanceof HTMLHeadingElement) {
// Special case for page title h1
if (el.id === PAGE_TITLE_ID) return true;
// Check the heading level is within the user-configured limits for the ToC
const level = el.tagName[1];
if (level) {
const int = parseInt(level, 10);
if (int >= this.minH && int <= this.maxH) return true;
}
}
return false;
};
/** Walk up the DOM to find the nearest heading. */
const getElementHeading = (el: Element | null): HTMLHeadingElement | null => {
if (!el) return null;
const origin = el;
while (el) {
if (isHeading(el)) return el;
// Assign the previous siblings last, most deeply nested child to el.
el = el.previousElementSibling;
while (el?.lastElementChild) {
el = el.lastElementChild;
}
// Look for headings amongst siblings.
const h = getElementHeading(el);
if (h) return h;
}
// Walk back up the parent.
return getElementHeading(origin.parentElement);
};
/** Handle intersections and set the current link to the heading for the current intersection. */
const setCurrent: IntersectionObserverCallback = (entries) => {
for (const { isIntersecting, target } of entries) {
if (!isIntersecting) continue;
const heading = getElementHeading(target);
if (!heading) continue;
const link = links.find((link) => link.hash === '#' + encodeURIComponent(heading.id));
if (link) {
this.current = link;
break;
}
}
};
// Observe elements with an `id` (most likely headings) and their siblings.
// Also observe direct children of `.content` to include elements before
// the first heading.
const toObserve = document.querySelectorAll('main [id], main [id] ~ *, main .content > *');
let observer: IntersectionObserver | undefined;
const observe = () => {
if (observer) return;
observer = new IntersectionObserver(setCurrent, { rootMargin: this.getRootMargin() });
toObserve.forEach((h) => observer!.observe(h));
};
observe();
let timeout: NodeJS.Timeout;
window.addEventListener('resize', () => {
// Disable intersection observer while window is resizing.
if (observer) {
observer.disconnect();
observer = undefined;
}
clearTimeout(timeout);
timeout = setTimeout(() => this.onIdle(observe), 200);
});
};
private getRootMargin(): `-${number}px 0% ${number}px` {
const navBarHeight = document.querySelector('header')?.getBoundingClientRect().height || 0;
// `<summary>` only exists in mobile ToC, so will fall back to 0 in large viewport component.
const mobileTocHeight = this.querySelector('summary')?.getBoundingClientRect().height || 0;
/** Start intersections at nav height + 2rem padding. */
const top = navBarHeight + mobileTocHeight + 32;
/** End intersections `53px` later. This is slightly more than the maximum `margin-top` in Markdown content. */
const bottom = top + 53;
const height = document.documentElement.clientHeight;
return `-${top}px 0% ${bottom - height}px`;
}
}
customElements.define('starlight-toc', StarlightTOC);

View File

@ -0,0 +1,233 @@
---
import type { MarkdownHeading } from 'astro';
import { generateToC } from './utils/generateToC.js';
import TableOfContentsList from './TableOfContents/TableOfContentsList.astro';
import Translate from "@polymech/astro-base/components/i18n.astro";
interface Props {
headings: MarkdownHeading[];
title?: string;
minHeadingLevel?: number;
maxHeadingLevel?: number;
}
const {
headings,
title = "toc.on-this-page",
minHeadingLevel = 2,
maxHeadingLevel = 4
} = Astro.props;
// Generate TOC from headings
const toc = generateToC(headings, {
minHeadingLevel,
maxHeadingLevel,
title
});
---
{toc && toc.length > 0 && (
<div class="toc-container" data-toc-scroll>
<h3 class="toc-title">
<Translate>{title}</Translate>
</h3>
<nav class="toc-nav">
<TableOfContentsList toc={toc} />
</nav>
</div>
)}
<script>
class TableOfContentsScroll {
private observer: IntersectionObserver;
private tocLinks: NodeListOf<HTMLAnchorElement>;
private headings: NodeListOf<Element>;
constructor() {
this.tocLinks = document.querySelectorAll('[data-toc-scroll] a[href^="#"]');
this.headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
if (this.headings.length === 0 || this.tocLinks.length === 0) return;
this.setupIntersectionObserver();
this.observer.observe(document.documentElement);
// Observe all headings
this.headings.forEach(heading => this.observer.observe(heading));
}
private setupIntersectionObserver() {
this.observer = new IntersectionObserver(
(entries) => {
// Find the heading that's most visible
let activeHeading: Element | null = null;
let maxRatio = 0;
entries.forEach(entry => {
if (entry.target.tagName?.match(/^H[1-6]$/)) {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
activeHeading = entry.target;
}
}
});
// If no heading is intersecting, find the one closest to viewport top
if (!activeHeading) {
let closestDistance = Infinity;
this.headings.forEach(heading => {
const rect = heading.getBoundingClientRect();
const distance = Math.abs(rect.top);
if (distance < closestDistance && rect.top <= window.innerHeight / 2) {
closestDistance = distance;
activeHeading = heading;
}
});
}
this.updateActiveLink(activeHeading);
},
{
rootMargin: '-20% 0% -35% 0%', // Trigger when heading is in the upper portion
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
}
private updateActiveLink(activeHeading: Element | null) {
// Remove current active states
this.tocLinks.forEach(link => {
link.removeAttribute('aria-current');
link.classList.remove('active');
});
if (activeHeading?.id) {
// Find and activate the corresponding TOC link
const activeLink = document.querySelector(`[data-toc-scroll] a[href="#${activeHeading.id}"]`) as HTMLAnchorElement;
if (activeLink) {
activeLink.setAttribute('aria-current', 'true');
activeLink.classList.add('active');
// Optionally scroll the TOC to keep active item visible
this.scrollTocToActiveItem(activeLink);
}
}
}
private scrollTocToActiveItem(activeLink: HTMLAnchorElement) {
const tocContainer = document.querySelector('[data-toc-scroll] .toc-nav');
if (!tocContainer) return;
const containerRect = tocContainer.getBoundingClientRect();
const linkRect = activeLink.getBoundingClientRect();
if (linkRect.bottom > containerRect.bottom || linkRect.top < containerRect.top) {
activeLink.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
public destroy() {
this.observer?.disconnect();
}
}
// Initialize when DOM is ready
let tocScroll: TableOfContentsScroll;
document.addEventListener('DOMContentLoaded', () => {
tocScroll = new TableOfContentsScroll();
});
// Cleanup on page navigation (for SPAs)
document.addEventListener('astro:before-preparation', () => {
tocScroll?.destroy();
});
</script>
<style>
.toc-container {
border-top: 1px solid #e5e7eb; /* border-gray-200 */
padding-top: 1rem; /* pt-4 */
margin-top: 1.5rem; /* mt-6 */
}
.toc-title {
font-size: 0.75rem; /* text-xs */
font-weight: 600; /* font-semibold */
color: #6b7280; /* text-gray-500 */
text-transform: uppercase;
letter-spacing: 0.05em; /* tracking-wider */
margin-bottom: 0.75rem; /* mb-3 */
}
.toc-nav {
font-size: 0.875rem; /* text-sm */
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #d1d5db #f9fafb;
}
.toc-nav::-webkit-scrollbar {
width: 4px;
}
.toc-nav::-webkit-scrollbar-track {
background: #f9fafb;
}
.toc-nav::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
.toc-nav::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Override Starlight styles for our clean theme */
:global(.toc-nav ul) {
display: flex;
flex-direction: column;
gap: 0.25rem; /* space-y-1 */
}
:global(.toc-nav a) {
display: block;
padding: 0.25rem 0.75rem; /* px-3 py-1 */
color: #4b5563; /* text-gray-600 */
border-radius: 0.375rem; /* rounded-md */
transition: all 0.2s ease;
text-decoration: none;
border-left: 2px solid transparent;
}
:global(.toc-nav a:hover) {
color: #111827; /* text-gray-900 */
background-color: #f3f4f6; /* bg-gray-100 */
}
:global(.toc-nav a[aria-current="true"]),
:global(.toc-nav a.active) {
color: #1d4ed8; /* text-blue-700 */
background-color: #eff6ff; /* bg-blue-50 */
font-weight: 500; /* font-medium */
border-left-color: #3b82f6; /* border-blue-500 */
}
/* Visual indicator for current section depth */
:global(.toc-nav a[aria-current="true"]::before) {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
background-color: #3b82f6;
border-radius: 0 2px 2px 0;
}
</style>

View File

@ -0,0 +1,28 @@
import type { SidebarGroup } from './types.js';
// Since we can't directly import the astro config at runtime,
// we'll define the sidebar config here based on the astro.config.mjs
// This could be improved by having a build step that extracts the config
export const sidebarConfig: SidebarGroup[] = [
{
label: 'sidebar.guides',
items: [
// Each item here is one entry in the navigation menu.
{ label: 'sidebar.example-guide', slug: 'guides/example', href: '/guides/example/' },
],
},
{
label: 'sidebar.resources',
autogenerate: {
directory: 'resources',
collapsed: false,
sortBy: 'alphabetical' // Default alphabetical sorting
},
}
];
// Helper function to get sidebar config
export function getSidebarConfig(): SidebarGroup[] {
return sidebarConfig;
}

View File

@ -0,0 +1,2 @@
/** Identifier for the page title h1 when it is injected into the ToC. */
export const PAGE_TITLE_ID = 'starlight__overview';

View File

@ -0,0 +1,33 @@
import type { MarkdownHeading } from 'astro';
import { PAGE_TITLE_ID } from './constants.js';
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}
interface TocOpts {
minHeadingLevel: number;
maxHeadingLevel: number;
title: string;
}
/** Convert the flat headings array generated by Astro into a nested tree structure. */
export function generateToC(
headings: MarkdownHeading[],
{ minHeadingLevel, maxHeadingLevel, title }: TocOpts
) {
headings = headings.filter(({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel);
const toc: Array<TocItem> = [{ depth: 2, slug: PAGE_TITLE_ID, text: title, children: [] }];
for (const heading of headings) injectChild(toc, { ...heading, children: [] });
return toc;
}
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
function injectChild(items: TocItem[], item: TocItem): void {
const lastItem = items.at(-1);
if (!lastItem || lastItem.depth >= item.depth) {
items.push(item);
} else {
return injectChild(lastItem.children, item);
}
}

View File

@ -0,0 +1,90 @@
function simpleHash(text: string): string {
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash.toString(36);
}
function getSidebarItems(): NodeListOf<Element> {
return document.querySelectorAll('.sidebar-group');
}
function getElementHash(element: Element): string {
const labelEl = element.querySelector(
':scope > h3.sidebar-group-title, :scope > .sidebar-group-header > details > summary'
);
const label = labelEl?.textContent?.trim() || '';
// Get direct child links (only exist in non-collapsible groups)
const childLinks = Array.from(
element.querySelectorAll(':scope > ul.sidebar-links > li > a.sidebar-link')
);
const childLinkHashes = childLinks.map((link) => {
const href = link.getAttribute('href') || '';
const linkText = link.textContent?.trim() || '';
return simpleHash(`${href}:${linkText}`);
});
// Get direct child groups from both possible locations
const childGroupsInList = Array.from(
element.querySelectorAll(':scope > ul.sidebar-links > li > .sidebar-group')
);
const childGroupInDetails = Array.from(
element.querySelectorAll(
':scope > .sidebar-group-header > details > .sidebar-subgroup-content > .sidebar-group'
)
);
const childGroups = [...childGroupsInList, ...childGroupInDetails];
const childGroupHashes = childGroups.map(getElementHash);
return simpleHash(`${label}:${childLinkHashes.join('')}:${childGroupHashes.join('')}`);
}
export function storeSidebarState() {
console.log('[Sidebar] Storing state...');
const items = getSidebarItems();
for (const item of items) {
const details = item.querySelector('details');
if (details) {
const hash = getElementHash(item);
const key = `sidebar-group-${hash}`;
const value = String(details.open);
sessionStorage.setItem(key, value);
console.log(`[Sidebar] Stored: ${key} = ${value}`);
}
}
}
export function restoreSidebarState() {
console.log('[Sidebar] Restoring state...');
const items = getSidebarItems();
console.log(`[Sidebar] Found ${items.length} sidebar groups`);
for (const item of items) {
const hash = getElementHash(item);
const key = `sidebar-group-${hash}`;
const storedState = sessionStorage.getItem(key);
const details = item.querySelector('details');
console.log(`[Sidebar] Processing group with hash: ${hash}`);
console.log(`[Sidebar] Stored state for ${key}: ${storedState}`);
console.log(`[Sidebar] Details element found: ${!!details}`);
if (details) {
console.log(`[Sidebar] Current details.open before restore: ${details.open}`);
if (storedState !== null) {
details.open = storedState === 'true';
console.log(`[Sidebar] Restored: ${key} = ${details.open}`);
} else {
console.log(`[Sidebar] No stored state found for ${key}, keeping current state: ${details.open}`);
}
console.log(`[Sidebar] Final details.open after restore: ${details.open}`);
}
}
}

View File

@ -0,0 +1,28 @@
// Types for our clean sidebar implementation
export interface SidebarLink {
label: string;
href: string;
slug?: string;
isCurrent?: boolean;
}
export type SortFunction = 'alphabetical' | 'date' | 'custom';
export interface SidebarGroup {
label: string;
items?: (SidebarLink | SidebarGroup)[];
autogenerate?: {
directory: string;
collapsed?: boolean;
maxDepth?: number;
sortBy?: SortFunction;
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number;
};
collapsed?: boolean;
isSubGroup?: boolean;
isPageLevel?: boolean; // For page-specific navigation
}
export interface SidebarConfig {
sidebar: SidebarGroup[];
}

View File

@ -0,0 +1,336 @@
import { getCollection } from 'astro:content';
import type { SidebarGroup, SidebarLink, SortFunction } from './types.js';
import path from 'path';
interface DirectoryStructure {
[key: string]: {
files: any[];
subdirs: DirectoryStructure;
};
}
/**
* Generate nested sidebar structure from a content collection directory
*/
export async function generateLinksFromDirectory(
directory: string,
currentPath?: string,
maxDepth: number = 2,
currentDepth: number = 0
): Promise<(SidebarLink | SidebarGroup)[]> {
return generateLinksFromDirectoryWithConfig(directory, currentPath, maxDepth, false, currentDepth);
}
/**
* Generate nested sidebar structure with configuration support
*/
export async function generateLinksFromDirectoryWithConfig(
directory: string,
currentPath?: string,
maxDepth: number = 2,
collapsedByDefault: boolean = false,
currentDepth: number = 0,
sortBy: SortFunction = 'alphabetical',
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number
): Promise<(SidebarLink | SidebarGroup)[]> {
try {
const entries = await getCollection(directory as any);
// Organize entries by directory structure
const structure = organizeByDirectory(entries);
return buildSidebarFromStructure(
structure,
directory,
currentPath,
maxDepth,
currentDepth,
collapsedByDefault,
sortBy,
customSort,
entries
);
} catch (error) {
console.warn(`Could not load collection "${directory}":`, error);
return [];
}
}
/**
* Organize entries into a directory structure
*/
function organizeByDirectory(entries: any[]): DirectoryStructure {
const structure: DirectoryStructure = {
'': { files: [], subdirs: {} }
};
entries.forEach(entry => {
const entryPath = entry.id;
const parts = entryPath.split('/');
parts.pop();
let parentSubdirs = structure[''].subdirs;
parts.forEach(part => {
if (!parentSubdirs[part]) {
parentSubdirs[part] = { files: [], subdirs: {} };
}
parentSubdirs = parentSubdirs[part].subdirs;
});
const parentPath = parts.join('/');
let parentNode = structure[''];
if(parentPath){
const parentParts = parentPath.split('/');
for (const part of parentParts) {
parentNode = parentNode.subdirs[part];
}
}
parentNode.files.push(entry);
});
return structure;
}
/**
* Build sidebar items from directory structure
*/
function buildSidebarFromStructure(
structure: DirectoryStructure,
baseDirectory: string,
currentPath?: string,
maxDepth: number = 2,
currentDepth: number = 0,
collapsedByDefault: boolean = false,
sortBy: SortFunction = 'alphabetical',
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number,
entries?: any[]
): (SidebarLink | SidebarGroup)[] {
const items: (SidebarLink | SidebarGroup)[] = [];
// Process root level files first
if (structure['']?.files) {
const rootFiles = structure[''].files
.filter(entry => !isPageHidden(entry))
.map(entry => createSidebarLink(entry, baseDirectory, currentPath));
// Add root files without sorting (will be sorted at the end)
items.push(...rootFiles);
}
// Process subdirectories if we haven't reached max depth
if (currentDepth < maxDepth) {
const subdirs = structure['']?.subdirs || {};
Object.entries(subdirs).forEach(([dirName, dirData]) => {
if (dirName === '') return; // Skip root files (already processed)
const subItems: (SidebarLink | SidebarGroup)[] = [];
// Add files in this subdirectory
if (dirData.files.length > 0) {
const subFiles = dirData.files
.filter(entry => !isPageHidden(entry))
.map(entry => createSidebarLink(entry, baseDirectory, currentPath));
subItems.push(...subFiles);
}
// Recursively add nested subdirectories
if (Object.keys(dirData.subdirs).length > 0) {
const nestedStructure = {'': {files: [], subdirs: dirData.subdirs}}
const nestedItems = buildSidebarFromStructure(
nestedStructure,
baseDirectory,
currentPath,
maxDepth,
currentDepth + 1,
collapsedByDefault,
sortBy,
customSort,
entries
);
subItems.push(...nestedItems);
}
if (subItems.length > 0) {
// Sort the subItems before adding to the group
const sortedSubItems = applySorting(subItems, sortBy, customSort, entries);
items.push({
label: formatDirectoryName(dirName),
items: sortedSubItems,
collapsed: currentDepth >= 1,
isSubGroup: true,
});
}
});
}
return applySorting(items, sortBy, customSort, entries);
}
/**
* Check if a page should be hidden from the sidebar based on frontmatter
*/
function isPageHidden(entry: any): boolean {
return entry.data?.sidebar?.hide === true;
}
/**
* Create a sidebar link from an entry
*/
function createSidebarLink(entry: any, baseDirectory: string, currentPath?: string): SidebarLink {
// Handle different collection schemas
let label = entry.id;
if (entry.data.title) {
label = entry.data.title;
} else if (entry.data.page) {
label = entry.data.page;
} else if (entry.data.name) {
label = entry.data.name;
}
// Clean up label if it includes directory path
if (label.includes('/')) {
label = label.split('/').pop() || label;
}
// Remove file extension from entry.id for clean URLs
const cleanId = entry.id.replace(/\.(md|mdx)$/, '');
return {
label,
href: `/${baseDirectory}/${cleanId}/`,
isCurrent: currentPath?.includes(`/${baseDirectory}/${cleanId}`) || false,
};
}
/**
* Format directory name for display
*/
function formatDirectoryName(dirName: string): string {
return dirName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Sort items alphabetically by label
*/
function sortAlphabetically(a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup): number {
const isAFile = 'href' in a;
const isBFile = 'href' in b;
if (isAFile && !isBFile) {
return -1; // a (file) comes before b (folder)
}
if (!isAFile && isBFile) {
return 1; // b (file) comes before a (folder)
}
// If both are files or both are folders, sort by label
return a.label.localeCompare(b.label);
}
/**
* Sort items by date (newest first) - requires entry data with pubDate
*/
function sortByDate(a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup, entries: any[]): number {
// Groups always come after files when sorting by date
if (!('href' in a) || !('href' in b)) {
return sortAlphabetically(a, b);
}
// Find corresponding entries for date comparison
const entryA = entries.find(entry => a.href?.includes(entry.id.replace(/\.(md|mdx)$/, '')));
const entryB = entries.find(entry => b.href?.includes(entry.id.replace(/\.(md|mdx)$/, '')));
if (entryA?.data?.pubDate && entryB?.data?.pubDate) {
const dateA = new Date(entryA.data.pubDate);
const dateB = new Date(entryB.data.pubDate);
return dateB.getTime() - dateA.getTime(); // Newest first
}
// Fallback to alphabetical if no dates
return sortAlphabetically(a, b);
}
/**
* Apply sorting to sidebar items
*/
function applySorting(
items: (SidebarLink | SidebarGroup)[],
sortBy: SortFunction = 'alphabetical',
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number,
entries?: any[]
): (SidebarLink | SidebarGroup)[] {
switch (sortBy) {
case 'date':
return items.sort((a, b) => sortByDate(a, b, entries || []));
case 'custom':
return customSort ? items.sort(customSort) : items.sort(sortAlphabetically);
case 'alphabetical':
default:
return items.sort(sortAlphabetically);
}
}
/**
* Process a sidebar group and generate links
*/
export async function processSidebarGroup(group: SidebarGroup, currentPath?: string): Promise<SidebarGroup> {
const processedGroup: SidebarGroup = {
label: group.label,
collapsed: group.collapsed,
isSubGroup: group.isSubGroup,
};
if (group.autogenerate) {
// Generate links from directory with nesting support
const maxDepth = group.autogenerate.maxDepth ?? 2;
const sortBy = group.autogenerate.sortBy ?? 'alphabetical';
const items = await generateLinksFromDirectoryWithConfig(
group.autogenerate.directory,
currentPath,
maxDepth,
group.autogenerate.collapsed ?? false, // Pass collapsed config to subdirectories
0, // currentDepth starts at 0
sortBy,
group.autogenerate.customSort
);
processedGroup.items = items;
processedGroup.collapsed = group.autogenerate.collapsed ?? group.collapsed;
} else if (group.items) {
// Process manual items (both links and nested groups)
processedGroup.items = await Promise.all(group.items.map(async item => {
if ('href' in item) {
// It's a link
return {
...item,
href: item.slug ? `/${item.slug}/` : item.href,
isCurrent: currentPath ?
(item.slug ? currentPath.includes(`/${item.slug}`) : currentPath === item.href) :
false,
};
} else {
// It's a nested group - process recursively
return await processSidebarGroup(item, currentPath);
}
}));
}
return processedGroup;
}
/**
* Get current path from Astro request
*/
export function getCurrentPath(url: URL | string): string {
try {
const urlObj = typeof url === 'string' ? new URL(url) : url;
return urlObj.pathname;
} catch {
return '';
}
}

View File

@ -0,0 +1,15 @@
import { stripLeadingSlash, stripTrailingSlash } from './path';
const base = stripTrailingSlash(import.meta.env.BASE_URL);
/** Get the a root-relative URL path with the sites `base` prefixed. */
export function pathWithBase(path: string) {
path = stripLeadingSlash(path);
return path ? base + '/' + path : base + '/';
}
/** Get the a root-relative file URL path with the sites `base` prefixed. */
export function fileWithBase(path: string) {
path = stripLeadingSlash(path);
return path ? base + '/' + path : base;
}

View File

@ -0,0 +1,19 @@
import type { AstroConfig } from 'astro';
import { ensureTrailingSlash, stripTrailingSlash } from './path';
export interface FormatCanonicalOptions {
format: AstroConfig['build']['format'];
trailingSlash: AstroConfig['trailingSlash'];
}
const canonicalTrailingSlashStrategies = {
always: ensureTrailingSlash,
never: stripTrailingSlash,
ignore: ensureTrailingSlash,
};
/** Format a canonical link based on the project config. */
export function formatCanonical(href: string, opts: FormatCanonicalOptions) {
if (opts.format === 'file') return href;
return canonicalTrailingSlashStrategies[opts.trailingSlash](href);
}

View File

@ -0,0 +1,21 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getCollectionUrl, type StarlightCollection } from './collection';
/**
* @see {@link file://./collection.ts} for more context about this file.
*
* Below are various functions to easily get paths to collections used in Starlight that rely on
* Node.js builtins. They exist in a separate file from {@link file://./collection.ts} to avoid
* potentially importing Node.js builtins in the final bundle.
*/
export function resolveCollectionPath(collection: StarlightCollection, srcDir: URL) {
return resolve(fileURLToPath(srcDir), `content/${collection}`);
}
export function getCollectionPosixPath(collection: StarlightCollection, srcDir: URL) {
// TODO: when Astro minimum Node.js version is >= 20.13.0, refactor to use the `fileURLToPath`
// second optional argument to enforce POSIX paths by setting `windows: false`.
return fileURLToPath(getCollectionUrl(collection, srcDir)).replace(/\\/g, '/');
}

View File

@ -0,0 +1,38 @@
const collectionNames = ['docs', 'i18n'] as const;
export type StarlightCollection = (typeof collectionNames)[number];
/**
* We still rely on the content collection folder structure to be fixed for now:
*
* - At build time, if the feature is enabled, we get all the last commit dates for each file in
* the docs folder ahead of time. In the current approach, we cannot know at this time the
* user-defined content folder path in the integration context as this would only be available
* from the loader. A potential solution could be to do that from a custom loader re-implementing
* the glob loader or built on top of it. Although, we don't have access to the Starlight
* configuration from the loader to even know we should do that.
* - Remark plugins get passed down an absolute path to a content file and we need to figure out
* the language from that path. Without knowing the content folder path, we cannot reliably do
* so.
*
* Below are various functions to easily get paths to these collections and avoid having to
* hardcode them throughout the codebase. When user-defined content folder locations are supported,
* these helper functions should be updated to reflect that in one place.
*/
export function getCollectionUrl(collection: StarlightCollection, srcDir: URL) {
return new URL(`content/${collection}/`, srcDir);
}
export function getCollectionPathFromRoot(
collection: StarlightCollection,
{ root, srcDir }: { root: URL | string; srcDir: URL | string }
) {
return (
(typeof srcDir === 'string' ? srcDir : srcDir.pathname).replace(
typeof root === 'string' ? root : root.pathname,
''
) +
'content/' +
collection
);
}

View File

@ -0,0 +1,60 @@
import type { AstroConfig } from 'astro';
import { fileWithBase, pathWithBase } from './base';
import {
ensureHtmlExtension,
ensureTrailingSlash,
stripHtmlExtension,
stripTrailingSlash,
} from './path';
interface FormatPathOptions {
format?: AstroConfig['build']['format'];
trailingSlash?: AstroConfig['trailingSlash'];
}
const defaultFormatStrategy = {
addBase: pathWithBase,
handleExtension: (href: string) => stripHtmlExtension(href),
};
const formatStrategies = {
file: {
addBase: fileWithBase,
handleExtension: (href: string) => ensureHtmlExtension(href),
},
directory: defaultFormatStrategy,
preserve: defaultFormatStrategy,
};
const trailingSlashStrategies = {
always: ensureTrailingSlash,
never: stripTrailingSlash,
ignore: (href: string) => href,
};
/** Format a path based on the project config. */
function formatPath(
href: string,
{ format = 'directory', trailingSlash = 'ignore' }: FormatPathOptions
) {
const formatStrategy = formatStrategies[format];
const trailingSlashStrategy = trailingSlashStrategies[trailingSlash];
// Handle extension
href = formatStrategy.handleExtension(href);
// Add base
href = formatStrategy.addBase(href);
// Skip trailing slash handling for `build.format: 'file'`
if (format === 'file') return href;
// Handle trailing slash
href = href === '/' ? href : trailingSlashStrategy(href);
return href;
}
export function createPathFormatter(opts: FormatPathOptions) {
return (href: string) => formatPath(href, opts);
}

View File

@ -0,0 +1,134 @@
import i18next, { type ExistsFunction, type TFunction } from 'i18next';
import type { i18nSchemaOutput } from '../schemas/i18n';
import builtinTranslations from '../translations/index';
import { BuiltInDefaultLocale } from './i18n';
import type { StarlightConfig } from './user-config';
import type { UserI18nKeys, UserI18nSchema } from './translations';
/**
* The namespace for i18next resources used by Starlight.
* All translations handled by Starlight are stored in the same namespace and Starlight always use
* a new instance of i18next configured for this namespace.
*/
export const I18nextNamespace = 'starlight' as const;
export function createTranslationSystem<T extends i18nSchemaOutput>(
config: Pick<StarlightConfig, 'defaultLocale' | 'locales'>,
userTranslations: Record<string, T>,
pluginTranslations: Record<string, T> = {}
) {
const defaultLocale =
config.defaultLocale.lang || config.defaultLocale?.locale || BuiltInDefaultLocale.lang;
const translations = {
[defaultLocale]: buildResources(
builtinTranslations[defaultLocale],
builtinTranslations[stripLangRegion(defaultLocale)],
pluginTranslations[defaultLocale],
userTranslations[defaultLocale]
),
};
if (config.locales) {
for (const locale in config.locales) {
const lang = localeToLang(locale, config.locales, config.defaultLocale);
translations[lang] = buildResources(
builtinTranslations[lang] || builtinTranslations[stripLangRegion(lang)],
pluginTranslations[lang],
userTranslations[lang]
);
}
}
const i18n = i18next.createInstance();
i18n.init({
resources: translations,
fallbackLng:
config.defaultLocale.lang || config.defaultLocale?.locale || BuiltInDefaultLocale.lang,
});
/**
* Generate a utility function that returns UI strings for the given language.
*
* Also includes a few utility methods:
* - `all()` method for getting the entire dictionary.
* - `exists()` method for checking if a key exists in the dictionary.
* - `dir()` method for getting the text direction of the locale.
*
* @param {string | undefined} [lang]
* @example
* const t = useTranslations('en');
* const label = t('search.label');
* // => 'Search'
* const dictionary = t.all();
* // => { 'skipLink.label': 'Skip to content', 'search.label': 'Search', ... }
* const exists = t.exists('search.label');
* // => true
* const dir = t.dir();
* // => 'ltr'
*/
return (lang: string | undefined) => {
lang ??= config.defaultLocale?.lang || BuiltInDefaultLocale.lang;
const t = i18n.getFixedT(lang, I18nextNamespace) as I18nT;
t.all = () => i18n.getResourceBundle(lang, I18nextNamespace);
t.exists = (key, options) => i18n.exists(key, { lng: lang, ns: I18nextNamespace, ...options });
t.dir = (dirLang = lang) => i18n.dir(dirLang);
return t;
};
}
/**
* Strips the region subtag from a BCP-47 lang string.
* @param {string} [lang]
* @example
* const lang = stripLangRegion('en-GB'); // => 'en'
*/
function stripLangRegion(lang: string) {
return lang.replace(/-[a-zA-Z]{2}/, '');
}
/**
* Get the BCP-47 language tag for the given locale.
* @param locale Locale string or `undefined` for the root locale.
*/
function localeToLang(
locale: string | undefined,
locales: StarlightConfig['locales'],
defaultLocale: StarlightConfig['defaultLocale']
): string {
const lang = locale ? locales?.[locale]?.lang : locales?.root?.lang;
const defaultLang = defaultLocale?.lang || defaultLocale?.locale;
return lang || defaultLang || BuiltInDefaultLocale.lang;
}
type BuiltInStrings = (typeof builtinTranslations)['en'];
/** Build an i18next resources dictionary by layering preferred translation sources. */
function buildResources<T extends Record<string, string | undefined>>(
...dictionaries: (T | BuiltInStrings | undefined)[]
): { [I18nextNamespace]: BuiltInStrings & T } {
const dictionary: Partial<BuiltInStrings> = {};
// Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`.
for (const dict of dictionaries) {
for (const key in dict) {
const value = dict[key as keyof typeof dict];
if (value) dictionary[key as keyof typeof dictionary] = value;
}
}
return { [I18nextNamespace]: dictionary as BuiltInStrings & T };
}
// `keyof BuiltInStrings` and `UserI18nKeys` may contain some identical keys, e.g. the built-in UI
// strings. We let TypeScript merge them into a single union type so that plugins with a TypeScript
// configuration preventing `UserI18nKeys` to be properly inferred can still get auto-completion
// for built-in UI strings.
export type I18nKeys = keyof BuiltInStrings | UserI18nKeys | keyof StarlightApp.I18n;
export type I18nT = TFunction<'starlight', undefined> & {
all: () => UserI18nSchema;
exists: ExistsFunction;
dir: (lang?: string) => 'ltr' | 'rtl';
};

View File

@ -0,0 +1,172 @@
/**
* This is a modified version of Astro's error map.
* source: https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts
*/
import { AstroError } from 'astro/errors';
import type { z } from 'astro:content';
type TypeOrLiteralErrByPathEntry = {
code: 'invalid_type' | 'invalid_literal';
received: unknown;
expected: unknown[];
};
/**
* Parse data with a Zod schema and throw a nicely formatted error if it is invalid.
*
* @param schema The Zod schema to use to parse the input.
* @param input Input data that should match the schema.
* @param message Error message preamble to use if the input fails to parse.
* @returns Validated data parsed by Zod.
*/
export function parseWithFriendlyErrors<T extends z.Schema>(
schema: T,
input: z.input<T>,
message: string
): z.output<T> {
return processParsedData(schema.safeParse(input, { errorMap }), message);
}
/**
* Asynchronously parse data with a Zod schema that contains asynchronous refinements or transforms
* and throw a nicely formatted error if it is invalid.
*
* @param schema The Zod schema to use to parse the input.
* @param input Input data that should match the schema.
* @param message Error message preamble to use if the input fails to parse.
* @returns Validated data parsed by Zod.
*/
export async function parseAsyncWithFriendlyErrors<T extends z.Schema>(
schema: T,
input: z.input<T>,
message: string
): Promise<z.output<T>> {
return processParsedData(await schema.safeParseAsync(input, { errorMap }), message);
}
function processParsedData(parsedData: z.SafeParseReturnType<any, any>, message: string) {
if (!parsedData.success) {
throw new AstroError(message, parsedData.error.issues.map((i) => i.message).join('\n'));
}
return parsedData.data;
}
const errorMap: z.ZodErrorMap = (baseError, ctx) => {
const baseErrorPath = flattenErrorPath(baseError.path);
if (baseError.code === 'invalid_union') {
// Optimization: Combine type and literal errors for keys that are common across ALL union types
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
// raise a single error when `key` does not match:
// > Did not match union.
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
let typeOrLiteralErrByPath: Map<string, TypeOrLiteralErrByPathEntry> = new Map();
for (const unionError of baseError.unionErrors.map((e) => e.errors).flat()) {
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
const flattenedErrorPath = flattenErrorPath(unionError.path);
if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected);
} else {
typeOrLiteralErrByPath.set(flattenedErrorPath, {
code: unionError.code,
received: (unionError as any).received,
expected: [unionError.expected],
});
}
}
}
const messages: string[] = [prefix(baseErrorPath, 'Did not match union.')];
const details: string[] = [...typeOrLiteralErrByPath.entries()]
// If type or literal error isn't common to ALL union types,
// filter it out. Can lead to confusing noise.
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
.map(([key, error]) =>
key === baseErrorPath
? // Avoid printing the key again if it's a base error
`> ${getTypeOrLiteralMsg(error)}`
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`
);
if (details.length === 0) {
const expectedShapes: string[] = [];
for (const unionError of baseError.unionErrors) {
const expectedShape: string[] = [];
for (const issue of unionError.issues) {
// If the issue is a nested union error, show the associated error message instead of the
// base error message.
if (issue.code === 'invalid_union') {
return errorMap(issue, ctx);
}
const relativePath = flattenErrorPath(issue.path)
.replace(baseErrorPath, '')
.replace(leadingPeriod, '');
if ('expected' in issue && typeof issue.expected === 'string') {
expectedShape.push(
relativePath ? `${relativePath}: ${issue.expected}` : issue.expected
);
} else {
expectedShape.push(relativePath);
}
}
if (expectedShape.length === 1 && !expectedShape[0]?.includes(':')) {
// In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`.
expectedShapes.push(expectedShape.join(''));
} else {
expectedShapes.push(`{ ${expectedShape.join('; ')} }`);
}
}
if (expectedShapes.length) {
details.push('> Expected type `' + expectedShapes.join(' | ') + '`');
details.push('> Received `' + stringify(ctx.data) + '`');
}
}
return {
message: messages.concat(details).join('\n'),
};
} else if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
return {
message: prefix(
baseErrorPath,
getTypeOrLiteralMsg({
code: baseError.code,
received: (baseError as any).received,
expected: [baseError.expected],
})
),
};
} else if (baseError.message) {
return { message: prefix(baseErrorPath, baseError.message) };
} else {
return { message: prefix(baseErrorPath, ctx.defaultError) };
}
};
const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
// received could be `undefined` or the string `'undefined'`
if (typeof error.received === 'undefined' || error.received === 'undefined') return 'Required';
const expectedDeduped = new Set(error.expected);
switch (error.code) {
case 'invalid_type':
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
error.received
)}\``;
case 'invalid_literal':
return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
error.received
)}\``;
}
};
const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);
const unionExpectedVals = (expectedVals: Set<unknown>) =>
[...expectedVals].map((expectedVal) => stringify(expectedVal)).join(' | ');
const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
/** `JSON.stringify()` a value with spaces around object/array entries. */
const stringify = (val: unknown) =>
JSON.stringify(val, null, 1).split(newlinePlusWhitespace).join(' ');
const newlinePlusWhitespace = /\n\s*/;
const leadingPeriod = /^\./;

View File

@ -0,0 +1,7 @@
import project from 'virtual:starlight/project-context';
import { createPathFormatter } from './createPathFormatter';
export const formatPath = createPathFormatter({
format: project.build.format,
trailingSlash: project.trailingSlash,
});

View File

@ -0,0 +1,33 @@
import type { MarkdownHeading } from 'astro';
import { PAGE_TITLE_ID } from '../constants.js';
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}
interface TocOpts {
minHeadingLevel: number;
maxHeadingLevel: number;
title: string;
}
/** Convert the flat headings array generated by Astro into a nested tree structure. */
export function generateToC(
headings: MarkdownHeading[],
{ minHeadingLevel, maxHeadingLevel, title }: TocOpts
) {
headings = headings.filter(({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel);
const toc: Array<TocItem> = [{ depth: 2, slug: PAGE_TITLE_ID, text: title, children: [] }];
for (const heading of headings) injectChild(toc, { ...heading, children: [] });
return toc;
}
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
function injectChild(items: TocItem[], item: TocItem): void {
const lastItem = items.at(-1);
if (!lastItem || lastItem.depth >= item.depth) {
items.push(item);
} else {
return injectChild(lastItem.children, item);
}
}

View File

@ -0,0 +1,121 @@
/**
* Git module to be used from the dev server and from the integration.
*/
import { basename, dirname, relative, resolve } from 'node:path';
import { realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
export type GitAPI = {
getNewestCommitDate: (file: string) => Date;
};
export const makeAPI = (directory: string): GitAPI => {
return {
getNewestCommitDate: (file) => getNewestCommitDate(resolve(directory, file)),
};
};
export function getNewestCommitDate(file: string): Date {
const result = spawnSync('git', ['log', '--format=%ct', '--max-count=1', basename(file)], {
cwd: dirname(file),
encoding: 'utf-8',
});
if (result.error) {
throw new Error(`Failed to retrieve the git history for file "${file}"`);
}
const output = result.stdout.trim();
const regex = /^(?<timestamp>\d+)$/;
const match = output.match(regex);
if (!match?.groups?.timestamp) {
throw new Error(`Failed to validate the timestamp for file "${file}"`);
}
const timestamp = Number(match.groups.timestamp);
const date = new Date(timestamp * 1000);
return date;
}
function getRepoRoot(directory: string): string {
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd: directory,
encoding: 'utf-8',
});
if (result.error) {
return directory;
}
try {
return realpathSync(result.stdout.trim());
} catch {
return directory;
}
}
export function getAllNewestCommitDate(rootPath: string, docsPath: string): [string, number][] {
const repoRoot = getRepoRoot(docsPath);
const gitLog = spawnSync(
'git',
[
'log',
// Format each history entry as t:<seconds since epoch>
'--format=t:%ct',
// In each entry include the name and status for each modified file
'--name-status',
'--',
docsPath,
],
{
cwd: repoRoot,
encoding: 'utf-8',
// The default `maxBuffer` for `spawnSync` is 1024 * 1024 bytes, a.k.a 1 MB. In big projects,
// the full git history can be larger than this, so we increase this to ~10 MB. For example,
// Cloudflare passed 1 MB with ~4,800 pages and ~17,000 commits. If we get reports of others
// hitting ENOBUFS errors here in the future, we may want to switch to streaming the git log
// with `spawn` instead.
// See https://github.com/withastro/starlight/issues/3154
maxBuffer: 10 * 1024 * 1024,
}
);
if (gitLog.error) {
return [];
}
let runningDate = Date.now();
const latestDates = new Map<string, number>();
for (const logLine of gitLog.stdout.split('\n')) {
if (logLine.startsWith('t:')) {
// t:<seconds since epoch>
runningDate = Number.parseInt(logLine.slice(2)) * 1000;
}
// - Added files take the format `A\t<file>`
// - Modified files take the format `M\t<file>`
// - Deleted files take the format `D\t<file>`
// - Renamed files take the format `R<count>\t<old>\t<new>`
// - Copied files take the format `C<count>\t<old>\t<new>`
// The name of the file as of the commit being processed is always
// the last part of the log line.
const tabSplit = logLine.lastIndexOf('\t');
if (tabSplit === -1) continue;
const fileName = logLine.slice(tabSplit + 1);
const currentLatest = latestDates.get(fileName) || 0;
latestDates.set(fileName, Math.max(currentLatest, runningDate));
}
return Array.from(latestDates.entries()).map(([file, date]) => {
const fileFullPath = resolve(repoRoot, file);
let fileInDirectory = relative(rootPath, fileFullPath);
// Format path to unix style path.
fileInDirectory = fileInDirectory?.replace(/\\/g, '/');
return [fileInDirectory, date];
});
}

View File

@ -0,0 +1,20 @@
/**
* Git module to be used on production build results.
* The API is based on inlined git information.
*/
import type { GitAPI, getAllNewestCommitDate } from './git';
type InlinedData = ReturnType<typeof getAllNewestCommitDate>;
export const makeAPI = (data: InlinedData): GitAPI => {
const trackedDocsFiles = new Map(data);
return {
getNewestCommitDate: (file) => {
const timestamp = trackedDocsFiles.get(file);
if (!timestamp) throw new Error(`Failed to retrieve the git history for file "${file}"`);
return new Date(timestamp);
},
};
};

View File

@ -0,0 +1,216 @@
import config from 'virtual:starlight/user-config';
import project from 'virtual:starlight/project-context';
import { version } from '../package.json';
import { type HeadConfig, HeadConfigSchema, type HeadUserConfig } from '../schemas/head';
import type { PageProps, RouteDataContext } from './routing/data';
import { fileWithBase } from './base';
import { formatCanonical } from './canonical';
import { localizedUrl } from './localizedUrl';
const HeadSchema = HeadConfigSchema();
/** Get the head for the current page. */
export function getHead(
{ entry, lang }: PageProps,
context: RouteDataContext,
siteTitle: string
): HeadConfig {
const { data } = entry;
const canonical = context.site ? new URL(context.url.pathname, context.site) : undefined;
const canonicalHref = canonical?.href
? formatCanonical(canonical.href, {
format: project.build.format,
trailingSlash: project.trailingSlash,
})
: undefined;
const description = data.description || config.description;
const headDefaults: HeadUserConfig = [
{ tag: 'meta', attrs: { charset: 'utf-8' } },
{
tag: 'meta',
attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
},
{ tag: 'title', content: `${data.title} ${config.titleDelimiter} ${siteTitle}` },
{ tag: 'link', attrs: { rel: 'canonical', href: canonicalHref } },
{ tag: 'meta', attrs: { name: 'generator', content: context.generator } },
{
tag: 'meta',
attrs: { name: 'generator', content: `Starlight v${version}` },
},
// Favicon
{
tag: 'link',
attrs: {
rel: 'shortcut icon',
href: fileWithBase(config.favicon.href),
type: config.favicon.type,
},
},
// OpenGraph Tags
{ tag: 'meta', attrs: { property: 'og:title', content: data.title } },
{ tag: 'meta', attrs: { property: 'og:type', content: 'article' } },
{ tag: 'meta', attrs: { property: 'og:url', content: canonicalHref } },
{ tag: 'meta', attrs: { property: 'og:locale', content: lang } },
{ tag: 'meta', attrs: { property: 'og:description', content: description } },
{ tag: 'meta', attrs: { property: 'og:site_name', content: siteTitle } },
// Twitter Tags
{
tag: 'meta',
attrs: { name: 'twitter:card', content: 'summary_large_image' },
},
];
if (description)
headDefaults.push({
tag: 'meta',
attrs: { name: 'description', content: description },
});
// Link to language alternates.
if (canonical && config.isMultilingual) {
for (const locale in config.locales) {
const localeOpts = config.locales[locale];
if (!localeOpts) continue;
headDefaults.push({
tag: 'link',
attrs: {
rel: 'alternate',
hreflang: localeOpts.lang,
href: localizedUrl(canonical, locale, project.trailingSlash).href,
},
});
}
}
// Link to sitemap, but only when `site` is set.
if (context.site) {
headDefaults.push({
tag: 'link',
attrs: {
rel: 'sitemap',
href: fileWithBase('/sitemap-index.xml'),
},
});
}
// Link to Twitter account if set in Starlight config.
const twitterLink = config.social?.find(({ icon }) => icon === 'twitter' || icon === 'x.com');
if (twitterLink) {
headDefaults.push({
tag: 'meta',
attrs: {
name: 'twitter:site',
content: new URL(twitterLink.href).pathname.replace('/', '@'),
},
});
}
return createHead(headDefaults, config.head, data.head);
}
/** Create a fully parsed, merged, and sorted head entry array from multiple sources. */
function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
let head = HeadSchema.parse(defaults);
for (const next of heads) {
head = mergeHead(head, next);
}
return sortHead(head);
}
/**
* Test if a head config object contains a matching `<title>` or `<meta>` or `<link rel="canonical">` tag.
*
* For example, will return true if `head` already contains
* `<meta name="description" content="A">` and the passed `tag`
* is `<meta name="description" content="B">`. Tests against `name`,
* `property`, and `http-equiv` attributes for `<meta>` tags.
*/
function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean {
switch (entry.tag) {
case 'title':
return head.some(({ tag }) => tag === 'title');
case 'meta':
return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
case 'link':
return head.some(
({ attrs }) => entry.attrs?.rel === 'canonical' && attrs?.rel === 'canonical'
);
default:
return false;
}
}
/**
* Test if a head config object contains a tag of the same type
* as `entry` and a matching attribute for one of the passed `keys`.
*/
function hasOneOf(head: HeadConfig, entry: HeadConfig[number], keys: string[]): boolean {
const attr = getAttr(keys, entry);
if (!attr) return false;
const [key, val] = attr;
return head.some(({ tag, attrs }) => tag === entry.tag && attrs?.[key] === val);
}
/** Find the first matching keyvalue pair in a head entrys attributes. */
function getAttr(
keys: string[],
entry: HeadConfig[number]
): [key: string, value: string | boolean] | undefined {
let attr: [string, string | boolean] | undefined;
for (const key of keys) {
const val = entry.attrs?.[key];
if (val) {
attr = [key, val];
break;
}
}
return attr;
}
/** Merge two heads, overwriting entries in the first head that exist in the second. */
function mergeHead(oldHead: HeadConfig, newHead: HeadConfig) {
return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead];
}
/** Sort head tags to place important tags first and relegate “SEO” meta tags. */
function sortHead(head: HeadConfig) {
return head.sort((a, b) => {
const aImportance = getImportance(a);
const bImportance = getImportance(b);
return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0;
});
}
/** Get the relative importance of a specific head tag. */
function getImportance(entry: HeadConfig[number]) {
// 1. Important meta tags.
if (
entry.tag === 'meta' &&
entry.attrs &&
('charset' in entry.attrs || 'http-equiv' in entry.attrs || entry.attrs.name === 'viewport')
) {
return 100;
}
// 2. Page title
if (entry.tag === 'title') return 90;
// 3. Anything that isnt an SEO meta tag.
if (entry.tag !== 'meta') {
// The default favicon should be below any extra icons that the user may have set
// because if several icons are equally appropriate, the last one is used and we
// want to use the SVG icon when supported.
if (
entry.tag === 'link' &&
entry.attrs &&
'rel' in entry.attrs &&
entry.attrs.rel === 'shortcut icon'
) {
return 70;
}
return 80;
}
// 4. SEO meta tags.
return 0;
}

View File

@ -0,0 +1,215 @@
import type { AstroConfig } from 'astro';
import { AstroError } from 'astro/errors';
import type { StarlightConfig } from './user-config';
/**
* A list of well-known right-to-left languages used as a fallback when determining the text
* direction of a locale is not supported by the `Intl.Locale` API in the current environment.
*
* @see getLocaleDir()
* @see https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags
*/
const wellKnownRTL = ['ar', 'fa', 'he', 'prs', 'ps', 'syc', 'ug', 'ur'];
/** Informations about the built-in default locale used as a fallback when no locales are defined. */
export const BuiltInDefaultLocale = { ...getLocaleInfo('en'), lang: 'en' };
/**
* Processes the Astro and Starlight i18n configurations to generate/update them accordingly:
*
* - If no Astro and Starlight i18n configurations are provided, the built-in default locale is
* used in Starlight and the generated Astro i18n configuration will match it.
* - If only a Starlight i18n configuration is provided, an equivalent Astro i18n configuration is
* generated.
* - If only an Astro i18n configuration is provided, an equivalent Starlight i18n configuration is
* used.
* - If both an Astro and Starlight i18n configurations are provided, an error is thrown.
*/
export function processI18nConfig(
starlightConfig: StarlightConfig,
astroI18nConfig: AstroConfig['i18n']
) {
// We don't know what to do if both an Astro and Starlight i18n configuration are provided.
if (astroI18nConfig && !starlightConfig.isUsingBuiltInDefaultLocale) {
throw new AstroError(
'Cannot provide both an Astro `i18n` configuration and a Starlight `locales` configuration.',
'Remove one of the two configurations.\nSee more at https://starlight.astro.build/guides/i18n/'
);
} else if (astroI18nConfig) {
// If a Starlight compatible Astro i18n configuration is provided, we generate the matching
// Starlight configuration.
return {
astroI18nConfig,
starlightConfig: {
...starlightConfig,
...getStarlightI18nConfig(astroI18nConfig),
} as StarlightConfig,
};
}
// Otherwise, we generate the Astro i18n configuration based on the Starlight configuration.
return { astroI18nConfig: getAstroI18nConfig(starlightConfig), starlightConfig: starlightConfig };
}
/** Generate an Astro i18n configuration based on a Starlight configuration. */
function getAstroI18nConfig(config: StarlightConfig): NonNullable<AstroConfig['i18n']> {
return {
// When using custom locale `path`s, the default locale must match one of these paths.
// In Starlight, this matches the `locale` property if defined, and we fallback to the `lang`
// property if not (which would be set to the languages directory name by default).
defaultLocale:
// If the default locale is explicitly set to `root`, we use the `lang` property instead.
(config.defaultLocale.locale === 'root'
? config.defaultLocale.lang
: (config.defaultLocale.locale ?? config.defaultLocale.lang)) ?? BuiltInDefaultLocale.lang,
locales: config.locales
? Object.entries(config.locales).map(([locale, localeConfig]) => {
return {
codes: [localeConfig?.lang ?? locale],
path: locale === 'root' ? (localeConfig?.lang ?? BuiltInDefaultLocale.lang) : locale,
};
})
: [config.defaultLocale.lang],
routing: {
prefixDefaultLocale:
// Sites with multiple languages without a root locale.
(config.isMultilingual && config.locales?.root === undefined) ||
// Sites with a single non-root language different from the built-in default locale.
(!config.isMultilingual && config.locales !== undefined),
redirectToDefaultLocale: false,
fallbackType: 'redirect',
},
};
}
/** Generate a Starlight i18n configuration based on an Astro configuration. */
function getStarlightI18nConfig(
astroI18nConfig: NonNullable<AstroConfig['i18n']>
): Pick<StarlightConfig, 'isMultilingual' | 'locales' | 'defaultLocale'> {
if (astroI18nConfig.routing === 'manual') {
throw new AstroError(
'Starlight is not compatible with the `manual` routing option in the Astro i18n configuration.'
);
}
const prefixDefaultLocale = astroI18nConfig.routing.prefixDefaultLocale;
const isMultilingual = astroI18nConfig.locales.length > 1;
const isMonolingualWithRootLocale = !isMultilingual && !prefixDefaultLocale;
const locales = isMonolingualWithRootLocale
? undefined
: Object.fromEntries(
astroI18nConfig.locales.map((locale) => [
isDefaultAstroLocale(astroI18nConfig, locale) && !prefixDefaultLocale
? 'root'
: isAstroLocaleExtendedConfig(locale)
? locale.path
: locale,
inferStarlightLocaleFromAstroLocale(locale),
])
);
const defaultAstroLocale = astroI18nConfig.locales.find((locale) =>
isDefaultAstroLocale(astroI18nConfig, locale)
);
// This should never happen as Astro validation should prevent this case.
if (!defaultAstroLocale) {
throw new AstroError(
'Astro default locale not found.',
'This should never happen. Please open a new issue: https://github.com/withastro/starlight/issues/new?template=---01-bug-report.yml'
);
}
return {
isMultilingual,
locales,
defaultLocale: {
...inferStarlightLocaleFromAstroLocale(defaultAstroLocale),
locale:
isMonolingualWithRootLocale || (isMultilingual && !prefixDefaultLocale)
? undefined
: isAstroLocaleExtendedConfig(defaultAstroLocale)
? defaultAstroLocale.codes[0]
: defaultAstroLocale,
},
};
}
/** Infer Starlight locale informations based on a locale from an Astro i18n configuration. */
function inferStarlightLocaleFromAstroLocale(astroLocale: AstroLocale) {
const lang = isAstroLocaleExtendedConfig(astroLocale) ? astroLocale.codes[0] : astroLocale;
return { ...getLocaleInfo(lang), lang };
}
/** Check if the passed locale is the default locale in an Astro i18n configuration. */
function isDefaultAstroLocale(
astroI18nConfig: NonNullable<AstroConfig['i18n']>,
locale: AstroLocale
) {
return (
(isAstroLocaleExtendedConfig(locale) ? locale.path : locale) === astroI18nConfig.defaultLocale
);
}
/**
* Check if the passed Astro locale is using the object variant.
* @see AstroLocaleExtendedConfig
*/
function isAstroLocaleExtendedConfig(locale: AstroLocale): locale is AstroLocaleExtendedConfig {
return typeof locale !== 'string';
}
/** Returns the locale informations such as a label and a direction based on a BCP-47 tag. */
function getLocaleInfo(lang: string) {
try {
const locale = new Intl.Locale(lang);
const label = new Intl.DisplayNames(locale, { type: 'language' }).of(lang);
if (!label || lang === label) throw new Error('Label not found.');
return {
label: label[0]?.toLocaleUpperCase(locale) + label.slice(1),
dir: getLocaleDir(locale),
};
} catch (error) {
throw new AstroError(
`Failed to get locale informations for the '${lang}' locale.`,
'Make sure to provide a valid BCP-47 tags (e.g. en, ar, or zh-CN).'
);
}
}
/**
* Returns the direction of the passed locale.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getTextInfo
*/
function getLocaleDir(locale: Intl.Locale): 'ltr' | 'rtl' {
if ('textInfo' in locale) {
// @ts-expect-error - `textInfo` is not typed but is available in v8 based environments.
return locale.textInfo.direction;
} else if ('getTextInfo' in locale) {
// @ts-expect-error - `getTextInfo` is not typed but is available in some non-v8 based environments.
return locale.getTextInfo().direction;
}
// Firefox does not support `textInfo` or `getTextInfo` yet so we fallback to a well-known list
// of right-to-left languages.
return wellKnownRTL.includes(locale.language) ? 'rtl' : 'ltr';
}
/**
* Get the string for the passed language from a dictionary object.
*
* TODO: Make this clever. Currently a simple key look-up, but should use
* BCP-47 mapping so that e.g. `en-US` returns `en` strings, and use the
* sites default locale as a last resort.
*
* @example
* pickLang({ en: 'Hello', fr: 'Bonjour' }, 'en'); // => 'Hello'
*/
export function pickLang<T extends Record<string, string>>(
dictionary: T,
lang: keyof T
): string | undefined {
return dictionary[lang];
}
type AstroLocale = NonNullable<AstroConfig['i18n']>['locales'][number];
type AstroLocaleExtendedConfig = Exclude<AstroLocale, string>;

View File

@ -0,0 +1,51 @@
import config from 'virtual:starlight/user-config';
import { stripTrailingSlash } from './path';
import type { AstroConfig } from 'astro';
/**
* Get the equivalent of the passed URL for the passed locale.
*/
export function localizedUrl(
url: URL,
locale: string | undefined,
trailingSlash: AstroConfig['trailingSlash']
): URL {
// Create a new URL object to void mutating the global.
url = new URL(url);
if (!config.locales) {
// i18n is not configured on this site, no localization required.
return url;
}
if (locale === 'root') locale = '';
/** Base URL with trailing `/` stripped. */
const base = stripTrailingSlash(import.meta.env.BASE_URL);
const hasBase = url.pathname.startsWith(base);
// Temporarily remove base to simplify
if (hasBase) url.pathname = url.pathname.replace(base, '');
const [_leadingSlash, baseSegment] = url.pathname.split('/');
// Strip .html extension to handle file output builds where URL might be e.g. `/en.html`
const htmlExt = '.html';
const isRootHtml = baseSegment?.endsWith(htmlExt);
const baseSlug = isRootHtml ? baseSegment?.slice(0, -1 * htmlExt.length) : baseSegment;
if (baseSlug && baseSlug in config.locales) {
// Were in a localized route, substitute the new locale (or strip for root lang).
if (locale) {
url.pathname = url.pathname.replace(baseSlug, locale);
} else if (isRootHtml) {
url.pathname = '/index.html';
} else {
url.pathname = url.pathname.replace('/' + baseSlug, '');
}
} else if (locale) {
// Were in the root language. Inject the new locale if we have one.
if (baseSegment === 'index.html') {
url.pathname = '/' + locale + '.html';
} else {
url.pathname = '/' + locale + url.pathname;
}
}
// Restore base
if (hasBase) url.pathname = base + url.pathname;
if (trailingSlash === 'never') url.pathname = stripTrailingSlash(url.pathname);
return url;
}

View File

@ -0,0 +1,548 @@
import { AstroError } from 'astro/errors';
import project from 'virtual:starlight/project-context';
import config from 'virtual:starlight/user-config';
import type { Badge, I18nBadge, I18nBadgeConfig } from '../schemas/badge.js';
import type { PrevNextLinkConfig } from '../schemas/prevNextLink.js';
import type {
AutoSidebarGroup,
InternalSidebarLinkItem,
LinkHTMLAttributes,
SidebarItem,
SidebarLinkItem,
} from '../schemas/sidebar.js';
import { getCollectionPathFromRoot } from './collection.js';
import { createPathFormatter } from './createPathFormatter.js';
import { formatPath } from './format-path.js';
import { BuiltInDefaultLocale, pickLang } from './i18n.js';
import {
ensureLeadingSlash,
ensureTrailingSlash,
stripExtension,
stripLeadingAndTrailingSlashes,
} from './path.js';
import { getLocaleRoutes, routes } from './routing/index.js';
import type {
SidebarGroup,
SidebarLink,
PaginationLinks,
Route,
SidebarEntry,
} from './routing/types';
import { localeToLang, localizedId, slugToPathname } from './slugs';
import type { StarlightConfig } from './user-config';
const DirKey = Symbol('DirKey');
const SlugKey = Symbol('SlugKey');
const neverPathFormatter = createPathFormatter({ trailingSlash: 'never' });
const docsCollectionPathFromRoot = getCollectionPathFromRoot('docs', project);
/**
* A representation of the route structure. For each object entry:
* if its a folder, the key is the directory name, and value is the directory
* content; if its a route entry, the key is the last segment of the route, and value
* is the full entry.
*/
interface Dir {
[DirKey]: undefined;
[SlugKey]: string;
[item: string]: Dir | Route;
}
/** Create a new directory object. */
function makeDir(slug: string): Dir {
const dir = {} as Dir;
// Add DirKey and SlugKey as non-enumerable properties so that `Object.entries(dir)` ignores them.
Object.defineProperty(dir, DirKey, { enumerable: false });
Object.defineProperty(dir, SlugKey, { value: slug, enumerable: false });
return dir;
}
/** Test if the passed object is a directory record. */
function isDir(data: Record<string, unknown>): data is Dir {
return DirKey in data;
}
/** Convert an item in a users sidebar config to a sidebar entry. */
function configItemToEntry(
item: SidebarItem,
currentPathname: string,
locale: string | undefined,
routes: Route[]
): SidebarEntry {
if ('link' in item) {
return linkFromSidebarLinkItem(item, locale);
} else if ('autogenerate' in item) {
return groupFromAutogenerateConfig(item, locale, routes, currentPathname);
} else if ('slug' in item) {
return linkFromInternalSidebarLinkItem(item, locale);
} else {
const label = pickLang(item.translations, localeToLang(locale)) || item.label;
return {
type: 'group',
label,
entries: item.items.map((i) => configItemToEntry(i, currentPathname, locale, routes)),
collapsed: item.collapsed,
badge: getSidebarBadge(item.badge, locale, label),
};
}
}
/** Autogenerate a group of links from a users sidebar config. */
function groupFromAutogenerateConfig(
item: AutoSidebarGroup,
locale: string | undefined,
routes: Route[],
currentPathname: string
): SidebarGroup {
const { attrs, collapsed: subgroupCollapsed, directory } = item.autogenerate;
const localeDir = locale ? locale + '/' + directory : directory;
const dirDocs = routes.filter((doc) => {
const filePathFromContentDir = getRoutePathRelativeToCollectionRoot(doc, locale);
return (
// Match against `foo.md` or `foo/index.md`.
stripExtension(filePathFromContentDir) === localeDir ||
// Match against `foo/anything/else.md`.
filePathFromContentDir.startsWith(localeDir + '/')
);
});
const tree = treeify(dirDocs, locale, localeDir);
const label = pickLang(item.translations, localeToLang(locale)) || item.label;
return {
type: 'group',
label,
entries: sidebarFromDir(
tree,
currentPathname,
locale,
subgroupCollapsed ?? item.collapsed,
attrs
),
collapsed: item.collapsed,
badge: getSidebarBadge(item.badge, locale, label),
};
}
/** Check if a string starts with one of `http://` or `https://`. */
const isAbsolute = (link: string) => /^https?:\/\//.test(link);
/** Create a link entry from a manual link item in user config. */
function linkFromSidebarLinkItem(item: SidebarLinkItem, locale: string | undefined) {
let href = item.link;
if (!isAbsolute(href)) {
href = ensureLeadingSlash(href);
// Inject current locale into link.
if (locale) href = '/' + locale + href;
}
const label = pickLang(item.translations, localeToLang(locale)) || item.label;
return makeSidebarLink(href, label, getSidebarBadge(item.badge, locale, label), item.attrs);
}
/** Create a link entry from an automatic internal link item in user config. */
function linkFromInternalSidebarLinkItem(
item: InternalSidebarLinkItem,
locale: string | undefined
) {
// Astro passes root `index.[md|mdx]` entries with a slug of `index`
const slug = item.slug === 'index' ? '' : item.slug;
const localizedSlug = locale ? (slug ? locale + '/' + slug : locale) : slug;
const route = routes.find((entry) => localizedSlug === entry.slug);
if (!route) {
const hasExternalSlashes = item.slug.at(0) === '/' || item.slug.at(-1) === '/';
if (hasExternalSlashes) {
throw new AstroError(
`The slug \`"${item.slug}"\` specified in the Starlight sidebar config must not start or end with a slash.`,
`Please try updating \`"${item.slug}"\` to \`"${stripLeadingAndTrailingSlashes(item.slug)}"\`.`
);
} else {
throw new AstroError(
`The slug \`"${item.slug}"\` specified in the Starlight sidebar config does not exist.`,
'Update the Starlight config to reference a valid entry slug in the docs content collection.\n' +
'Learn more about Astro content collection slugs at https://docs.astro.build/en/reference/modules/astro-content/#getentry'
);
}
}
const frontmatter = route.entry.data;
const label =
pickLang(item.translations, localeToLang(locale)) ||
item.label ||
frontmatter.sidebar?.label ||
frontmatter.title;
const badge = item.badge ?? frontmatter.sidebar?.badge;
const attrs = { ...frontmatter.sidebar?.attrs, ...item.attrs };
return makeSidebarLink(
slugToPathname(route.slug),
label,
getSidebarBadge(badge, locale, label),
attrs
);
}
/** Process sidebar link options to create a link entry. */
function makeSidebarLink(
href: string,
label: string,
badge?: Badge,
attrs?: LinkHTMLAttributes
): SidebarLink {
if (!isAbsolute(href)) {
href = formatPath(href);
}
return makeLink({ label, href, badge, attrs });
}
/** Create a link entry */
function makeLink({
attrs = {},
badge = undefined,
...opts
}: {
label: string;
href: string;
badge?: Badge | undefined;
attrs?: LinkHTMLAttributes | undefined;
}): SidebarLink {
return { type: 'link', ...opts, badge, isCurrent: false, attrs };
}
/** Test if two paths are equivalent even if formatted differently. */
function pathsMatch(pathA: string, pathB: string) {
return neverPathFormatter(pathA) === neverPathFormatter(pathB);
}
/** Get the segments leading to a page. */
function getBreadcrumbs(path: string, baseDir: string): string[] {
// Strip extension from path.
const pathWithoutExt = stripExtension(path);
// Index paths will match `baseDir` and dont include breadcrumbs.
if (pathWithoutExt === baseDir) return [];
// Ensure base directory ends in a trailing slash.
baseDir = ensureTrailingSlash(baseDir);
// Strip base directory from path if present.
const relativePath = pathWithoutExt.startsWith(baseDir)
? pathWithoutExt.replace(baseDir, '')
: pathWithoutExt;
return relativePath.split('/');
}
/** Return the path of a route relative to the root of the collection, which is equivalent to legacy IDs. */
function getRoutePathRelativeToCollectionRoot(route: Route, locale: string | undefined) {
return project.legacyCollections
? route.id
: // For collections with a loader, use a localized filePath relative to the collection
localizedId(route.entry.filePath.replace(`${docsCollectionPathFromRoot}/`, ''), locale);
}
/** Turn a flat array of routes into a tree structure. */
function treeify(routes: Route[], locale: string | undefined, baseDir: string): Dir {
const treeRoot: Dir = makeDir(baseDir);
routes
// Remove any entries that should be hidden
.filter((doc) => !doc.entry.data.sidebar.hidden)
// Compute the path of each entry from the root of the collection ahead of time.
.map((doc) => [getRoutePathRelativeToCollectionRoot(doc, locale), doc] as const)
// Sort by depth, to build the tree depth first.
.sort(([a], [b]) => b.split('/').length - a.split('/').length)
// Build the tree
.forEach(([filePathFromContentDir, doc]) => {
const parts = getBreadcrumbs(filePathFromContentDir, baseDir);
let currentNode = treeRoot;
parts.forEach((part, index) => {
const isLeaf = index === parts.length - 1;
// Handle directory index pages by renaming them to `index`
if (isLeaf && currentNode.hasOwnProperty(part)) {
currentNode = currentNode[part] as Dir;
part = 'index';
}
// Recurse down the tree if this isnt the leaf node.
if (!isLeaf) {
const path = currentNode[SlugKey];
currentNode[part] ||= makeDir(stripLeadingAndTrailingSlashes(path + '/' + part));
currentNode = currentNode[part] as Dir;
} else {
currentNode[part] = doc;
}
});
});
return treeRoot;
}
/** Create a link entry for a given content collection entry. */
function linkFromRoute(route: Route, attrs?: LinkHTMLAttributes): SidebarLink {
return makeSidebarLink(
slugToPathname(route.slug),
route.entry.data.sidebar.label || route.entry.data.title,
route.entry.data.sidebar.badge,
{ ...attrs, ...route.entry.data.sidebar.attrs }
);
}
/**
* Get the sort weight for a given route or directory. Lower numbers rank higher.
* Directories have the weight of the lowest weighted route they contain.
*/
function getOrder(routeOrDir: Route | Dir): number {
return isDir(routeOrDir)
? Math.min(...Object.values(routeOrDir).flatMap(getOrder))
: // If no order value is found, set it to the largest number possible.
(routeOrDir.entry.data.sidebar.order ?? Number.MAX_VALUE);
}
/** Sort a directorys entries by user-specified order or alphabetically if no order specified. */
function sortDirEntries(dir: [string, Dir | Route][]): [string, Dir | Route][] {
const collator = new Intl.Collator(localeToLang(undefined));
return dir.sort(([_keyA, a], [_keyB, b]) => {
const [aOrder, bOrder] = [getOrder(a), getOrder(b)];
// Pages are sorted by order in ascending order.
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
// If two pages have the same order value they will be sorted by their slug.
return collator.compare(isDir(a) ? a[SlugKey] : a.slug, isDir(b) ? b[SlugKey] : b.slug);
});
}
/** Create a group entry for a given content collection directory. */
function groupFromDir(
dir: Dir,
fullPath: string,
dirName: string,
currentPathname: string,
locale: string | undefined,
collapsed: boolean,
attrs?: LinkHTMLAttributes
): SidebarGroup {
const entries = sortDirEntries(Object.entries(dir)).map(([key, dirOrRoute]) =>
dirToItem(dirOrRoute, `${fullPath}/${key}`, key, currentPathname, locale, collapsed, attrs)
);
return {
type: 'group',
label: dirName,
entries,
collapsed,
badge: undefined,
};
}
/** Create a sidebar entry for a directory or content entry. */
function dirToItem(
dirOrRoute: Dir[string],
fullPath: string,
dirName: string,
currentPathname: string,
locale: string | undefined,
collapsed: boolean,
attrs?: LinkHTMLAttributes
): SidebarEntry {
return isDir(dirOrRoute)
? groupFromDir(dirOrRoute, fullPath, dirName, currentPathname, locale, collapsed, attrs)
: linkFromRoute(dirOrRoute, attrs);
}
/** Create a sidebar entry for a given content directory. */
function sidebarFromDir(
tree: Dir,
currentPathname: string,
locale: string | undefined,
collapsed: boolean,
attrs?: LinkHTMLAttributes
) {
return sortDirEntries(Object.entries(tree)).map(([key, dirOrRoute]) =>
dirToItem(dirOrRoute, key, key, currentPathname, locale, collapsed, attrs)
);
}
/**
* Intermediate sidebar represents sidebar entries generated from the user config for a specific
* locale and do not contain any information about the current page.
* These representations are cached per locale to avoid regenerating them for each page.
* When generating the final sidebar for a page, the intermediate sidebar is cloned and the current
* page is marked as such.
*
* @see getSidebarFromIntermediateSidebar
*/
const intermediateSidebars = new Map<string | undefined, SidebarEntry[]>();
/** Get the sidebar for the current page using the global config. */
export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] {
let intermediateSidebar = intermediateSidebars.get(locale);
if (!intermediateSidebar) {
intermediateSidebar = getIntermediateSidebarFromConfig(config.sidebar, pathname, locale);
intermediateSidebars.set(locale, intermediateSidebar);
}
return getSidebarFromIntermediateSidebar(intermediateSidebar, pathname);
}
/** Get the sidebar for the current page using the specified sidebar config. */
export function getSidebarFromConfig(
sidebarConfig: StarlightConfig['sidebar'],
pathname: string,
locale: string | undefined
): SidebarEntry[] {
const intermediateSidebar = getIntermediateSidebarFromConfig(sidebarConfig, pathname, locale);
return getSidebarFromIntermediateSidebar(intermediateSidebar, pathname);
}
/** Get the intermediate sidebar for the current page using the specified sidebar config. */
function getIntermediateSidebarFromConfig(
sidebarConfig: StarlightConfig['sidebar'],
pathname: string,
locale: string | undefined
): SidebarEntry[] {
const routes = getLocaleRoutes(locale);
if (sidebarConfig) {
return sidebarConfig.map((group) => configItemToEntry(group, pathname, locale, routes));
} else {
const tree = treeify(routes, locale, locale || '');
return sidebarFromDir(tree, pathname, locale, false);
}
}
/** Transform an intermediate sidebar into a sidebar for the current page. */
function getSidebarFromIntermediateSidebar(
intermediateSidebar: SidebarEntry[],
pathname: string
): SidebarEntry[] {
const sidebar = structuredClone(intermediateSidebar);
setIntermediateSidebarCurrentEntry(sidebar, pathname);
return sidebar;
}
/** Marks the current page as such in an intermediate sidebar. */
function setIntermediateSidebarCurrentEntry(
intermediateSidebar: SidebarEntry[],
pathname: string
): boolean {
for (const entry of intermediateSidebar) {
if (entry.type === 'link' && pathsMatch(encodeURI(entry.href), pathname)) {
entry.isCurrent = true;
return true;
}
if (entry.type === 'group' && setIntermediateSidebarCurrentEntry(entry.entries, pathname)) {
return true;
}
}
return false;
}
/** Generates a deterministic string based on the content of the passed sidebar. */
export function getSidebarHash(sidebar: SidebarEntry[]): string {
let hash = 0;
const sidebarIdentity = recursivelyBuildSidebarIdentity(sidebar);
for (let i = 0; i < sidebarIdentity.length; i++) {
const char = sidebarIdentity.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return (hash >>> 0).toString(36).padStart(7, '0');
}
/** Recurses through a sidebar tree to generate a string concatenating labels and link hrefs. */
function recursivelyBuildSidebarIdentity(sidebar: SidebarEntry[]): string {
return sidebar
.flatMap((entry) =>
entry.type === 'group'
? entry.label + recursivelyBuildSidebarIdentity(entry.entries)
: entry.label + entry.href
)
.join('');
}
/** Turn the nested tree structure of a sidebar into a flat list of all the links. */
export function flattenSidebar(sidebar: SidebarEntry[]): SidebarLink[] {
return sidebar.flatMap((entry) =>
entry.type === 'group' ? flattenSidebar(entry.entries) : entry
);
}
/** Get previous/next pages in the sidebar or the ones from the frontmatter if any. */
export function getPrevNextLinks(
sidebar: SidebarEntry[],
paginationEnabled: boolean,
config: {
prev?: PrevNextLinkConfig;
next?: PrevNextLinkConfig;
}
): PaginationLinks {
const entries = flattenSidebar(sidebar);
const currentIndex = entries.findIndex((entry) => entry.isCurrent);
const prev = applyPrevNextLinkConfig(entries[currentIndex - 1], paginationEnabled, config.prev);
const next = applyPrevNextLinkConfig(
currentIndex > -1 ? entries[currentIndex + 1] : undefined,
paginationEnabled,
config.next
);
return { prev, next };
}
/** Apply a prev/next link config to a navigation link. */
function applyPrevNextLinkConfig(
link: SidebarLink | undefined,
paginationEnabled: boolean,
config: PrevNextLinkConfig | undefined
): SidebarLink | undefined {
// Explicitly remove the link.
if (config === false) return undefined;
// Use the generated link if any.
else if (config === true) return link;
// If a link exists, update its label if needed.
else if (typeof config === 'string' && link) {
return { ...link, label: config };
} else if (typeof config === 'object') {
if (link) {
// If a link exists, update both its label and href if needed.
return {
...link,
label: config.label ?? link.label,
href: config.link ?? link.href,
// Explicitly remove sidebar link attributes for prev/next links.
attrs: {},
};
} else if (config.link && config.label) {
// If there is no link and the frontmatter contains both a URL and a label,
// create a new link.
return makeLink({ href: config.link, label: config.label });
}
}
// Otherwise, if the global config is enabled, return the generated link if any.
return paginationEnabled ? link : undefined;
}
/** Get a sidebar badge for a given item. */
function getSidebarBadge(
config: I18nBadgeConfig,
locale: string | undefined,
itemLabel: string
): Badge | undefined {
if (!config) return;
if (typeof config === 'string') {
return { variant: 'default', text: config };
}
return { ...config, text: getSidebarBadgeText(config.text, locale, itemLabel) };
}
/** Get the badge text for a sidebar item. */
function getSidebarBadgeText(
text: I18nBadge['text'],
locale: string | undefined,
itemLabel: string
): string {
if (typeof text === 'string') return text;
const defaultLang =
config.defaultLocale?.lang || config.defaultLocale?.locale || BuiltInDefaultLocale.lang;
const defaultText = text[defaultLang];
if (!defaultText) {
throw new AstroError(
`The badge text for "${itemLabel}" must have a key for the default language "${defaultLang}".`,
'Update the Starlight config to include a badge text for the default language.\n' +
'Learn more about sidebar badges internationalization at https://starlight.astro.build/guides/sidebar/#internationalization-with-badges'
);
}
return pickLang(text, localeToLang(locale)) || defaultText;
}

View File

@ -0,0 +1,58 @@
/** Ensure the passed path starts with a leading slash. */
export function ensureLeadingSlash(href: string): string {
if (href[0] !== '/') href = '/' + href;
return href;
}
/** Ensure the passed path ends with a trailing slash. */
export function ensureTrailingSlash(href: string): string {
if (href[href.length - 1] !== '/') href += '/';
return href;
}
/** Ensure the passed path starts and ends with slashes. */
export function ensureLeadingAndTrailingSlashes(href: string): string {
href = ensureLeadingSlash(href);
href = ensureTrailingSlash(href);
return href;
}
/** Ensure the passed path does not start with a leading slash. */
export function stripLeadingSlash(href: string) {
if (href[0] === '/') href = href.slice(1);
return href;
}
/** Ensure the passed path does not end with a trailing slash. */
export function stripTrailingSlash(href: string) {
if (href[href.length - 1] === '/') href = href.slice(0, -1);
return href;
}
/** Ensure the passed path does not start and end with slashes. */
export function stripLeadingAndTrailingSlashes(href: string): string {
href = stripLeadingSlash(href);
href = stripTrailingSlash(href);
return href;
}
/** Remove the extension from a path. */
export function stripHtmlExtension(path: string) {
const pathWithoutTrailingSlash = stripTrailingSlash(path);
return pathWithoutTrailingSlash.endsWith('.html') ? pathWithoutTrailingSlash.slice(0, -5) : path;
}
/** Add '.html' extension to a path. */
export function ensureHtmlExtension(path: string) {
path = stripLeadingAndTrailingSlashes(path);
if (!path.endsWith('.html')) {
path = path ? path + '.html' : '/index.html';
}
return ensureLeadingSlash(path);
}
/** Remove the extension from a path. */
export function stripExtension(path: string) {
const periodIndex = path.lastIndexOf('.');
return path.slice(0, periodIndex > -1 ? periodIndex : undefined);
}

View File

@ -0,0 +1,461 @@
import type { AstroIntegration, HookParameters as AstroHookParameters } from 'astro';
import { AstroError } from 'astro/errors';
import { z } from 'astro/zod';
import { parseWithFriendlyErrors } from '../utils/error-map';
import {
StarlightConfigSchema,
type StarlightConfig,
type StarlightUserConfig,
} from '../utils/user-config';
import type { UserI18nSchema } from './translations';
import { createTranslationSystemFromFs } from './translations-fs';
import { absolutePathToLang as getAbsolutePathFromLang } from '../integrations/shared/absolutePathToLang';
import { getCollectionPosixPath } from './collection-fs';
/**
* Runs Starlight plugins in the order that they are configured after validating the user-provided
* configuration and returns the final validated user config that may have been updated by the
* plugins and a list of any integrations added by the plugins.
*/
export async function runPlugins(
starlightUserConfig: StarlightUserConfig,
pluginsUserConfig: StarlightPluginsUserConfig,
context: StarlightPluginContext
) {
// Validate the user-provided configuration.
let userConfig = starlightUserConfig;
let starlightConfig = parseWithFriendlyErrors(
StarlightConfigSchema,
userConfig,
'Invalid config passed to starlight integration'
);
// Validate the user-provided plugins configuration.
const pluginsConfig = parseWithFriendlyErrors(
starlightPluginsConfigSchema,
pluginsUserConfig,
'Invalid plugins config passed to starlight integration'
);
// A list of translations injected by the various plugins keyed by locale.
const pluginTranslations: PluginTranslations = {};
// A list of route middleware added by the various plugins.
const routeMiddlewareConfigs: Array<z.output<typeof routeMiddlewareConfigSchema>> = [];
for (const {
hooks: { 'i18n:setup': i18nSetup },
} of pluginsConfig) {
if (i18nSetup) {
await i18nSetup({
injectTranslations(translations) {
// Merge the translations injected by the plugin.
for (const [locale, localeTranslations] of Object.entries(translations)) {
pluginTranslations[locale] ??= {};
Object.assign(pluginTranslations[locale]!, localeTranslations);
}
},
});
}
}
const useTranslations = createTranslationSystemFromFs(
starlightConfig,
context.config,
pluginTranslations
);
function absolutePathToLang(path: string) {
return getAbsolutePathFromLang(path, {
docsPath: getCollectionPosixPath('docs', context.config.srcDir),
starlightConfig,
});
}
// A list of Astro integrations added by the various plugins.
const integrations: AstroIntegration[] = [];
for (const {
name,
hooks: { 'config:setup': configSetup, setup: deprecatedSetup },
} of pluginsConfig) {
// A refinement in the schema ensures that at least one of the two hooks is defined.
const setup = (configSetup ?? deprecatedSetup)!;
await setup({
config: pluginsUserConfig ? { ...userConfig, plugins: pluginsUserConfig } : userConfig,
updateConfig(newConfig) {
// Ensure that plugins do not update the `plugins` config key.
if ('plugins' in newConfig) {
throw new AstroError(
`The \`${name}\` plugin tried to update the \`plugins\` config key which is not supported.`
);
}
if ('routeMiddleware' in newConfig) {
throw new AstroError(
`The \`${name}\` plugin tried to update the \`routeMiddleware\` config key which is not supported.`,
'Use the `addRouteMiddleware()` utility instead.\n' +
'See https://starlight.astro.build/reference/plugins/#addroutemiddleware for more details.'
);
}
// If the plugin is updating the user config, re-validate it.
const mergedUserConfig = { ...userConfig, ...newConfig };
const mergedConfig = parseWithFriendlyErrors(
StarlightConfigSchema,
mergedUserConfig,
`Invalid config update provided by the '${name}' plugin`
);
// If the updated config is valid, keep track of both the user config and parsed config.
userConfig = mergedUserConfig;
starlightConfig = mergedConfig;
},
addIntegration(integration) {
// Collect any Astro integrations added by the plugin.
integrations.push(integration);
},
addRouteMiddleware(middlewareConfig) {
routeMiddlewareConfigs.push(middlewareConfig);
},
astroConfig: {
...context.config,
integrations: [...context.config.integrations, ...integrations],
},
command: context.command,
isRestart: context.isRestart,
logger: context.logger.fork(name),
useTranslations,
absolutePathToLang,
});
}
applyPluginMiddleware(routeMiddlewareConfigs, starlightConfig);
return { integrations, starlightConfig, pluginTranslations, useTranslations, absolutePathToLang };
}
/** Updates `routeMiddleware` in the Starlight config to add plugin middlewares in the correct order. */
function applyPluginMiddleware(
routeMiddlewareConfigs: { entrypoint: string; order: 'default' | 'pre' | 'post' }[],
starlightConfig: StarlightConfig
) {
const middlewareBuckets = routeMiddlewareConfigs.reduce<
Record<'pre' | 'default' | 'post', string[]>
>(
(buckets, { entrypoint, order = 'default' }) => {
buckets[order].push(entrypoint);
return buckets;
},
{ pre: [], default: [], post: [] }
);
starlightConfig.routeMiddleware.unshift(...middlewareBuckets.pre);
starlightConfig.routeMiddleware.push(...middlewareBuckets.default, ...middlewareBuckets.post);
}
export function injectPluginTranslationsTypes(
translations: PluginTranslations,
injectTypes: AstroHookParameters<'astro:config:done'>['injectTypes']
) {
const allKeys = new Set<string>();
for (const localeTranslations of Object.values(translations)) {
for (const key of Object.keys(localeTranslations)) {
allKeys.add(key);
}
}
// If there are no translations to inject, we don't need to generate any types or cleanup
// previous ones as they will not be referenced anymore.
if (allKeys.size === 0) return;
injectTypes({
filename: 'i18n-plugins.d.ts',
content: `declare namespace StarlightApp {
type PluginUIStringKeys = {
${[...allKeys].map((key) => `'${key}': string;`).join('\n\t\t')}
};
interface I18n extends PluginUIStringKeys {}
}`,
});
}
// https://github.com/withastro/astro/blob/910eb00fe0b70ca80bd09520ae100e8c78b675b5/packages/astro/src/core/config/schema.ts#L113
const astroIntegrationSchema = z.object({
name: z.string(),
hooks: z.object({}).passthrough().default({}),
}) as z.Schema<AstroIntegration>;
const routeMiddlewareConfigSchema = z.object({
entrypoint: z.string(),
order: z.enum(['pre', 'post', 'default']).default('default'),
});
const baseStarlightPluginSchema = z.object({
/** Name of the Starlight plugin. */
name: z.string(),
});
const configSetupHookSchema = z
.function(
z.tuple([
z.object({
/**
* A read-only copy of the user-supplied Starlight configuration.
*
* Note that this configuration may have been updated by other plugins configured
* before this one.
*/
config: z.any() as z.Schema<
// The configuration passed to plugins should contains the list of plugins.
StarlightUserConfig & { plugins?: z.input<typeof baseStarlightPluginSchema>[] }
>,
/**
* A callback function to update the user-supplied Starlight configuration.
*
* You only need to provide the configuration values that you want to update but no deep
* merge is performed.
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* 'config:setup'({ updateConfig }) {
* updateConfig({
* description: 'Custom description',
* });
* }
* }
* }
*/
updateConfig: z.function(
z.tuple([
z.record(z.any()) as z.Schema<Partial<Omit<StarlightUserConfig, 'routeMiddleware'>>>,
]),
z.void()
),
/**
* A callback function to add an Astro integration required by this plugin.
*
* @see https://docs.astro.build/en/reference/integrations-reference/
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* 'config:setup'({ addIntegration }) {
* addIntegration({
* name: 'My Plugin Astro Integration',
* hooks: {
* 'astro:config:setup': () => {
* // …
* },
* },
* });
* }
* }
* }
*/
addIntegration: z.function(z.tuple([astroIntegrationSchema]), z.void()),
/**
* A callback function to register additional route middleware handlers.
*
* If the order of execution is important, a plugin can use the `order` option to enforce
* running first or last.
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* setup({ addRouteMiddleware }) {
* addRouteMiddleware({ entrypoint: '@me/my-plugin/route-middleware' });
* },
* },
* }
*/
addRouteMiddleware: z.function(z.tuple([routeMiddlewareConfigSchema]), z.void()),
/**
* A read-only copy of the user-supplied Astro configuration.
*
* Note that this configuration is resolved before any other integrations have run.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#config-option
*/
astroConfig: z.any() as z.Schema<StarlightPluginContext['config']>,
/**
* The command used to run Starlight.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#command-option
*/
command: z.any() as z.Schema<StarlightPluginContext['command']>,
/**
* `false` when the dev server starts, `true` when a reload is triggered.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#isrestart-option
*/
isRestart: z.any() as z.Schema<StarlightPluginContext['isRestart']>,
/**
* An instance of the Astro integration logger with all logged messages prefixed with the
* plugin name.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#astrointegrationlogger
*/
logger: z.any() as z.Schema<StarlightPluginContext['logger']>,
/**
* A callback function to generate a utility function to access UI strings for a given
* language.
*
* @see https://starlight.astro.build/guides/i18n/#using-ui-translations
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* 'config:setup'({ useTranslations, logger }) {
* const t = useTranslations('en');
* logger.info(t('builtWithStarlight.label'));
* // ^ Logs 'Built with Starlight' to the console.
* }
* }
* }
*/
useTranslations: z.any() as z.Schema<ReturnType<typeof createTranslationSystemFromFs>>,
/**
* A callback function to get the language for a given absolute file path. The returned
* language can be used with the `useTranslations` helper to get UI strings for that
* language.
*
* This can be particularly useful in remark or rehype plugins to get the language for
* the current file being processed and use it to get the appropriate UI strings for that
* language.
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* 'config:setup'({ absolutePathToLang, useTranslations, logger }) {
* const lang = absolutePathToLang('/absolute/path/to/project/src/content/docs/fr/index.mdx');
* const t = useTranslations(lang);
* logger.info(t('aside.tip'));
* // ^ Logs 'Astuce' to the console.
* }
* }
* }
*/
absolutePathToLang: z.function(z.tuple([z.string()]), z.string()),
}),
]),
z.union([z.void(), z.promise(z.void())])
)
.optional();
/**
* A plugin `config` and `updateConfig` argument are purposely not validated using the Starlight
* user config schema but properly typed for user convenience because we do not want to run any of
* the Zod `transform`s used in the user config schema when running plugins.
*/
const starlightPluginSchema = baseStarlightPluginSchema
.extend({
/** The different hooks available to the plugin. */
hooks: z.object({
/**
* Plugin internationalization setup function allowing to inject translations strings for the
* plugin in various locales. These translations will be available in the `config:setup` hook
* and plugin UI.
*/
'i18n:setup': z
.function(
z.tuple([
z.object({
/**
* A callback function to add or update translations strings.
*
* @see https://starlight.astro.build/guides/i18n/#extend-translation-schema
*
* @example
* {
* name: 'My Starlight Plugin',
* hooks: {
* 'i18n:setup'({ injectTranslations }) {
* injectTranslations({
* en: {
* 'myPlugin.doThing': 'Do the thing',
* },
* fr: {
* 'myPlugin.doThing': 'Faire le truc',
* },
* });
* }
* }
* }
*/
injectTranslations: z.function(
z.tuple([z.record(z.string(), z.record(z.string(), z.string()))]),
z.void()
),
}),
]),
z.union([z.void(), z.promise(z.void())])
)
.optional(),
/**
* Plugin configuration setup function called with an object containing various values that
* can be used by the plugin to interact with Starlight.
*/
'config:setup': configSetupHookSchema,
/**
* @deprecated Use the `config:setup` hook instead as `setup` will be removed in a future
* version.
*/
setup: configSetupHookSchema,
}),
})
.superRefine((plugin, ctx) => {
if (!plugin.hooks['config:setup'] && !plugin.hooks.setup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A plugin must define at least a `config:setup` hook.',
});
} else if (plugin.hooks['config:setup'] && plugin.hooks.setup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'A plugin cannot define both a `config:setup` and `setup` hook. ' +
'As `setup` is deprecated and will be removed in a future version, ' +
'consider using `config:setup` instead.',
});
}
});
const starlightPluginsConfigSchema = z.array(starlightPluginSchema).default([]);
type StarlightPluginsUserConfig = z.input<typeof starlightPluginsConfigSchema>;
export type StarlightPlugin = z.input<typeof starlightPluginSchema>;
export type HookParameters<
Hook extends keyof StarlightPlugin['hooks'],
HookFn = StarlightPlugin['hooks'][Hook],
> = HookFn extends (...args: any) => any ? Parameters<HookFn>[0] : never;
export type StarlightUserConfigWithPlugins = StarlightUserConfig & {
/**
* A list of plugins to extend Starlight with.
*
* @example
* // Add Starlight Algolia plugin.
* starlight({
* plugins: [starlightAlgolia({ })],
* })
*/
plugins?: StarlightPluginsUserConfig;
};
export type StarlightPluginContext = Pick<
AstroHookParameters<'astro:config:setup'>,
'command' | 'config' | 'isRestart' | 'logger'
>;
export type PluginTranslations = Record<string, UserI18nSchema & Record<string, string>>;

View File

@ -0,0 +1,160 @@
import type { APIContext, MarkdownHeading } from 'astro';
import project from 'virtual:starlight/project-context';
import config from 'virtual:starlight/user-config';
import { generateToC } from '../generateToC';
import { getNewestCommitDate } from 'virtual:starlight/git-info';
import { getPrevNextLinks, getSidebar } from '../navigation';
import { ensureTrailingSlash } from '../path';
import { getRouteBySlugParam, normalizeCollectionEntry } from '../routing';
import type {
Route,
StarlightDocsCollectionEntry,
StarlightDocsEntry,
StarlightRouteData,
} from './types';
import { formatPath } from '../format-path';
import { useTranslations } from '../translations';
import { BuiltInDefaultLocale } from '../i18n';
import { getEntry, type RenderResult } from 'astro:content';
import { getCollectionPathFromRoot } from '../collection';
import { getHead } from '../head';
export interface PageProps extends Route {
headings: MarkdownHeading[];
}
export type RouteDataContext = Pick<APIContext, 'generator' | 'site' | 'url'>;
export async function getRoute(context: APIContext): Promise<Route> {
return (
('slug' in context.params && getRouteBySlugParam(context.params.slug)) ||
(await get404Route(context.locals))
);
}
export async function useRouteData(
context: APIContext,
route: Route,
{ Content, headings }: RenderResult
): Promise<StarlightRouteData> {
const routeData = generateRouteData({ props: { ...route, headings }, context });
return { ...routeData, Content };
}
export function generateRouteData({
props,
context,
}: {
props: PageProps;
context: RouteDataContext;
}): StarlightRouteData {
const { entry, locale, lang } = props;
const sidebar = getSidebar(context.url.pathname, locale);
const siteTitle = getSiteTitle(lang);
return {
...props,
siteTitle,
siteTitleHref: getSiteTitleHref(locale),
sidebar,
hasSidebar: entry.data.template !== 'splash',
pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
toc: getToC(props),
lastUpdated: getLastUpdated(props),
editUrl: getEditUrl(props),
head: getHead(props, context, siteTitle),
};
}
export function getToC({ entry, lang, headings }: PageProps) {
const tocConfig =
entry.data.template === 'splash'
? false
: entry.data.tableOfContents !== undefined
? entry.data.tableOfContents
: config.tableOfContents;
if (!tocConfig) return;
const t = useTranslations(lang);
return {
...tocConfig,
items: generateToC(headings, { ...tocConfig, title: t('tableOfContents.overview') }),
};
}
function getLastUpdated({ entry }: PageProps): Date | undefined {
const { lastUpdated: frontmatterLastUpdated } = entry.data;
const { lastUpdated: configLastUpdated } = config;
if (frontmatterLastUpdated ?? configLastUpdated) {
try {
return frontmatterLastUpdated instanceof Date
? frontmatterLastUpdated
: getNewestCommitDate(entry.filePath);
} catch {
// If the git command fails, ignore the error.
return undefined;
}
}
return undefined;
}
function getEditUrl({ entry }: PageProps): URL | undefined {
const { editUrl } = entry.data;
// If frontmatter value is false, editing is disabled for this page.
if (editUrl === false) return;
let url: string | undefined;
if (typeof editUrl === 'string') {
// If a URL was provided in frontmatter, use that.
url = editUrl;
} else if (config.editLink.baseUrl) {
// If a base URL was added in Starlight config, synthesize the edit URL from it.
url = ensureTrailingSlash(config.editLink.baseUrl) + entry.filePath;
}
return url ? new URL(url) : undefined;
}
/** Get the site title for a given language. **/
export function getSiteTitle(lang: string): string {
const defaultLang = config.defaultLocale.lang as string;
if (lang && config.title[lang]) {
return config.title[lang] as string;
}
return config.title[defaultLang] as string;
}
export function getSiteTitleHref(locale: string | undefined): string {
return formatPath(locale || '/');
}
/** Generate a route object for Starlights 404 page. */
async function get404Route(locals: App.Locals): Promise<Route> {
const { lang = BuiltInDefaultLocale.lang, dir = BuiltInDefaultLocale.dir } =
config.defaultLocale || {};
let locale = config.defaultLocale?.locale;
if (locale === 'root') locale = undefined;
const entryMeta = { dir, lang, locale };
const fallbackEntry: StarlightDocsEntry = {
slug: '404',
id: '404',
body: '',
collection: 'docs',
data: {
title: '404',
template: 'splash',
editUrl: false,
head: [],
hero: { tagline: locals.t('404.text'), actions: [] },
pagefind: false,
sidebar: { hidden: false, attrs: {} },
draft: false,
},
filePath: `${getCollectionPathFromRoot('docs', project)}/404.md`,
};
const userEntry = (await getEntry('docs', '404')) as StarlightDocsCollectionEntry;
const entry = userEntry ? normalizeCollectionEntry(userEntry) : fallbackEntry;
return { ...entryMeta, entryMeta, entry, id: entry.id, slug: entry.slug };
}

View File

@ -0,0 +1,143 @@
import type { GetStaticPathsItem } from 'astro';
import { getCollection } from 'astro:content';
import config from 'virtual:starlight/user-config';
import project from 'virtual:starlight/project-context';
import { getCollectionPathFromRoot } from '../collection';
import { localizedId, localizedSlug, slugToLocaleData, slugToParam } from '../slugs';
import { validateLogoImports } from '../validateLogoImports';
import { BuiltInDefaultLocale } from '../i18n';
import type { Route, StarlightDocsCollectionEntry, StarlightDocsEntry } from './types';
// Validate any user-provided logos imported correctly.
// We do this here so all pages trigger it and at the top level so it runs just once.
validateLogoImports();
interface Path extends GetStaticPathsItem {
params: { slug: string | undefined };
props: Route;
}
/**
* Astro is inconsistent in its `index.md` slug generation. In most cases,
* `index` is stripped, but in the root of a collection, we get a slug of `index`.
* We map that to an empty string for consistent behaviour.
*/
const normalizeIndexSlug = (slug: string) => (slug === 'index' ? '' : slug);
/** Normalize the different collection entry we can get from a legacy collection or a loader. */
export function normalizeCollectionEntry(entry: StarlightDocsCollectionEntry): StarlightDocsEntry {
const slug = normalizeIndexSlug(entry.slug ?? entry.id);
return {
...entry,
// In a collection with a loader, the `id` is a slug and should be normalized.
id: entry.slug ? entry.id : slug,
// In a legacy collection, the `filePath` property doesn't exist.
filePath: entry.filePath ?? `${getCollectionPathFromRoot('docs', project)}/${entry.id}`,
// In a collection with a loader, the `slug` property is replaced by the `id`.
slug: normalizeIndexSlug(entry.slug ?? entry.id),
};
}
/** All entries in the docs content collection. */
const docs: StarlightDocsEntry[] = (
(await getCollection('docs', ({ data }) => {
// In production, filter out drafts.
return import.meta.env.MODE !== 'production' || data.draft === false;
})) ?? []
).map(normalizeCollectionEntry);
function getRoutes(): Route[] {
const routes: Route[] = docs.map((entry) => ({
entry,
slug: entry.slug,
id: entry.id,
entryMeta: slugToLocaleData(entry.slug),
...slugToLocaleData(entry.slug),
}));
// In multilingual sites, add required fallback routes.
if (config.isMultilingual) {
/** Entries in the docs content collection for the default locale. */
const defaultLocaleDocs = getLocaleDocs(
config.defaultLocale?.locale === 'root' ? undefined : config.defaultLocale?.locale
);
for (const key in config.locales) {
if (key === config.defaultLocale.locale) continue;
const localeConfig = config.locales[key];
if (!localeConfig) continue;
const locale = key === 'root' ? undefined : key;
const localeDocs = getLocaleDocs(locale);
for (const fallback of defaultLocaleDocs) {
const slug = localizedSlug(fallback.slug, locale);
const id = project.legacyCollections ? localizedId(fallback.id, locale) : slug;
const doesNotNeedFallback = localeDocs.some((doc) => doc.slug === slug);
if (doesNotNeedFallback) continue;
routes.push({
entry: fallback,
slug,
id,
isFallback: true,
lang: localeConfig.lang || BuiltInDefaultLocale.lang,
locale,
dir: localeConfig.dir,
entryMeta: slugToLocaleData(fallback.slug),
});
}
}
}
return routes;
}
export const routes = getRoutes();
function getParamRouteMapping(): ReadonlyMap<string | undefined, Route> {
const map = new Map<string | undefined, Route>();
for (const route of routes) {
map.set(slugToParam(route.slug), route);
}
return map;
}
const routesBySlugParam = getParamRouteMapping();
export function getRouteBySlugParam(slugParam: string | undefined): Route | undefined {
return routesBySlugParam.get(slugParam?.replace(/\/$/, '') || undefined);
}
function getPaths(): Path[] {
return routes.map((route) => ({
params: { slug: slugToParam(route.slug) },
props: route,
}));
}
export const paths = getPaths();
/**
* Get all routes for a specific locale.
* A locale of `undefined` is treated as the root locale, if configured.
*/
export function getLocaleRoutes(locale: string | undefined): Route[] {
return filterByLocale(routes, locale);
}
/**
* Get all entries in the docs content collection for a specific locale.
* A locale of `undefined` is treated as the root locale, if configured.
*/
function getLocaleDocs(locale: string | undefined): StarlightDocsEntry[] {
return filterByLocale(docs, locale);
}
/** Filter an array to find items whose slug matches the passed locale. */
function filterByLocale<T extends { slug: string }>(items: T[], locale: string | undefined): T[] {
if (config.locales) {
if (locale && locale in config.locales) {
return items.filter((i) => i.slug === locale || i.slug.startsWith(locale + '/'));
} else if (config.locales.root) {
const langKeys = Object.keys(config.locales).filter((k) => k !== 'root');
const isLangIndex = new RegExp(`^(${langKeys.join('|')})$`);
const isLangDir = new RegExp(`^(${langKeys.join('|')})/`);
return items.filter((i) => !isLangIndex.test(i.slug) && !isLangDir.test(i.slug));
}
}
return items;
}

View File

@ -0,0 +1,81 @@
import type { APIContext } from 'astro';
import { klona } from 'klona/lite';
import { routeMiddleware } from 'virtual:starlight/route-middleware';
import type { StarlightRouteData } from './types';
/**
* Adds a deep clone of the passed `routeData` object to locals and then runs middleware.
* @param context Astro context object
* @param routeData Initial route data object to attach.
*/
export async function attachRouteDataAndRunMiddleware(
context: APIContext,
routeData: StarlightRouteData
) {
context.locals.starlightRoute = klona(routeData);
const runner = new MiddlewareRunner(context, routeMiddleware);
await runner.run();
}
type MiddlewareHandler<T> = (context: T, next: () => Promise<void>) => void | Promise<void>;
/**
* A middleware function wrapper that only allows a single execution of the wrapped function.
* Subsequent calls to `run()` are no-ops.
*/
class MiddlewareRunnerStep<T> {
#callback: MiddlewareHandler<T> | null;
constructor(callback: MiddlewareHandler<T>) {
this.#callback = callback;
}
async run(context: T, next: () => Promise<void>): Promise<void> {
if (this.#callback) {
await this.#callback(context, next);
this.#callback = null;
}
}
}
/**
* Class that runs a stack of middleware handlers with an initial context object.
* Middleware functions can mutate properties of the `context` object, but cannot replace it.
*
* @example
* const context = { value: 10 };
* const timesTwo = async (ctx, next) => {
* await next();
* ctx.value *= 2;
* };
* const addFive = async (ctx) => {
* ctx.value += 5;
* }
* const runner = new MiddlewareRunner(context, [timesTwo, addFive]);
* runner.run();
* console.log(context); // { value: 30 }
*/
class MiddlewareRunner<T> {
#context: T;
#steps: Array<MiddlewareRunnerStep<T>>;
constructor(
/** Context object passed as the first argument to each middleware function. */
context: T,
/** Array of middleware functions to run in sequence. */
stack: Array<MiddlewareHandler<T>> = []
) {
this.#context = context;
this.#steps = stack.map((callback) => new MiddlewareRunnerStep(callback));
}
async #stepThrough(steps: Array<MiddlewareRunnerStep<T>>) {
let currentStep: MiddlewareRunnerStep<T>;
while (steps.length > 0) {
[currentStep, ...steps] = steps as [MiddlewareRunnerStep<T>, ...MiddlewareRunnerStep<T>[]];
await currentStep.run(this.#context, async () => this.#stepThrough(steps));
}
}
async run() {
await this.#stepThrough(this.#steps);
}
}

View File

@ -0,0 +1,99 @@
import type { MarkdownHeading } from 'astro';
import type { CollectionEntry, RenderResult } from 'astro:content';
import type { TocItem } from '../generateToC';
import type { LinkHTMLAttributes } from '../../schemas/sidebar';
import type { Badge } from '../../schemas/badge';
import type { HeadConfig } from '../../schemas/head';
export interface LocaleData {
/** Writing direction. */
dir: 'ltr' | 'rtl';
/** BCP-47 language tag. */
lang: string;
/** The base path at which a language is served. `undefined` for root locale slugs. */
locale: string | undefined;
}
export interface SidebarLink {
type: 'link';
label: string;
href: string;
isCurrent: boolean;
badge: Badge | undefined;
attrs: LinkHTMLAttributes;
}
export interface SidebarGroup {
type: 'group';
label: string;
entries: (SidebarLink | SidebarGroup)[];
collapsed: boolean;
badge: Badge | undefined;
}
export type SidebarEntry = SidebarLink | SidebarGroup;
export interface PaginationLinks {
/** Link to previous page in the sidebar. */
prev: SidebarLink | undefined;
/** Link to next page in the sidebar. */
next: SidebarLink | undefined;
}
// The type returned from `CollectionEntry` is different for legacy collections and collections
// using a loader. This type is a common subset of both types.
export type StarlightDocsCollectionEntry = Omit<
CollectionEntry<'docs'>,
'id' | 'filePath' | 'render' | 'slug'
> & {
// Update the `id` property to be a string like in the loader type.
id: string;
// Add the `filePath` property which is only present in the loader type.
filePath?: string;
// Add the `slug` property which is only present in the legacy type.
slug?: string;
};
export type StarlightDocsEntry = StarlightDocsCollectionEntry & {
filePath: string;
slug: string;
};
export interface Route extends LocaleData {
/** Content collection entry for the current page. Includes frontmatter at `data`. */
entry: StarlightDocsEntry;
/** Locale metadata for the page content. Can be different from top-level locale values when a page is using fallback content. */
entryMeta: LocaleData;
/** @deprecated Migrate to the new Content Layer API and use `id` instead. */
slug: string;
/** The slug or unique ID if using the `legacy.collections` flag. */
id: string;
/** Whether this page is untranslated in the current language and using fallback content from the default locale. */
isFallback?: boolean;
[key: string]: unknown;
}
export interface StarlightRouteData extends Route {
/** Title of the site. */
siteTitle: string;
/** URL or path used as the link when clicking on the site title. */
siteTitleHref: string;
/** Array of Markdown headings extracted from the current page. */
headings: MarkdownHeading[];
/** Site navigation sidebar entries for this page. */
sidebar: SidebarEntry[];
/** Whether or not the sidebar should be displayed on this page. */
hasSidebar: boolean;
/** Links to the previous and next page in the sidebar if enabled. */
pagination: PaginationLinks;
/** Table of contents for this page if enabled. */
toc: { minHeadingLevel: number; maxHeadingLevel: number; items: TocItem[] } | undefined;
/** JS Date object representing when this page was last updated if enabled. */
lastUpdated: Date | undefined;
/** URL object for the address where this page can be edited if enabled. */
editUrl: URL | undefined;
/** An Astro component to render the current pages content if this route is a Markdown page. */
Content?: RenderResult['Content'];
/** Array of tags to include in the `<head>` of the current page. */
head: HeadConfig;
}

View File

@ -0,0 +1,120 @@
import config from 'virtual:starlight/user-config';
import { slugToLocale as getLocaleFromSlug } from '../integrations/shared/slugToLocale';
import { BuiltInDefaultLocale } from './i18n';
import { stripTrailingSlash } from './path';
import type { LocaleData } from './routing/types';
/**
* Get the locale of a slug. This is the base path at which a language is served.
* For example, if French docs are in `src/content/docs/french/`, the locale is `french`.
* Root locale slugs will return `undefined`.
* @param slug A collection entry slug
*/
function slugToLocale(slug: string): string | undefined {
return getLocaleFromSlug(slug, config);
}
/** Get locale information for a given slug. */
export function slugToLocaleData(slug: string): LocaleData {
const locale = slugToLocale(slug);
return { dir: localeToDir(locale), lang: localeToLang(locale), locale };
}
/**
* Get the BCP-47 language tag for the given locale.
* @param locale Locale string or `undefined` for the root locale.
*/
export function localeToLang(locale: string | undefined): string {
const lang = locale ? config.locales?.[locale]?.lang : config.locales?.root?.lang;
const defaultLang = config.defaultLocale?.lang || config.defaultLocale?.locale;
return lang || defaultLang || BuiltInDefaultLocale.lang;
}
/**
* Get the configured writing direction for the given locale.
* @param locale Locale string or `undefined` for the root locale.
*/
function localeToDir(locale: string | undefined): 'ltr' | 'rtl' {
const dir = locale ? config.locales?.[locale]?.dir : config.locales?.root?.dir;
return dir || config.defaultLocale.dir;
}
/**
* Convert a content collection slug to a param as expected by Astros router.
* This utility handles stripping `index` from file names and matches
* [Astros param sanitization logic](https://github.com/withastro/astro/blob/687d25365a41ff8a9e6da155d3527f841abb70dd/packages/astro/src/core/routing/manifest/generator.ts#L4-L18)
* by normalizing strings to their canonical representations.
* @param slug Content collection slug
* @returns Param compatible with Astros router
*/
export function slugToParam(slug: string): string | undefined {
return slug === 'index' || slug === '' || slug === '/'
? undefined
: (slug.endsWith('/index') ? slug.slice(0, -6) : slug).normalize();
}
export function slugToPathname(slug: string): string {
const param = slugToParam(slug);
return param ? '/' + param + '/' : '/';
}
/**
* Convert a slug to a different locale.
* For example, passing a slug of `en/home` and a locale of `fr` results in `fr/home`.
* An undefined locale is treated as the root locale, resulting in `home`
* @param slug A collection entry slug
* @param locale The target locale
* @example
* localizedSlug('en/home', 'fr') // => 'fr/home'
* localizedSlug('en/home', undefined) // => 'home'
*/
export function localizedSlug(slug: string, locale: string | undefined): string {
const slugLocale = slugToLocale(slug);
if (slugLocale === locale) return slug;
locale = locale || '';
if (slugLocale === slug) return locale;
if (slugLocale) {
return stripTrailingSlash(slug.replace(slugLocale + '/', locale ? locale + '/' : ''));
}
return slug ? locale + '/' + slug : locale;
}
/**
* Convert a legacy collection entry ID or filePath relative to the collection root to a different
* locale.
* For example, passing an ID of `en/home.md` and a locale of `fr` results in `fr/home.md`.
* An undefined locale is treated as the root locale, resulting in `home.md`.
* @param id A collection entry ID
* @param locale The target locale
* @example
* localizedSlug('en/home.md', 'fr') // => 'fr/home.md'
* localizedSlug('en/home.md', undefined) // => 'home.md'
*/
export function localizedId(id: string, locale: string | undefined): string {
const idLocale = slugToLocale(id);
if (idLocale) {
return id.replace(idLocale + '/', locale ? locale + '/' : '');
} else if (locale) {
return locale + '/' + id;
} else {
return id;
}
}
/** Extract the slug from a URL. */
export function urlToSlug(url: URL): string {
let pathname = url.pathname;
const base = stripTrailingSlash(import.meta.env.BASE_URL);
if (pathname.startsWith(base)) pathname = pathname.replace(base, '');
const segments = pathname.split('/');
const htmlExt = '.html';
if (segments.at(-1) === 'index.html') {
// Remove trailing `index.html`.
segments.pop();
} else if (segments.at(-1)?.endsWith(htmlExt)) {
// Remove trailing `.html`.
const last = segments.pop();
if (last) segments.push(last.slice(0, -1 * htmlExt.length));
}
return segments.filter(Boolean).join('/');
}

View File

@ -0,0 +1,215 @@
import { z } from 'astro/zod';
import { type ContentConfig, type ImageFunction, type SchemaContext } from 'astro:content';
import project from 'virtual:starlight/project-context';
import config from 'virtual:starlight/user-config';
import { getCollectionPathFromRoot } from './collection';
import { parseWithFriendlyErrors, parseAsyncWithFriendlyErrors } from './error-map';
import { stripLeadingAndTrailingSlashes } from './path';
import {
getSiteTitle,
getSiteTitleHref,
getToC,
type PageProps,
type RouteDataContext,
} from './routing/data';
import type { StarlightDocsEntry, StarlightRouteData } from './routing/types';
import { slugToLocaleData, urlToSlug } from './slugs';
import { getPrevNextLinks, getSidebar, getSidebarFromConfig } from './navigation';
import { docsSchema } from '../schema';
import type { Prettify, RemoveIndexSignature } from './types';
import { SidebarItemSchema } from '../schemas/sidebar';
import type { StarlightConfig, StarlightUserConfig } from './user-config';
import { getHead } from './head';
/**
* The frontmatter schema for Starlight pages derived from the default schema for Starlights
* `docs` content collection.
* The frontmatter schema for Starlight pages cannot include some properties which will be omitted
* and some others needs to be refined to a stricter type.
*/
const StarlightPageFrontmatterSchema = async (context: SchemaContext) => {
const userDocsSchema = await getUserDocsSchema();
const schema = typeof userDocsSchema === 'function' ? userDocsSchema(context) : userDocsSchema;
return schema.transform((frontmatter) => {
/**
* Starlight pages can only be edited if an edit URL is explicitly provided.
* The `sidebar` frontmatter prop only works for pages in an autogenerated links group.
* Starlight pages edit links cannot be autogenerated.
*
* These changes to the schema are done using a transformer and not using the usual `omit`
* method because when the frontmatter schema is extended by the user, an intersection between
* the default schema and the user schema is created using the `and` method. Intersections in
* Zod returns a `ZodIntersection` object which does not have some methods like `omit` or
* `pick`.
*
* This transformer only sets the `editUrl` default value and removes the `sidebar` property
* from the validated output but does not appply any changes to the input schema type itself so
* this needs to be done manually.
*
* @see StarlightPageFrontmatter
* @see https://github.com/colinhacks/zod#intersections
*/
const { editUrl, sidebar, ...others } = frontmatter;
const pageEditUrl = editUrl === undefined || editUrl === true ? false : editUrl;
return { ...others, editUrl: pageEditUrl };
});
};
/**
* Type of Starlight pages frontmatter schema.
* We manually refines the `editUrl` type and omit the `sidebar` property as it's not possible to
* do that on the schema itself using Zod but the proper validation is still using a transformer.
* @see StarlightPageFrontmatterSchema
*/
type StarlightPageFrontmatter = Omit<
z.input<Awaited<ReturnType<typeof StarlightPageFrontmatterSchema>>>,
'editUrl' | 'sidebar'
> & { editUrl?: string | false };
/** Parse sidebar prop to ensure it's valid. */
const validateSidebarProp = (
sidebarProp: StarlightUserConfig['sidebar']
): StarlightConfig['sidebar'] => {
return parseWithFriendlyErrors(
SidebarItemSchema.array().optional(),
sidebarProp,
'Invalid sidebar prop passed to the `<StarlightPage/>` component.'
);
};
/**
* The props accepted by the `<StarlightPage/>` component.
*/
export type StarlightPageProps = Prettify<
// Remove the index signature from `Route`, omit undesired properties and make the rest optional.
Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> &
// Add the sidebar definitions for a Starlight page.
Partial<Pick<StarlightRouteData, 'hasSidebar'>> & {
sidebar?: StarlightUserConfig['sidebar'];
// And finally add the Starlight page frontmatter properties in a `frontmatter` property.
frontmatter: StarlightPageFrontmatter;
}
>;
/**
* A docs entry used for Starlight pages meant to be rendered by plugins and which is safe to cast
* to a `StarlightDocsEntry`.
* A Starlight page docs entry cannot be rendered like a content collection entry.
*/
type StarlightPageDocsEntry = Omit<StarlightDocsEntry, 'id' | 'render'> & {
/**
* The unique ID if using the `legacy.collections` for this Starlight page which cannot be
* inferred from codegen like content collection entries or the slug.
*/
id: string;
};
export async function generateStarlightPageRouteData({
props,
context,
}: {
props: StarlightPageProps;
context: RouteDataContext;
}): Promise<StarlightRouteData> {
const { frontmatter, ...routeProps } = props;
const { url } = context;
const slug = urlToSlug(url);
const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter);
const id = project.legacyCollections ? `${stripLeadingAndTrailingSlashes(slug)}.md` : slug;
const localeData = slugToLocaleData(slug);
const sidebar = props.sidebar
? getSidebarFromConfig(validateSidebarProp(props.sidebar), url.pathname, localeData.locale)
: getSidebar(url.pathname, localeData.locale);
const headings = props.headings ?? [];
const pageDocsEntry: StarlightPageDocsEntry = {
id,
slug,
body: '',
collection: 'docs',
filePath: `${getCollectionPathFromRoot('docs', project)}/${stripLeadingAndTrailingSlashes(slug)}.md`,
data: {
...pageFrontmatter,
sidebar: {
attrs: {},
hidden: false,
},
},
};
const entry = pageDocsEntry as StarlightDocsEntry;
const entryMeta: StarlightRouteData['entryMeta'] = {
dir: props.dir ?? localeData.dir,
lang: props.lang ?? localeData.lang,
locale: localeData.locale,
};
const editUrl = pageFrontmatter.editUrl ? new URL(pageFrontmatter.editUrl) : undefined;
const lastUpdated =
pageFrontmatter.lastUpdated instanceof Date ? pageFrontmatter.lastUpdated : undefined;
const pageProps: PageProps = {
...routeProps,
...localeData,
entry,
entryMeta,
headings,
id,
locale: localeData.locale,
slug,
};
const siteTitle = getSiteTitle(localeData.lang);
const routeData: StarlightRouteData = {
...routeProps,
...localeData,
id,
editUrl,
entry,
entryMeta,
hasSidebar: props.hasSidebar ?? entry.data.template !== 'splash',
head: getHead(pageProps, context, siteTitle),
headings,
lastUpdated,
pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
sidebar,
siteTitle,
siteTitleHref: getSiteTitleHref(localeData.locale),
slug,
toc: getToC(pageProps),
};
return routeData;
}
/** Validates the Starlight page frontmatter properties from the props received by a Starlight page. */
async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter) {
const schema = await StarlightPageFrontmatterSchema({
image: (() =>
// Mock validator for ImageMetadata.
// https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32
// It uses a custom validation approach because imported SVGs have a type of `function` as
// well as containing the metadata properties and this ensures we handle those correctly.
z.custom(
(value) =>
value &&
(typeof value === 'function' || typeof value === 'object') &&
'src' in value &&
'width' in value &&
'height' in value &&
'format' in value,
'Invalid image passed to `<StarlightPage>` component. Expected imported `ImageMetadata` object.'
)) as ImageFunction,
});
// Starting with Astro 4.14.0, a frontmatter schema that contains collection references will
// contain an async transform.
return parseAsyncWithFriendlyErrors(
schema,
frontmatter,
'Invalid frontmatter props passed to the `<StarlightPage/>` component.'
);
}
/** Returns the user docs schema and falls back to the default schema if needed. */
async function getUserDocsSchema(): Promise<
NonNullable<ContentConfig['collections']['docs']['schema']>
> {
const userCollections = (await import('virtual:starlight/collection-config')).collections;
return userCollections?.docs?.schema ?? docsSchema();
}

View File

@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import type { i18nSchemaOutput } from '../schemas/i18n';
import { createTranslationSystem } from './createTranslationSystem';
import type { StarlightConfig } from './user-config';
import type { AstroConfig } from 'astro';
const contentCollectionFileExtensions = ['.json', '.yaml', '.yml'];
/**
* Loads and creates a translation system from the file system.
* Only for use in integration code.
* In modules loaded by Vite/Astro, import [`useTranslations`](./translations.ts) instead.
*
* @see [`./translations.ts`](./translations.ts)
*/
export function createTranslationSystemFromFs<T extends i18nSchemaOutput>(
opts: Pick<StarlightConfig, 'defaultLocale' | 'locales'>,
{ srcDir }: Pick<AstroConfig, 'srcDir'>,
pluginTranslations: Record<string, T> = {}
) {
/** All translation data from the i18n collection, keyed by `id`, which matches locale. */
let userTranslations: Record<string, i18nSchemaOutput> = {};
try {
const i18nDir = new URL('content/i18n/', srcDir);
// Load the users i18n directory
const files = fs.readdirSync(i18nDir, 'utf-8');
// Load the users i18n collection and ignore the error if it doesnt exist.
for (const file of files) {
const filePath = path.parse(file);
if (!contentCollectionFileExtensions.includes(filePath.ext)) continue;
const id = filePath.name;
const url = new URL(filePath.base, i18nDir);
const content = fs.readFileSync(new URL(file, i18nDir), 'utf-8');
const data =
filePath.ext === '.json'
? JSON.parse(content)
: yaml.load(content, { filename: fileURLToPath(url) });
userTranslations[id] = data as i18nSchemaOutput;
}
} catch (e: unknown) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
// i18nDir doesnt exist, so we ignore the error.
} else {
// Other errors may be meaningful, e.g. JSON syntax errors, so should be thrown.
throw e;
}
}
return createTranslationSystem(opts, userTranslations, pluginTranslations);
}

View File

@ -0,0 +1,56 @@
import { getCollection, type CollectionEntry, type DataCollectionKey } from 'astro:content';
import config from 'virtual:starlight/user-config';
import project from 'virtual:starlight/project-context';
import pluginTranslations from 'virtual:starlight/plugin-translations';
import type { i18nSchemaOutput } from '../schemas/i18n';
import { createTranslationSystem } from './createTranslationSystem';
import type { RemoveIndexSignature } from './types';
import { getCollectionPathFromRoot } from './collection';
import { stripExtension, stripLeadingSlash } from './path';
// @ts-ignore - This may be a type error in projects without an i18n collection and running
// `tsc --noEmit` in their project. Note that it is not possible to inline this type in
// `UserI18nSchema` because this would break types for users having multiple data collections.
type i18nCollection = CollectionEntry<'i18n'>;
const i18nCollectionPathFromRoot = getCollectionPathFromRoot('i18n', project);
export type UserI18nSchema = 'i18n' extends DataCollectionKey
? i18nCollection extends { data: infer T }
? i18nSchemaOutput & T
: i18nSchemaOutput
: i18nSchemaOutput;
export type UserI18nKeys = keyof RemoveIndexSignature<UserI18nSchema>;
/** Get all translation data from the i18n collection, keyed by `lang`, which are BCP-47 language tags. */
async function loadTranslations() {
// Briefly override `console.warn()` to silence logging when a project has no i18n collection.
const warn = console.warn;
console.warn = () => {};
const userTranslations: Record<string, UserI18nSchema> = Object.fromEntries(
// @ts-ignore — may be a type error in projects without an i18n collection
(await getCollection('i18n')).map(({ id, data, filePath }) => {
const lang =
project.legacyCollections || !filePath
? id
: stripExtension(stripLeadingSlash(filePath.replace(i18nCollectionPathFromRoot, '')));
return [lang, data] as const;
})
);
// Restore the original warn implementation.
console.warn = warn;
return userTranslations;
}
/**
* Generate a utility function that returns UI strings for the given language.
* @param {string | undefined} [lang]
* @example
* const t = useTranslations('en');
* const label = t('search.label'); // => 'Search'
*/
export const useTranslations = createTranslationSystem(
config,
await loadTranslations(),
pluginTranslations
);

View File

@ -0,0 +1,15 @@
// https://stackoverflow.com/a/66252656/1945960
export type RemoveIndexSignature<T> = {
[K in keyof T as string extends K
? never
: number extends K
? never
: symbol extends K
? never
: K]: T[K];
};
// https://www.totaltypescript.com/concepts/the-prettify-helper
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

View File

@ -0,0 +1,347 @@
import { z } from 'astro/zod';
import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47';
import { ComponentConfigSchema } from '../schemas/components';
import { ExpressiveCodeSchema } from '../schemas/expressiveCode';
import { FaviconSchema } from '../schemas/favicon';
import { HeadConfigSchema } from '../schemas/head';
import { LogoConfigSchema } from '../schemas/logo';
import { PagefindConfigDefaults, PagefindConfigSchema } from '../schemas/pagefind';
import { SidebarItemSchema } from '../schemas/sidebar';
import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title';
import { SocialLinksSchema } from '../schemas/social';
import { TableOfContentsSchema } from '../schemas/tableOfContents';
import { BuiltInDefaultLocale } from './i18n';
const LocaleSchema = z.object({
/** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */
label: z
.string()
.describe(
'The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`.'
),
/** The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`. */
lang: z
.string()
.optional()
.describe('The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`.'),
/** The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left. */
dir: z
.enum(['rtl', 'ltr'])
.optional()
.default('ltr')
.describe(
'The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left.'
),
});
const UserConfigSchema = z.object({
/** Title for your website. Will be used in metadata and as browser tab title. */
title: TitleConfigSchema(),
/** Description metadata for your website. Can be used in page metadata. */
description: z
.string()
.optional()
.describe('Description metadata for your website. Can be used in page metadata.'),
/** Set a logo image to show in the navigation bar alongside or instead of the site title. */
logo: LogoConfigSchema(),
/**
* Optional details about the social media accounts for this site.
*
* @example
* social: [
* { icon: 'codeberg', label: 'Codeberg', href: 'https://codeberg.org/knut' },
* { icon: 'discord', label: 'Discord', href: 'https://astro.build/chat' },
* { icon: 'github', label: 'GitHub', href: 'https://github.com/withastro' },
* { icon: 'gitlab', label: 'GitLab', href: 'https://gitlab.com/delucis' },
* { icon: 'mastodon', label: 'Mastodon', href: 'https://m.webtoo.ls/@astro' },
* ]
*/
social: SocialLinksSchema(),
/** The tagline for your website. */
tagline: z.string().optional().describe('The tagline for your website.'),
/** Configure the defaults for the table of contents on each page. */
tableOfContents: TableOfContentsSchema(),
/** Enable and configure “Edit this page” links. */
editLink: z
.object({
/** Set the base URL for edit links. The final link will be `baseUrl` + the current page path. */
baseUrl: z.string().url().optional(),
})
.optional()
.default({}),
/** Configure locales for internationalization (i18n). */
locales: z
.object({
/** Configure a “root” locale to serve a default language from `/`. */
root: LocaleSchema.required({ lang: true }).optional(),
})
.catchall(LocaleSchema)
.transform((locales, ctx) => {
for (const key in locales) {
const locale = locales[key]!;
// Fall back to the key in the locales object as the lang.
let lang = locale.lang || key;
// Parse the lang tag so we can check it is valid according to BCP-47.
const schema = bcpParse(lang, { forgiving: true });
schema.region = schema.region?.toUpperCase();
const normalizedLang = bcpStringify(schema);
// Error if parsing the language tag failed.
if (!normalizedLang) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Could not validate language tag "${lang}" at locales.${key}.lang.`,
});
return z.NEVER;
}
// Let users know were modifying their configured `lang`.
if (normalizedLang !== lang) {
console.warn(
`Warning: using "${normalizedLang}" language tag for locales.${key}.lang instead of "${lang}".`
);
lang = normalizedLang;
}
// Set the final value as the normalized lang, based on the key if needed.
locale.lang = lang;
}
return locales;
})
.optional()
.describe('Configure locales for internationalization (i18n).'),
/**
* Specify the default language for this site.
*
* The default locale will be used to provide fallback content where translations are missing.
*/
defaultLocale: z.string().optional(),
/** Configure your sites sidebar navigation items. */
sidebar: SidebarItemSchema.array().optional(),
/**
* Add extra tags to your sites `<head>`.
*
* Can also be set for a single page in a pages frontmatter.
*
* @example
* // Add Fathom analytics to your site
* starlight({
* head: [
* {
* tag: 'script',
* attrs: {
* src: 'https://cdn.usefathom.com/script.js',
* 'data-site': 'MY-FATHOM-ID',
* defer: true,
* },
* },
* ],
* })
*/
head: HeadConfigSchema(),
/**
* Provide CSS files to customize the look and feel of your Starlight site.
*
* Supports local CSS files relative to the root of your project,
* e.g. `'/src/custom.css'`, and CSS you installed as an npm
* module, e.g. `'@fontsource/roboto'`.
*
* @example
* starlight({
* customCss: ['/src/custom-styles.css', '@fontsource/roboto'],
* })
*/
customCss: z.string().array().optional().default([]),
/** Define if the last update date should be visible in the page footer. */
lastUpdated: z
.boolean()
.default(false)
.describe('Define if the last update date should be visible in the page footer.'),
/** Define if the previous and next page links should be visible in the page footer. */
pagination: z
.boolean()
.default(true)
.describe('Define if the previous and next page links should be visible in the page footer.'),
/** The default favicon for your site which should be a path to an image in the `public/` directory. */
favicon: FaviconSchema(),
/**
* Define how code blocks are rendered by passing options to Expressive Code,
* or disable the integration by passing `false`.
*/
expressiveCode: ExpressiveCodeSchema(),
/**
* Configure Starlights default site search provider Pagefind. Set to `false` to disable indexing
* your site with Pagefind, which will also hide the default search UI if in use.
*/
pagefind: z
.boolean()
// Transform `true` to our default config object.
.transform((val) => val && PagefindConfigDefaults())
.or(PagefindConfigSchema())
.optional(),
/** Specify paths to components that should override Starlights default components */
components: ComponentConfigSchema(),
/** Will be used as title delimiter in the generated `<title>` tag. */
titleDelimiter: z
.string()
.default('|')
.describe('Will be used as title delimiter in the generated `<title>` tag.'),
/** Disable Starlight's default 404 page. */
disable404Route: z.boolean().default(false).describe("Disable Starlight's default 404 page."),
/**
* Define whether Starlight pages should be prerendered or not.
* Defaults to always prerender Starlight pages, even when the project is
* set to "server" output mode.
*/
prerender: z.boolean().default(true),
/** Enable displaying a “Built with Starlight” link in your sites footer. */
credits: z
.boolean()
.default(false)
.describe('Enable displaying a “Built with Starlight” link in your sites footer.'),
/** Add middleware to process Starlights route data for each page. */
routeMiddleware: z
.string()
.transform((string) => [string])
.or(z.string().array())
.default([])
.superRefine((middlewares, ctx) => {
// Regex pattern to match invalid middleware paths: https://regex101.com/r/kQH7xm/2
const invalidPathRegex = /^\.?\/src\/middleware(?:\/index)?\.[jt]s$/;
const invalidPaths = middlewares.filter((middleware) => invalidPathRegex.test(middleware));
for (const invalidPath of invalidPaths) {
ctx.addIssue({
code: 'custom',
message:
`The \`"${invalidPath}"\` path in your Starlight \`routeMiddleware\` config conflicts with Astros middleware locations.\n\n` +
`You should rename \`${invalidPath}\` to something else like \`./src/starlightRouteData.ts\` and update the \`routeMiddleware\` file path to match.\n\n` +
'- More about Starlight route middleware: https://starlight.astro.build/guides/route-data/#how-to-customize-route-data\n' +
'- More about Astro middleware: https://docs.astro.build/en/guides/middleware/',
});
}
})
.describe('Add middleware to process Starlights route data for each page.'),
/** Configure features that impact Starlights Markdown processing. */
markdown: z
.object({
/** Define whether headings in content should be rendered with clickable anchor links. Default: `true`. */
headingLinks: z
.boolean()
.default(true)
.describe(
'Define whether headings in content should be rendered with clickable anchor links. Default: `true`.'
),
})
.default({})
.describe('Configure features that impact Starlights Markdown processing.'),
});
export const StarlightConfigSchema = UserConfigSchema.strict()
.transform((config) => ({
...config,
// Pagefind only defaults to true if prerender is also true.
pagefind:
typeof config.pagefind === 'undefined'
? config.prerender && PagefindConfigDefaults()
: config.pagefind,
}))
.refine((config) => !(!config.prerender && config.pagefind), {
message: 'Pagefind search is not supported with prerendering disabled.',
})
.transform(({ title, locales, defaultLocale, ...config }, ctx) => {
const configuredLocales = Object.keys(locales ?? {});
// This is a multilingual site (more than one locale configured) or a monolingual site with
// only one locale configured (not a root locale).
// Monolingual sites with only one non-root locale needs their configuration to be defined in
// `config.locales` so that slugs can be correctly generated by taking into consideration the
// base path at which a language is served which is the key of the `config.locales` object.
if (
locales !== undefined &&
(configuredLocales.length > 1 ||
(configuredLocales.length === 1 && locales.root === undefined))
) {
// Make sure we can find the default locale and if not, help the user set it.
// We treat the root locale as the default if present and no explicit default is set.
const defaultLocaleConfig = locales[defaultLocale || 'root'];
if (!defaultLocaleConfig) {
const availableLocales = configuredLocales.map((l) => `"${l}"`).join(', ');
ctx.addIssue({
code: 'custom',
message:
'Could not determine the default locale. ' +
'Please make sure `defaultLocale` in your Starlight config is one of ' +
availableLocales,
});
return z.NEVER;
}
// Transform the title
const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang as string);
const parsedTitle = TitleSchema.parse(title);
return {
...config,
title: parsedTitle,
/** Flag indicating if this site has multiple locales set up. */
isMultilingual: configuredLocales.length > 1,
/** Flag indicating if the Starlight built-in default locale is used. */
isUsingBuiltInDefaultLocale: false,
/** Full locale object for this sites default language. */
defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale },
locales,
} as const;
}
// This is a monolingual site with no locales configured or only a root locale, so things are
// pretty simple.
/** Full locale object for this sites default language. */
const defaultLocaleConfig = {
label: BuiltInDefaultLocale.label,
lang: BuiltInDefaultLocale.lang,
dir: BuiltInDefaultLocale.dir,
locale: undefined,
...locales?.root,
};
/** Transform the title */
const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang);
const parsedTitle = TitleSchema.parse(title);
return {
...config,
title: parsedTitle,
/** Flag indicating if this site has multiple locales set up. */
isMultilingual: false,
/** Flag indicating if the Starlight built-in default locale is used. */
isUsingBuiltInDefaultLocale: locales?.root === undefined,
defaultLocale: defaultLocaleConfig,
locales: undefined,
} as const;
});
export type StarlightConfig = z.infer<typeof StarlightConfigSchema>;
export type StarlightUserConfig = z.input<typeof StarlightConfigSchema>;

View File

@ -0,0 +1,22 @@
/*
import config from 'virtual:starlight/user-config';
import { logos } from 'virtual:starlight/user-images';
export function validateLogoImports(): void {
if (config.logo) {
let err: string | undefined;
if ('src' in config.logo) {
if (!logos.dark || !logos.light) {
err = `Could not resolve logo import for "${config.logo.src}" (logo.src)`;
}
} else {
if (!logos.dark) {
err = `Could not resolve logo import for "${config.logo.dark}" (logo.dark)`;
} else if (!logos.light) {
err = `Could not resolve logo import for "${config.logo.light}" (logo.light)`;
}
}
if (err) throw new Error(err);
}
}
*/

View File

@ -0,0 +1,33 @@
// Access to extended Astro config
import type { SidebarGroup } from '@/components/sidebar/types';
// Define extended config interface
interface ExtendedAstroConfig {
sidebar?: SidebarGroup[];
}
// Import the config (this approach works in most cases)
let config: ExtendedAstroConfig = {};
try {
// In development/build, we can access the config
// Note: This might need adjustment based on your setup
if (typeof window === 'undefined') {
// Server-side: try to import the config
config = await import('../../astro.config.mjs').then(m => m.default || m);
}
} catch (error) {
console.warn('Could not load astro config, using fallback');
}
// Fallback configuration that matches your astro.config.mjs
const fallbackConfig: SidebarGroup[] = [
{
label: 'Resources',
autogenerate: { directory: 'resources' },
}
];
export function getSidebarConfig(): SidebarGroup[] {
return config.sidebar || fallbackConfig;
}

View File

@ -0,0 +1,19 @@
// Extract sidebar config from astro.config.mjs
// This file should be updated whenever astro.config.mjs changes
import type { SidebarGroup } from '../components/sidebar/types.js';
export const sidebarConfig: SidebarGroup[] = [
{
label: 'Resources',
autogenerate: {
directory: 'resources',
collapsed: true, // Subgroups default to open
sortBy: 'alphabetical' // Default sort function
},
}
];
export function getSidebarConfig(): SidebarGroup[] {
return sidebarConfig;
}

View File

@ -28,7 +28,6 @@ import { gallery } from '@/base/media.js';
import { get } from '@polymech/commons/component'
import { PFilterValid } from '@polymech/commons/filter'
import { IAssemblyData} from '@polymech/cad'
import { logger as log } from '@/base/index.js'
interface ILoaderContextEx extends LoaderContext {