From e9570bde941d19d3dc65d820b0c1b049bc27381a Mon Sep 17 00:00:00 2001 From: babayaga Date: Wed, 20 Aug 2025 11:16:21 +0200 Subject: [PATCH] kbot import --- packages/polymech/package.json | 5 +- packages/polymech/src/base/index.ts | 2 +- packages/polymech/src/base/kbot-contexts.ts | 30 + packages/polymech/src/base/kbot-templates.ts | 133 ++ packages/polymech/src/base/kbot.ts | 105 ++ .../polymech/src/components/Breadcrumb.astro | 157 +++ .../polymech/src/components/GalleryK.astro | 4 +- .../src/components/MasonryGallery.astro | 1098 ++++++++--------- packages/polymech/src/components/i18n.astro | 4 +- .../src/components/sidebar/MobileToggle.astro | 122 ++ .../src/components/sidebar/Sidebar.astro | 50 + .../src/components/sidebar/SidebarGroup.astro | 140 +++ .../components/sidebar/SidebarPersister.astro | 99 ++ .../components/sidebar/TableOfContents.astro | 86 ++ .../TableOfContents/TableOfContentsList.astro | 110 ++ .../sidebar/TableOfContents/starlight-toc.ts | 113 ++ .../sidebar/TableOfContentsWithScroll.astro | 233 ++++ .../polymech/src/components/sidebar/config.ts | 28 + .../src/components/sidebar/constants.ts | 2 + .../src/components/sidebar/generateToC.ts | 33 + .../src/components/sidebar/persist.ts | 90 ++ .../polymech/src/components/sidebar/types.ts | 28 + .../polymech/src/components/sidebar/utils.ts | 336 +++++ .../src/components/sidebar/utils/base.ts | 15 + .../src/components/sidebar/utils/canonical.ts | 19 + .../components/sidebar/utils/collection-fs.ts | 21 + .../components/sidebar/utils/collection.ts | 38 + .../sidebar/utils/createPathFormatter.ts | 60 + .../sidebar/utils/createTranslationSystem.ts | 134 ++ .../src/components/sidebar/utils/error-map.ts | 172 +++ .../components/sidebar/utils/format-path.ts | 7 + .../components/sidebar/utils/generateToC.ts | 33 + .../src/components/sidebar/utils/git.ts | 121 ++ .../components/sidebar/utils/gitInlined.ts | 20 + .../src/components/sidebar/utils/head.ts | 216 ++++ .../src/components/sidebar/utils/i18n.ts | 215 ++++ .../components/sidebar/utils/localizedUrl.ts | 51 + .../components/sidebar/utils/navigation.ts | 548 ++++++++ .../src/components/sidebar/utils/path.ts | 58 + .../src/components/sidebar/utils/plugins.ts | 461 +++++++ .../components/sidebar/utils/routing/data.ts | 160 +++ .../components/sidebar/utils/routing/index.ts | 143 +++ .../sidebar/utils/routing/middleware.ts | 81 ++ .../components/sidebar/utils/routing/types.ts | 99 ++ .../src/components/sidebar/utils/slugs.ts | 120 ++ .../sidebar/utils/starlight-page.ts | 215 ++++ .../sidebar/utils/translations-fs.ts | 53 + .../components/sidebar/utils/translations.ts | 56 + .../src/components/sidebar/utils/types.ts | 15 + .../components/sidebar/utils/user-config.ts | 347 ++++++ .../sidebar/utils/validateLogoImports.ts | 22 + packages/polymech/src/config/astro-config.ts | 33 + packages/polymech/src/config/sidebar.ts | 19 + packages/polymech/src/model/component.ts | 1 - 54 files changed, 5999 insertions(+), 562 deletions(-) create mode 100644 packages/polymech/src/base/kbot-contexts.ts create mode 100644 packages/polymech/src/base/kbot-templates.ts create mode 100644 packages/polymech/src/base/kbot.ts create mode 100644 packages/polymech/src/components/Breadcrumb.astro create mode 100644 packages/polymech/src/components/sidebar/MobileToggle.astro create mode 100644 packages/polymech/src/components/sidebar/Sidebar.astro create mode 100644 packages/polymech/src/components/sidebar/SidebarGroup.astro create mode 100644 packages/polymech/src/components/sidebar/SidebarPersister.astro create mode 100644 packages/polymech/src/components/sidebar/TableOfContents.astro create mode 100644 packages/polymech/src/components/sidebar/TableOfContents/TableOfContentsList.astro create mode 100644 packages/polymech/src/components/sidebar/TableOfContents/starlight-toc.ts create mode 100644 packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro create mode 100644 packages/polymech/src/components/sidebar/config.ts create mode 100644 packages/polymech/src/components/sidebar/constants.ts create mode 100644 packages/polymech/src/components/sidebar/generateToC.ts create mode 100644 packages/polymech/src/components/sidebar/persist.ts create mode 100644 packages/polymech/src/components/sidebar/types.ts create mode 100644 packages/polymech/src/components/sidebar/utils.ts create mode 100644 packages/polymech/src/components/sidebar/utils/base.ts create mode 100644 packages/polymech/src/components/sidebar/utils/canonical.ts create mode 100644 packages/polymech/src/components/sidebar/utils/collection-fs.ts create mode 100644 packages/polymech/src/components/sidebar/utils/collection.ts create mode 100644 packages/polymech/src/components/sidebar/utils/createPathFormatter.ts create mode 100644 packages/polymech/src/components/sidebar/utils/createTranslationSystem.ts create mode 100644 packages/polymech/src/components/sidebar/utils/error-map.ts create mode 100644 packages/polymech/src/components/sidebar/utils/format-path.ts create mode 100644 packages/polymech/src/components/sidebar/utils/generateToC.ts create mode 100644 packages/polymech/src/components/sidebar/utils/git.ts create mode 100644 packages/polymech/src/components/sidebar/utils/gitInlined.ts create mode 100644 packages/polymech/src/components/sidebar/utils/head.ts create mode 100644 packages/polymech/src/components/sidebar/utils/i18n.ts create mode 100644 packages/polymech/src/components/sidebar/utils/localizedUrl.ts create mode 100644 packages/polymech/src/components/sidebar/utils/navigation.ts create mode 100644 packages/polymech/src/components/sidebar/utils/path.ts create mode 100644 packages/polymech/src/components/sidebar/utils/plugins.ts create mode 100644 packages/polymech/src/components/sidebar/utils/routing/data.ts create mode 100644 packages/polymech/src/components/sidebar/utils/routing/index.ts create mode 100644 packages/polymech/src/components/sidebar/utils/routing/middleware.ts create mode 100644 packages/polymech/src/components/sidebar/utils/routing/types.ts create mode 100644 packages/polymech/src/components/sidebar/utils/slugs.ts create mode 100644 packages/polymech/src/components/sidebar/utils/starlight-page.ts create mode 100644 packages/polymech/src/components/sidebar/utils/translations-fs.ts create mode 100644 packages/polymech/src/components/sidebar/utils/translations.ts create mode 100644 packages/polymech/src/components/sidebar/utils/types.ts create mode 100644 packages/polymech/src/components/sidebar/utils/user-config.ts create mode 100644 packages/polymech/src/components/sidebar/utils/validateLogoImports.ts create mode 100644 packages/polymech/src/config/astro-config.ts create mode 100644 packages/polymech/src/config/sidebar.ts diff --git a/packages/polymech/package.json b/packages/polymech/package.json index 12bd6b9..c967849 100644 --- a/packages/polymech/package.json +++ b/packages/polymech/package.json @@ -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", diff --git a/packages/polymech/src/base/index.ts b/packages/polymech/src/base/index.ts index 5dee470..9cc953a 100644 --- a/packages/polymech/src/base/index.ts +++ b/packages/polymech/src/base/index.ts @@ -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, diff --git a/packages/polymech/src/base/kbot-contexts.ts b/packages/polymech/src/base/kbot-contexts.ts new file mode 100644 index 0000000..7ad73a6 --- /dev/null +++ b/packages/polymech/src/base/kbot-contexts.ts @@ -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.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' + } +}; \ No newline at end of file diff --git a/packages/polymech/src/base/kbot-templates.ts b/packages/polymech/src/base/kbot-templates.ts new file mode 100644 index 0000000..2e5418a --- /dev/null +++ b/packages/polymech/src/base/kbot-templates.ts @@ -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; +const LLMConfigSchema = z.object({ + options: z.record(OptionsSchema()), + instructions: InstructionSetSchema.optional(), + defaults: z.record(z.array(z.string())).optional() +}); +type LLMConfig = z.infer; + +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, + defaults: Record +): 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); + + 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; +const PromptRegistrySchema = z.record(PromptSchema); +type PromptRegistry = z.infer; + +const createTemplate = (config: LLMConfig, name: string, defaults: Partial) => { + return (opts: Partial = {}) => { + 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 +}; \ No newline at end of file diff --git a/packages/polymech/src/base/kbot.ts b/packages/polymech/src/base/kbot.ts new file mode 100644 index 0000000..a53afb2 --- /dev/null +++ b/packages/polymech/src/base/kbot.ts @@ -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, + }); +}; \ No newline at end of file diff --git a/packages/polymech/src/components/Breadcrumb.astro b/packages/polymech/src/components/Breadcrumb.astro new file mode 100644 index 0000000..9f0ed70 --- /dev/null +++ b/packages/polymech/src/components/Breadcrumb.astro @@ -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); +--- + + + + diff --git a/packages/polymech/src/components/GalleryK.astro b/packages/polymech/src/components/GalleryK.astro index 448f246..05020e9 100644 --- a/packages/polymech/src/components/GalleryK.astro +++ b/packages/polymech/src/components/GalleryK.astro @@ -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" >
GroupInfo; - -export interface Props { - images?: Image[]; - glob?: string; // Glob pattern for auto-loading images - maxItems?: number; // Maximum number of images to display - maxWidth?: string; // Maximum width for individual images (e.g., "300px") - maxHeight?: string; // Maximum height for individual images (e.g., "400px") - entryPath?: string; // Content entry path for resolving relative images - groupBy?: GroupByFunction | 'groupByYear' | 'groupByMonth' | null; // Grouping strategy (default: null = no grouping) - gallerySettings?: { - SIZES_REGULAR?: string; - SIZES_THUMB?: string; - SIZES_LARGE?: string; - SHOW_TITLE?: boolean; - SHOW_DESCRIPTION?: boolean; - }; - lightboxSettings?: { - SIZES_REGULAR?: string; - SIZES_THUMB?: string; - SIZES_LARGE?: string; - SHOW_TITLE?: boolean; - SHOW_DESCRIPTION?: boolean; - }; -} - -const { - images = [], - glob: globPattern, - maxItems = 50, - maxWidth = "300px", - maxHeight = "400px", - entryPath, - groupBy = null, - gallerySettings = {}, - lightboxSettings = {} -} = Astro.props; - -const mergedGallerySettings = { - SIZES_REGULAR: gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.GALLERY.SIZES_REGULAR, - SIZES_THUMB: gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.GALLERY.SIZES_THUMB, - SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.GALLERY.SHOW_TITLE, - SHOW_DESCRIPTION: gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.GALLERY.SHOW_DESCRIPTION, -}; - -const mergedLightboxSettings = { - SIZES_LARGE: lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.LIGHTBOX.SIZES_LARGE, - SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_TITLE, - SHOW_DESCRIPTION: lightboxSettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_DESCRIPTION, -}; - -const locale = Astro.currentLocale || "en"; - -// Calculate responsive gap and grid columns based on maxWidth -const maxWidthNum = parseInt(maxWidth); -const gap = maxWidthNum <= 200 ? '0.5rem' : maxWidthNum <= 300 ? '0.75rem' : '1rem'; - -// Calculate responsive grid columns - use smaller minmax for smaller images -const getGridColumns = (maxWidth: string) => { - const width = parseInt(maxWidth); - if (width <= 200) { - return 'repeat(auto-fill, minmax(140px, 1fr))'; - } else if (width <= 300) { - return 'repeat(auto-fill, minmax(200px, 1fr))'; - } else { - return 'repeat(auto-fill, minmax(250px, 1fr))'; - } -}; - -const gridColumns = getGridColumns(maxWidth); - -let allImages: Image[] = [...images]; - -// Process glob patterns if provided -if (globPattern) { - try { - // Get current content directory from URL or use entryPath - const currentUrl = Astro.url.pathname; - const pathSegments = currentUrl.split('/').filter(Boolean); - - // Handle locale-aware URLs - const isLocaleFirst = pathSegments.length > 0 && pathSegments[0].length === 2 && /^[a-z]{2}$/.test(pathSegments[0]); - - // Determine content subdirectory - let contentSubdir = 'resources'; - if (pathSegments.length >= 1) { - contentSubdir = isLocaleFirst && pathSegments.length > 1 - ? pathSegments[1] - : pathSegments[0]; - } - - // Get nested content directory - let contentPath = contentSubdir; - const minNestedSegments = isLocaleFirst ? 4 : 3; - - if (pathSegments.length >= minNestedSegments) { - const nestedDirIndex = isLocaleFirst ? 2 : 1; - if (pathSegments.length > nestedDirIndex) { - const nestedDir = pathSegments[nestedDirIndex]; - contentPath = `${contentSubdir}/${nestedDir}`; - } - } - - // Use entryPath if provided, otherwise derive from URL (same as RelativeGallery) - const finalContentPath = entryPath ? entryPath.substring(0, entryPath.lastIndexOf('/')) : contentPath; - const contentDir = path.join(process.cwd(), 'src', 'content', finalContentPath); - - let matchedFiles = glob.sync(globPattern, { cwd: contentDir, absolute: true }); - - if (matchedFiles.length === 0) { - const pathInfo2 = pathInfo(globPattern, false, path.join(contentDir, globBase(globPattern).base)); - matchedFiles = pathInfo2.FILES; - } - - // Process matched files - const globImages: Image[] = await Promise.all( - matchedFiles.slice(0, maxItems).map(async (filePath) => { - const fileName = path.basename(filePath, path.extname(filePath)); - // Get the relative path from the content directory, not from process.cwd() - const relativeFromContentDir = path.relative(contentDir, filePath); - // Convert to a path that resolveImagePath can handle (like "./gallery/image.jpg") - const relativeSrc = `./${relativeFromContentDir.replace(/\\/g, '/')}`; - - // Extract comprehensive metadata using the same logic as media.ts - const metadata = await extractImageMetadata(filePath); - - // Create image structure with extracted metadata - const image: Image = { - src: relativeSrc, - alt: metadata.description || `Image: ${fileName}`, - title: metadata.title, - description: metadata.description || `Auto-loaded from ${globPattern}`, - year: metadata.year, - dateTaken: metadata.dateTaken, - }; - - return image; - }) - ); - - allImages = [...allImages, ...globImages]; - } catch (error) { - console.warn('Glob pattern failed:', error); - } -} - -// Apply maxItems constraint and resolve image paths -const resolvedImages = allImages.slice(0, maxItems).map(image => { - const resolvedSrc = resolveImagePath(image.src, entryPath, Astro.url); - // Ensure the path is absolute (starts with /) for proper browser resolution - const absoluteSrc = resolvedSrc.startsWith('/') ? resolvedSrc : `/${resolvedSrc}`; - return { - ...image, - src: absoluteSrc - }; -}); - -// Determine the grouping function to use -let groupingFunction: GroupByFunction | null = null; -if (groupBy === 'groupByYear') { - groupingFunction = groupByYear; -} else if (groupBy === 'groupByMonth') { - groupingFunction = groupByMonth; -} else if (typeof groupBy === 'function') { - groupingFunction = groupBy; -} - -let imagesByGroup: Record = {}; -let sortedGroupKeys: string[] = []; -let finalImages: Image[] = []; - -if (groupingFunction) { - // Group images using the specified function - imagesByGroup = resolvedImages.reduce((groups, image) => { - const groupInfo = groupingFunction!(image); - if (!groups[groupInfo.key]) { - groups[groupInfo.key] = { images: [], groupInfo }; - } - groups[groupInfo.key].images.push(image); - return groups; - }, {} as Record); - - // Sort groups by sortOrder (descending - newest first) - sortedGroupKeys = Object.keys(imagesByGroup).sort((a, b) => - imagesByGroup[b].groupInfo.sortOrder - imagesByGroup[a].groupInfo.sortOrder - ); - - // Sort images within each group by date (newest first) - sortedGroupKeys.forEach(key => { - imagesByGroup[key].images.sort((a, b) => { - if (a.dateTaken && b.dateTaken) { - return b.dateTaken.getTime() - a.dateTaken.getTime(); - } - return 0; - }); - }); - - // Create flat array with proper sorting for lightbox - finalImages = sortedGroupKeys.flatMap(key => imagesByGroup[key].images); -} else { - // No grouping - sort all images by date (newest first) - finalImages = resolvedImages.sort((a, b) => { - if (a.dateTaken && b.dateTaken) { - return b.dateTaken.getTime() - a.dateTaken.getTime(); - } - return 0; - }); -} - ---- - -
= this.minSwipeDistance) { - if (swipeDistance > 0 && this.currentIndex > 0) { - this.currentIndex--; - this.preloadAndOpen(); - } else if (swipeDistance < 0 && this.currentIndex < this.total - 1) { - this.currentIndex++; - this.preloadAndOpen(); - } - } - this.isSwiping = false; - }, - preloadAndOpen() { - if (this.isSwiping) return; - this.lightboxLoaded = false; - let img = new Image(); - const currentImage = this.images[this.currentIndex]; - - // Use the original resolved src - imagetools will handle the optimization - img.src = currentImage.src; - img.onload = () => { - this.lightboxLoaded = true; - this.open = true; - }; - } - }`} - @keydown.escape.window="open = false" - @keydown.window="if(open){ if($event.key === 'ArrowRight' && currentIndex < total - 1){ currentIndex++; preloadAndOpen(); } else if($event.key === 'ArrowLeft' && currentIndex > 0){ currentIndex--; preloadAndOpen(); } }" - class="masonry-gallery" -> - {groupingFunction ? ( - -
- {sortedGroupKeys.map((groupKey) => { - const groupData = imagesByGroup[groupKey]; - const groupImages = groupData.images; - - return ( -
-

- {groupData.groupInfo.label} - - ({groupImages.length} {groupImages.length === 1 ? 'image' : 'images'}) - -

- -
- {groupImages.map((image, groupIndex) => { - const globalIndex = finalImages.findIndex(img => img.src === image.src); - return ( -
-
- {image.alt} - - {/* Overlay with title/description on hover */} - {(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( -
-
- {mergedGallerySettings.SHOW_TITLE && image.title && ( -

- {image.title} -

- )} - {mergedGallerySettings.SHOW_DESCRIPTION && image.description && ( -

- {image.description} -

- )} -
-
- )} -
-
- ); - })} -
-
- ); - })} -
- ) : ( - -
- {finalImages.map((image, index) => ( -
-
- {image.alt} - - {/* Overlay with title/description on hover */} - {(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( -
-
- {mergedGallerySettings.SHOW_TITLE && image.title && ( -

- {image.title} -

- )} - {mergedGallerySettings.SHOW_DESCRIPTION && image.description && ( -

- {image.description} -

- )} -
-
- )} -
-
- ))} -
- )} - - -
-
- {finalImages.map((image, index) => { - return ( -
- {image.alt} - {(mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && ( -
- {mergedLightboxSettings.SHOW_TITLE && image.title && ( -

- {image.title} -

- )} - {mergedLightboxSettings.SHOW_DESCRIPTION && image.description && ( -

- {image.description} -

- )} -
- )} -
- ); - })} - - - - - - - -
-
-
- - \ No newline at end of file diff --git a/packages/polymech/src/components/i18n.astro b/packages/polymech/src/components/i18n.astro index 0265458..7998908 100644 --- a/packages/polymech/src/components/i18n.astro +++ b/packages/polymech/src/components/i18n.astro @@ -16,6 +16,6 @@ const { const content = await Astro.slots.render('default') const translatedText = await translate(content, I18N_SOURCE_LANGUAGE, language, rest) --- -
+

{translatedText} -

+

diff --git a/packages/polymech/src/components/sidebar/MobileToggle.astro b/packages/polymech/src/components/sidebar/MobileToggle.astro new file mode 100644 index 0000000..04735b9 --- /dev/null +++ b/packages/polymech/src/components/sidebar/MobileToggle.astro @@ -0,0 +1,122 @@ +--- +// Simple mobile toggle for sidebar +import Translate from "@polymech/astro-base/components/i18n.astro"; +--- + + + + diff --git a/packages/polymech/src/components/sidebar/Sidebar.astro b/packages/polymech/src/components/sidebar/Sidebar.astro new file mode 100644 index 0000000..4f284f4 --- /dev/null +++ b/packages/polymech/src/components/sidebar/Sidebar.astro @@ -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)) +); +--- + + + + \ No newline at end of file diff --git a/packages/polymech/src/components/sidebar/SidebarGroup.astro b/packages/polymech/src/components/sidebar/SidebarGroup.astro new file mode 100644 index 0000000..f2e899a --- /dev/null +++ b/packages/polymech/src/components/sidebar/SidebarGroup.astro @@ -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; +--- + +
+ {group.items && group.items.length > 0 && ( + <> + {/* Group title - always show for top-level, and for sub-groups */} + {(!isNested || group.isSubGroup) && ( + + )} + + {/* Render items only if this is not a sub-group (sub-groups render recursively) */} + {!group.isSubGroup && ( + + )} + + )} +
+ + diff --git a/packages/polymech/src/components/sidebar/SidebarPersister.astro b/packages/polymech/src/components/sidebar/SidebarPersister.astro new file mode 100644 index 0000000..bc25552 --- /dev/null +++ b/packages/polymech/src/components/sidebar/SidebarPersister.astro @@ -0,0 +1,99 @@ +--- +--- + diff --git a/packages/polymech/src/components/sidebar/TableOfContents.astro b/packages/polymech/src/components/sidebar/TableOfContents.astro new file mode 100644 index 0000000..91da0c2 --- /dev/null +++ b/packages/polymech/src/components/sidebar/TableOfContents.astro @@ -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 && ( +
+

+ {title} +

+ +
+)} + + diff --git a/packages/polymech/src/components/sidebar/TableOfContents/TableOfContentsList.astro b/packages/polymech/src/components/sidebar/TableOfContents/TableOfContentsList.astro new file mode 100644 index 0000000..bf4531b --- /dev/null +++ b/packages/polymech/src/components/sidebar/TableOfContents/TableOfContentsList.astro @@ -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) + '├─ '; +} +--- + + + + diff --git a/packages/polymech/src/components/sidebar/TableOfContents/starlight-toc.ts b/packages/polymech/src/components/sidebar/TableOfContents/starlight-toc.ts new file mode 100644 index 0000000..cd31980 --- /dev/null +++ b/packages/polymech/src/components/sidebar/TableOfContents/starlight-toc.ts @@ -0,0 +1,113 @@ +import { PAGE_TITLE_ID } from '../../constants'; + +export class StarlightTOC extends HTMLElement { + private _current = this.querySelector('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; + // `` 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); diff --git a/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro b/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro new file mode 100644 index 0000000..601225d --- /dev/null +++ b/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro @@ -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 && ( +
+

+ {title} +

+ +
+)} + + + + diff --git a/packages/polymech/src/components/sidebar/config.ts b/packages/polymech/src/components/sidebar/config.ts new file mode 100644 index 0000000..fa541fc --- /dev/null +++ b/packages/polymech/src/components/sidebar/config.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/constants.ts b/packages/polymech/src/components/sidebar/constants.ts new file mode 100644 index 0000000..d941c23 --- /dev/null +++ b/packages/polymech/src/components/sidebar/constants.ts @@ -0,0 +1,2 @@ +/** Identifier for the page title h1 when it is injected into the ToC. */ +export const PAGE_TITLE_ID = 'starlight__overview'; diff --git a/packages/polymech/src/components/sidebar/generateToC.ts b/packages/polymech/src/components/sidebar/generateToC.ts new file mode 100644 index 0000000..c5996e0 --- /dev/null +++ b/packages/polymech/src/components/sidebar/generateToC.ts @@ -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 = [{ 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); + } +} diff --git a/packages/polymech/src/components/sidebar/persist.ts b/packages/polymech/src/components/sidebar/persist.ts new file mode 100644 index 0000000..3b1a6be --- /dev/null +++ b/packages/polymech/src/components/sidebar/persist.ts @@ -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 { + 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}`); + } + } +} diff --git a/packages/polymech/src/components/sidebar/types.ts b/packages/polymech/src/components/sidebar/types.ts new file mode 100644 index 0000000..8fe1a0c --- /dev/null +++ b/packages/polymech/src/components/sidebar/types.ts @@ -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[]; +} diff --git a/packages/polymech/src/components/sidebar/utils.ts b/packages/polymech/src/components/sidebar/utils.ts new file mode 100644 index 0000000..044990d --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils.ts @@ -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 { + 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 ''; + } +} diff --git a/packages/polymech/src/components/sidebar/utils/base.ts b/packages/polymech/src/components/sidebar/utils/base.ts new file mode 100644 index 0000000..49533f6 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/base.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/utils/canonical.ts b/packages/polymech/src/components/sidebar/utils/canonical.ts new file mode 100644 index 0000000..5a0da5d --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/canonical.ts @@ -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); +} diff --git a/packages/polymech/src/components/sidebar/utils/collection-fs.ts b/packages/polymech/src/components/sidebar/utils/collection-fs.ts new file mode 100644 index 0000000..f641039 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/collection-fs.ts @@ -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, '/'); +} diff --git a/packages/polymech/src/components/sidebar/utils/collection.ts b/packages/polymech/src/components/sidebar/utils/collection.ts new file mode 100644 index 0000000..086815a --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/collection.ts @@ -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 + ); +} diff --git a/packages/polymech/src/components/sidebar/utils/createPathFormatter.ts b/packages/polymech/src/components/sidebar/utils/createPathFormatter.ts new file mode 100644 index 0000000..52c9527 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/createPathFormatter.ts @@ -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); +} diff --git a/packages/polymech/src/components/sidebar/utils/createTranslationSystem.ts b/packages/polymech/src/components/sidebar/utils/createTranslationSystem.ts new file mode 100644 index 0000000..540b74b --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/createTranslationSystem.ts @@ -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( + config: Pick, + userTranslations: Record, + pluginTranslations: Record = {} +) { + 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>( + ...dictionaries: (T | BuiltInStrings | undefined)[] +): { [I18nextNamespace]: BuiltInStrings & T } { + const dictionary: Partial = {}; + // 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'; +}; diff --git a/packages/polymech/src/components/sidebar/utils/error-map.ts b/packages/polymech/src/components/sidebar/utils/error-map.ts new file mode 100644 index 0000000..9c7475a --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/error-map.ts @@ -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( + schema: T, + input: z.input, + message: string +): z.output { + 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( + schema: T, + input: z.input, + message: string +): Promise> { + return processParsedData(await schema.safeParseAsync(input, { errorMap }), message); +} + +function processParsedData(parsedData: z.SafeParseReturnType, 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 = 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) => + [...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 = /^\./; diff --git a/packages/polymech/src/components/sidebar/utils/format-path.ts b/packages/polymech/src/components/sidebar/utils/format-path.ts new file mode 100644 index 0000000..147045a --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/format-path.ts @@ -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, +}); diff --git a/packages/polymech/src/components/sidebar/utils/generateToC.ts b/packages/polymech/src/components/sidebar/utils/generateToC.ts new file mode 100644 index 0000000..e0a2de0 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/generateToC.ts @@ -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 = [{ 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); + } +} diff --git a/packages/polymech/src/components/sidebar/utils/git.ts b/packages/polymech/src/components/sidebar/utils/git.ts new file mode 100644 index 0000000..f05fd88 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/git.ts @@ -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 = /^(?\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: + '--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(); + + for (const logLine of gitLog.stdout.split('\n')) { + if (logLine.startsWith('t:')) { + // t: + runningDate = Number.parseInt(logLine.slice(2)) * 1000; + } + + // - Added files take the format `A\t` + // - Modified files take the format `M\t` + // - Deleted files take the format `D\t` + // - Renamed files take the format `R\t\t` + // - Copied files take the format `C\t\t` + // 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]; + }); +} diff --git a/packages/polymech/src/components/sidebar/utils/gitInlined.ts b/packages/polymech/src/components/sidebar/utils/gitInlined.ts new file mode 100644 index 0000000..387e67a --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/gitInlined.ts @@ -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; + +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); + }, + }; +}; diff --git a/packages/polymech/src/components/sidebar/utils/head.ts b/packages/polymech/src/components/sidebar/utils/head.ts new file mode 100644 index 0000000..2883110 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/head.ts @@ -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 `` 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; +} diff --git a/packages/polymech/src/components/sidebar/utils/i18n.ts b/packages/polymech/src/components/sidebar/utils/i18n.ts new file mode 100644 index 0000000..c212c7b --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/i18n.ts @@ -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>; diff --git a/packages/polymech/src/components/sidebar/utils/localizedUrl.ts b/packages/polymech/src/components/sidebar/utils/localizedUrl.ts new file mode 100644 index 0000000..5b9debe --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/localizedUrl.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/utils/navigation.ts b/packages/polymech/src/components/sidebar/utils/navigation.ts new file mode 100644 index 0000000..746bb44 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/navigation.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/utils/path.ts b/packages/polymech/src/components/sidebar/utils/path.ts new file mode 100644 index 0000000..cf30d03 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/path.ts @@ -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); +} diff --git a/packages/polymech/src/components/sidebar/utils/plugins.ts b/packages/polymech/src/components/sidebar/utils/plugins.ts new file mode 100644 index 0000000..480bfb6 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/plugins.ts @@ -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>>; diff --git a/packages/polymech/src/components/sidebar/utils/routing/data.ts b/packages/polymech/src/components/sidebar/utils/routing/data.ts new file mode 100644 index 0000000..8e1dcb2 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/routing/data.ts @@ -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 }; +} diff --git a/packages/polymech/src/components/sidebar/utils/routing/index.ts b/packages/polymech/src/components/sidebar/utils/routing/index.ts new file mode 100644 index 0000000..460d15a --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/routing/index.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/utils/routing/middleware.ts b/packages/polymech/src/components/sidebar/utils/routing/middleware.ts new file mode 100644 index 0000000..9aa09a5 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/routing/middleware.ts @@ -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); + } +} diff --git a/packages/polymech/src/components/sidebar/utils/routing/types.ts b/packages/polymech/src/components/sidebar/utils/routing/types.ts new file mode 100644 index 0000000..7a30735 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/routing/types.ts @@ -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; +} diff --git a/packages/polymech/src/components/sidebar/utils/slugs.ts b/packages/polymech/src/components/sidebar/utils/slugs.ts new file mode 100644 index 0000000..fb8c4bd --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/slugs.ts @@ -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('/'); +} diff --git a/packages/polymech/src/components/sidebar/utils/starlight-page.ts b/packages/polymech/src/components/sidebar/utils/starlight-page.ts new file mode 100644 index 0000000..c671145 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/starlight-page.ts @@ -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(); +} diff --git a/packages/polymech/src/components/sidebar/utils/translations-fs.ts b/packages/polymech/src/components/sidebar/utils/translations-fs.ts new file mode 100644 index 0000000..4bcfcda --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/translations-fs.ts @@ -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); +} diff --git a/packages/polymech/src/components/sidebar/utils/translations.ts b/packages/polymech/src/components/sidebar/utils/translations.ts new file mode 100644 index 0000000..71ce6e7 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/translations.ts @@ -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 +); diff --git a/packages/polymech/src/components/sidebar/utils/types.ts b/packages/polymech/src/components/sidebar/utils/types.ts new file mode 100644 index 0000000..46ac481 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/types.ts @@ -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]; +} & {}; diff --git a/packages/polymech/src/components/sidebar/utils/user-config.ts b/packages/polymech/src/components/sidebar/utils/user-config.ts new file mode 100644 index 0000000..5695551 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/user-config.ts @@ -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>; diff --git a/packages/polymech/src/components/sidebar/utils/validateLogoImports.ts b/packages/polymech/src/components/sidebar/utils/validateLogoImports.ts new file mode 100644 index 0000000..153ecd4 --- /dev/null +++ b/packages/polymech/src/components/sidebar/utils/validateLogoImports.ts @@ -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); + } +} +*/ \ No newline at end of file diff --git a/packages/polymech/src/config/astro-config.ts b/packages/polymech/src/config/astro-config.ts new file mode 100644 index 0000000..d1059ff --- /dev/null +++ b/packages/polymech/src/config/astro-config.ts @@ -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; +} diff --git a/packages/polymech/src/config/sidebar.ts b/packages/polymech/src/config/sidebar.ts new file mode 100644 index 0000000..7dbea77 --- /dev/null +++ b/packages/polymech/src/config/sidebar.ts @@ -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; +} diff --git a/packages/polymech/src/model/component.ts b/packages/polymech/src/model/component.ts index 69ca209..65e4e60 100644 --- a/packages/polymech/src/model/component.ts +++ b/packages/polymech/src/model/component.ts @@ -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 {