kbot import
This commit is contained in:
parent
d993f45cc8
commit
e9570bde94
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
30
packages/polymech/src/base/kbot-contexts.ts
Normal file
30
packages/polymech/src/base/kbot-contexts.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
133
packages/polymech/src/base/kbot-templates.ts
Normal file
133
packages/polymech/src/base/kbot-templates.ts
Normal 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
|
||||
};
|
||||
105
packages/polymech/src/base/kbot.ts
Normal file
105
packages/polymech/src/base/kbot.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
157
packages/polymech/src/components/Breadcrumb.astro
Normal file
157
packages/polymech/src/components/Breadcrumb.astro
Normal 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>
|
||||
@ -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
@ -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>
|
||||
|
||||
122
packages/polymech/src/components/sidebar/MobileToggle.astro
Normal file
122
packages/polymech/src/components/sidebar/MobileToggle.astro
Normal 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>
|
||||
50
packages/polymech/src/components/sidebar/Sidebar.astro
Normal file
50
packages/polymech/src/components/sidebar/Sidebar.astro
Normal 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 />
|
||||
140
packages/polymech/src/components/sidebar/SidebarGroup.astro
Normal file
140
packages/polymech/src/components/sidebar/SidebarGroup.astro
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 sibling’s 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);
|
||||
@ -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>
|
||||
28
packages/polymech/src/components/sidebar/config.ts
Normal file
28
packages/polymech/src/components/sidebar/config.ts
Normal 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;
|
||||
}
|
||||
2
packages/polymech/src/components/sidebar/constants.ts
Normal file
2
packages/polymech/src/components/sidebar/constants.ts
Normal 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';
|
||||
33
packages/polymech/src/components/sidebar/generateToC.ts
Normal file
33
packages/polymech/src/components/sidebar/generateToC.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
90
packages/polymech/src/components/sidebar/persist.ts
Normal file
90
packages/polymech/src/components/sidebar/persist.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/polymech/src/components/sidebar/types.ts
Normal file
28
packages/polymech/src/components/sidebar/types.ts
Normal 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[];
|
||||
}
|
||||
336
packages/polymech/src/components/sidebar/utils.ts
Normal file
336
packages/polymech/src/components/sidebar/utils.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
15
packages/polymech/src/components/sidebar/utils/base.ts
Normal file
15
packages/polymech/src/components/sidebar/utils/base.ts
Normal 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 site’s `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 site’s `base` prefixed. */
|
||||
export function fileWithBase(path: string) {
|
||||
path = stripLeadingSlash(path);
|
||||
return path ? base + '/' + path : base;
|
||||
}
|
||||
19
packages/polymech/src/components/sidebar/utils/canonical.ts
Normal file
19
packages/polymech/src/components/sidebar/utils/canonical.ts
Normal 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);
|
||||
}
|
||||
@ -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, '/');
|
||||
}
|
||||
38
packages/polymech/src/components/sidebar/utils/collection.ts
Normal file
38
packages/polymech/src/components/sidebar/utils/collection.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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';
|
||||
};
|
||||
172
packages/polymech/src/components/sidebar/utils/error-map.ts
Normal file
172
packages/polymech/src/components/sidebar/utils/error-map.ts
Normal 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 = /^\./;
|
||||
@ -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,
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
121
packages/polymech/src/components/sidebar/utils/git.ts
Normal file
121
packages/polymech/src/components/sidebar/utils/git.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
20
packages/polymech/src/components/sidebar/utils/gitInlined.ts
Normal file
20
packages/polymech/src/components/sidebar/utils/gitInlined.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
216
packages/polymech/src/components/sidebar/utils/head.ts
Normal file
216
packages/polymech/src/components/sidebar/utils/head.ts
Normal 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 key–value pair in a head entry’s 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 isn’t 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;
|
||||
}
|
||||
215
packages/polymech/src/components/sidebar/utils/i18n.ts
Normal file
215
packages/polymech/src/components/sidebar/utils/i18n.ts
Normal 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 language’s 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
|
||||
* site’s 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>;
|
||||
@ -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) {
|
||||
// We’re 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) {
|
||||
// We’re 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;
|
||||
}
|
||||
548
packages/polymech/src/components/sidebar/utils/navigation.ts
Normal file
548
packages/polymech/src/components/sidebar/utils/navigation.ts
Normal 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 it’s a folder, the key is the directory name, and value is the directory
|
||||
* content; if it’s 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 user’s 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 user’s 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 don’t 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 isn’t 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 directory’s 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;
|
||||
}
|
||||
58
packages/polymech/src/components/sidebar/utils/path.ts
Normal file
58
packages/polymech/src/components/sidebar/utils/path.ts
Normal 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);
|
||||
}
|
||||
461
packages/polymech/src/components/sidebar/utils/plugins.ts
Normal file
461
packages/polymech/src/components/sidebar/utils/plugins.ts
Normal 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>>;
|
||||
160
packages/polymech/src/components/sidebar/utils/routing/data.ts
Normal file
160
packages/polymech/src/components/sidebar/utils/routing/data.ts
Normal 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 Starlight’s 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 };
|
||||
}
|
||||
143
packages/polymech/src/components/sidebar/utils/routing/index.ts
Normal file
143
packages/polymech/src/components/sidebar/utils/routing/index.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 page’s 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;
|
||||
}
|
||||
120
packages/polymech/src/components/sidebar/utils/slugs.ts
Normal file
120
packages/polymech/src/components/sidebar/utils/slugs.ts
Normal 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 Astro’s router.
|
||||
* This utility handles stripping `index` from file names and matches
|
||||
* [Astro’s 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 Astro’s 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('/');
|
||||
}
|
||||
215
packages/polymech/src/components/sidebar/utils/starlight-page.ts
Normal file
215
packages/polymech/src/components/sidebar/utils/starlight-page.ts
Normal 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 Starlight’s
|
||||
* `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();
|
||||
}
|
||||
@ -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 user’s i18n directory
|
||||
const files = fs.readdirSync(i18nDir, 'utf-8');
|
||||
// Load the user’s i18n collection and ignore the error if it doesn’t 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 doesn’t 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);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
15
packages/polymech/src/components/sidebar/utils/types.ts
Normal file
15
packages/polymech/src/components/sidebar/utils/types.ts
Normal 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];
|
||||
} & {};
|
||||
347
packages/polymech/src/components/sidebar/utils/user-config.ts
Normal file
347
packages/polymech/src/components/sidebar/utils/user-config.ts
Normal 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 we’re 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 site’s sidebar navigation items. */
|
||||
sidebar: SidebarItemSchema.array().optional(),
|
||||
|
||||
/**
|
||||
* Add extra tags to your site’s `<head>`.
|
||||
*
|
||||
* Can also be set for a single page in a page’s 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 Starlight’s 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 Starlight’s 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 site’s footer. */
|
||||
credits: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Enable displaying a “Built with Starlight” link in your site’s footer.'),
|
||||
|
||||
/** Add middleware to process Starlight’s 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 Astro’s 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 Starlight’s route data for each page.'),
|
||||
|
||||
/** Configure features that impact Starlight’s 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 Starlight’s 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 site’s 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 site’s 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>;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
*/
|
||||
33
packages/polymech/src/config/astro-config.ts
Normal file
33
packages/polymech/src/config/astro-config.ts
Normal 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;
|
||||
}
|
||||
19
packages/polymech/src/config/sidebar.ts
Normal file
19
packages/polymech/src/config/sidebar.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user