latest
This commit is contained in:
parent
e9570bde94
commit
7a8e2606ba
467
packages/polymech/src/base/collections.ts
Normal file
467
packages/polymech/src/base/collections.ts
Normal file
@ -0,0 +1,467 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { isFolder } from '@polymech/commons';
|
||||
import { parseFrontmatter } from '@astrojs/markdown-remark';
|
||||
|
||||
// Filter function type
|
||||
export type CollectionFilter<T = any> = (entry: CollectionEntry<T>, astroConfig?: any) => boolean;
|
||||
|
||||
// Config interface for collection filters
|
||||
export interface CollectionFilterConfig {
|
||||
ENABLE_VALID_FRONTMATTER_CHECK?: boolean;
|
||||
ENABLE_FOLDER_FILTER?: boolean;
|
||||
ENABLE_DRAFT_FILTER?: boolean;
|
||||
ENABLE_TITLE_FILTER?: boolean;
|
||||
ENABLE_BODY_FILTER?: boolean;
|
||||
ENABLE_DESCRIPTION_FILTER?: boolean;
|
||||
ENABLE_IMAGE_FILTER?: boolean;
|
||||
ENABLE_AUTHOR_FILTER?: boolean;
|
||||
ENABLE_PUBDATE_FILTER?: boolean;
|
||||
ENABLE_TAGS_FILTER?: boolean;
|
||||
ENABLE_FILE_EXTENSION_FILTER?: boolean;
|
||||
REQUIRED_FIELDS?: string[];
|
||||
REQUIRED_TAGS?: string[];
|
||||
EXCLUDE_TAGS?: string[];
|
||||
FILTER_FUTURE_POSTS?: boolean;
|
||||
FILTER_OLD_POSTS?: boolean;
|
||||
OLD_POST_CUTOFF_DAYS?: number;
|
||||
}
|
||||
|
||||
// Default filters
|
||||
export const hasValidFrontMatter: CollectionFilter = (entry) => {
|
||||
// Check if the entry has valid frontmatter
|
||||
// For MD/MDX files, Astro automatically parses frontmatter
|
||||
// If data exists and is not empty, consider it valid
|
||||
if (!entry.data) return false;
|
||||
|
||||
// Check for basic required fields (can be customized)
|
||||
// At minimum, we expect some data to exist
|
||||
return typeof entry.data === 'object' && Object.keys(entry.data).length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advanced frontmatter validation using Astro's parseFrontmatter
|
||||
* This can be used for more thorough validation of raw markdown content
|
||||
* @param rawContent - Optional raw markdown content to parse
|
||||
*/
|
||||
export const hasValidParsedFrontMatter: CollectionFilter = (entry) => {
|
||||
try {
|
||||
// If entry already has parsed data, it's valid
|
||||
if (entry.data && typeof entry.data === 'object' && Object.keys(entry.data).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For entries that might need raw frontmatter parsing
|
||||
// This is more applicable when working with raw markdown content
|
||||
// In most Astro cases, entry.data is already parsed
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(`Frontmatter parsing failed for entry ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a filter that validates frontmatter against a schema or validation function
|
||||
* @param validator - Function that validates the frontmatter data
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createFrontmatterValidator<T = any>(
|
||||
validator: (data: any) => boolean
|
||||
): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
try {
|
||||
if (!entry.data) return false;
|
||||
return validator(entry.data);
|
||||
} catch (error) {
|
||||
console.warn(`Frontmatter validation failed for entry ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that parses and validates raw markdown content
|
||||
* Useful for validating files that haven't been processed by Astro yet
|
||||
* @param rawContentGetter - Function to get raw content for an entry
|
||||
* @param validator - Optional validator function for the parsed frontmatter
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createRawFrontmatterValidator<T = any>(
|
||||
rawContentGetter: (entry: CollectionEntry<T>) => string,
|
||||
validator?: (data: any) => boolean
|
||||
): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
try {
|
||||
const rawContent = rawContentGetter(entry);
|
||||
if (!rawContent) return false;
|
||||
|
||||
const parsed = parseFrontmatter(rawContent);
|
||||
|
||||
// Check if frontmatter exists and is valid
|
||||
if (!parsed.frontmatter || typeof parsed.frontmatter !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply custom validator if provided
|
||||
if (validator) {
|
||||
return validator(parsed.frontmatter);
|
||||
}
|
||||
|
||||
// Default validation: frontmatter should have at least one property
|
||||
return Object.keys(parsed.frontmatter).length > 0;
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Raw frontmatter parsing failed for entry ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that reads and validates frontmatter from the actual file
|
||||
* Uses the entry's filePath to read the raw file content
|
||||
* @param validator - Optional validator function for the parsed frontmatter
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createFileBasedFrontmatterValidator<T = any>(
|
||||
validator?: (data: any) => boolean
|
||||
): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
try {
|
||||
if (!entry.filePath) {
|
||||
console.warn(`No filePath available for entry ${entry.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// This would require fs import in a Node.js environment
|
||||
// For now, we'll rely on the entry.data which is already parsed
|
||||
// In a real implementation, you could use:
|
||||
// const fs = await import('fs');
|
||||
// const rawContent = fs.readFileSync(entry.filePath, 'utf-8');
|
||||
// const parsed = parseFrontmatter(rawContent);
|
||||
|
||||
// Fallback to using the already parsed data
|
||||
if (!entry.data || typeof entry.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (validator) {
|
||||
return validator(entry.data);
|
||||
}
|
||||
|
||||
return Object.keys(entry.data).length > 0;
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`File-based frontmatter validation failed for entry ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const isNotFolder: CollectionFilter = (entry) => {
|
||||
// Check if the entry is not a folder
|
||||
// Use filePath if available, otherwise construct from collection and id
|
||||
const entryPath = entry.filePath || `src/content/${entry.collection}/${entry.id}`;
|
||||
return !isFolder(entryPath);
|
||||
};
|
||||
|
||||
export const isNotDraft: CollectionFilter = (entry) => {
|
||||
// Filter out draft entries
|
||||
return !entry.data?.draft;
|
||||
};
|
||||
|
||||
export const hasTitle: CollectionFilter = (entry) => {
|
||||
// Check if entry has a title and it's not the default "Untitled"
|
||||
return !!entry.data?.title &&
|
||||
entry.data.title.trim() !== '' &&
|
||||
entry.data.title.trim() !== 'Untitled';
|
||||
};
|
||||
|
||||
export const hasBody: CollectionFilter = (entry) => {
|
||||
// Check if entry has body content
|
||||
return !!entry.body && entry.body.trim() !== '';
|
||||
};
|
||||
|
||||
export const hasValidFileExtension: CollectionFilter = (entry) => {
|
||||
// Check if the entry has a valid markdown/mdx file extension
|
||||
if (!entry.filePath) return true; // If no filePath, assume it's valid
|
||||
|
||||
const validExtensions = ['.md', '.mdx'];
|
||||
return validExtensions.some(ext => entry.filePath.endsWith(ext));
|
||||
};
|
||||
|
||||
export const hasImage: CollectionFilter = (entry) => {
|
||||
// Check if entry has an image defined
|
||||
return !!(entry.data?.image?.url);
|
||||
};
|
||||
|
||||
export const hasDescription: CollectionFilter = (entry) => {
|
||||
// Check if entry has a non-empty description
|
||||
return !!entry.data?.description && entry.data.description.trim() !== '';
|
||||
};
|
||||
|
||||
export const hasAuthor: CollectionFilter = (entry) => {
|
||||
// Check if entry has an author (and it's not the default "Unknown")
|
||||
return !!entry.data?.author &&
|
||||
entry.data.author.trim() !== '' &&
|
||||
entry.data.author !== 'Unknown';
|
||||
};
|
||||
|
||||
export const hasPubDate: CollectionFilter = (entry) => {
|
||||
// Check if entry has a valid publication date
|
||||
if (!entry.data?.pubDate) return false;
|
||||
|
||||
try {
|
||||
const date = new Date(entry.data.pubDate);
|
||||
return !isNaN(date.getTime());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasTags: CollectionFilter = (entry) => {
|
||||
// Check if entry has tags
|
||||
return !!(entry.data?.tags && Array.isArray(entry.data.tags) && entry.data.tags.length > 0);
|
||||
};
|
||||
|
||||
// Default filters array
|
||||
export const defaultFilters: CollectionFilter[] = [
|
||||
hasValidFrontMatter,
|
||||
isNotFolder,
|
||||
isNotDraft,
|
||||
hasTitle // Include title validation by default to filter out "Untitled" entries
|
||||
];
|
||||
|
||||
/**
|
||||
* Generic filter function for collections
|
||||
* @param collection - Array of collection entries from getCollection()
|
||||
* @param filters - Array of filter functions to apply (defaults to defaultFilters)
|
||||
* @param astroConfig - Optional Astro config object
|
||||
* @returns Filtered array of collection entries
|
||||
*/
|
||||
export function filterCollection<T = any>(
|
||||
collection: CollectionEntry<T>[],
|
||||
filters: CollectionFilter<T>[] = defaultFilters,
|
||||
astroConfig?: any
|
||||
): CollectionEntry<T>[] {
|
||||
return collection.filter(entry => {
|
||||
// Apply all filters - entry must pass ALL filters to be included
|
||||
return filters.every(filter => {
|
||||
try {
|
||||
return filter(entry, astroConfig);
|
||||
} catch (error) {
|
||||
console.warn(`Filter failed for entry ${entry.id}:`, error);
|
||||
return false; // If filter throws, exclude the entry
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to create custom filter combinations
|
||||
* @param baseFilters - Base filters to start with
|
||||
* @param additionalFilters - Additional filters to add
|
||||
* @returns Combined filter array
|
||||
*/
|
||||
export function combineFilters<T = any>(
|
||||
baseFilters: CollectionFilter<T>[] = defaultFilters,
|
||||
additionalFilters: CollectionFilter<T>[] = []
|
||||
): CollectionFilter<T>[] {
|
||||
return [...baseFilters, ...additionalFilters];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that checks for specific frontmatter fields
|
||||
* @param requiredFields - Array of field names that must exist
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createRequiredFieldsFilter<T = any>(requiredFields: string[]): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
if (!entry.data) return false;
|
||||
return requiredFields.every(field =>
|
||||
entry.data[field] !== undefined && entry.data[field] !== null && entry.data[field] !== ''
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that checks for specific tags
|
||||
* @param requiredTags - Array of tags that must be present
|
||||
* @param matchAll - If true, all tags must be present; if false, at least one tag must be present
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createTagFilter<T = any>(requiredTags: string[], matchAll: boolean = false): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
const entryTags = entry.data?.tags || [];
|
||||
if (!Array.isArray(entryTags)) return false;
|
||||
|
||||
if (matchAll) {
|
||||
return requiredTags.every(tag => entryTags.includes(tag));
|
||||
} else {
|
||||
return requiredTags.some(tag => entryTags.includes(tag));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter based on publication date
|
||||
* @param beforeDate - Optional date - entries must be published before this date
|
||||
* @param afterDate - Optional date - entries must be published after this date
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createDateFilter<T = any>(beforeDate?: Date, afterDate?: Date): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
const pubDate = entry.data?.pubDate;
|
||||
if (!pubDate) return false;
|
||||
|
||||
const entryDate = new Date(pubDate);
|
||||
|
||||
if (beforeDate && entryDate > beforeDate) return false;
|
||||
if (afterDate && entryDate < afterDate) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that excludes entries with specific tags
|
||||
* @param excludeTags - Array of tags to exclude
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createExcludeTagsFilter<T = any>(excludeTags: string[]): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
const entryTags = entry.data?.tags || [];
|
||||
if (!Array.isArray(entryTags)) return true;
|
||||
|
||||
return !excludeTags.some(tag => entryTags.includes(tag));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter that excludes future posts
|
||||
*/
|
||||
export const isNotFuture: CollectionFilter = (entry) => {
|
||||
const pubDate = entry.data?.pubDate;
|
||||
if (!pubDate) return true; // If no date, include it
|
||||
|
||||
const entryDate = new Date(pubDate);
|
||||
const now = new Date();
|
||||
|
||||
return entryDate <= now;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a filter that excludes old posts based on cutoff days
|
||||
* @param cutoffDays - Number of days to consider a post "old"
|
||||
* @returns Filter function
|
||||
*/
|
||||
export function createOldPostFilter<T = any>(cutoffDays: number): CollectionFilter<T> {
|
||||
return (entry) => {
|
||||
const pubDate = entry.data?.pubDate;
|
||||
if (!pubDate) return true; // If no date, include it
|
||||
|
||||
const entryDate = new Date(pubDate);
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - cutoffDays);
|
||||
|
||||
return entryDate > cutoffDate;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filters array based on configuration
|
||||
* @param config - Collection filter configuration
|
||||
* @returns Array of filter functions
|
||||
*/
|
||||
export function buildFiltersFromConfig<T = any>(config: CollectionFilterConfig): CollectionFilter<T>[] {
|
||||
const filters: CollectionFilter<T>[] = [];
|
||||
|
||||
// Add default filters based on config
|
||||
if (config.ENABLE_VALID_FRONTMATTER_CHECK !== false) {
|
||||
filters.push(hasValidFrontMatter);
|
||||
}
|
||||
|
||||
if (config.ENABLE_FOLDER_FILTER !== false) {
|
||||
filters.push(isNotFolder);
|
||||
}
|
||||
|
||||
if (config.ENABLE_DRAFT_FILTER !== false) {
|
||||
filters.push(isNotDraft);
|
||||
}
|
||||
|
||||
if (config.ENABLE_TITLE_FILTER) {
|
||||
filters.push(hasTitle);
|
||||
}
|
||||
|
||||
// Add content validation filters
|
||||
if (config.ENABLE_BODY_FILTER) {
|
||||
filters.push(hasBody);
|
||||
}
|
||||
|
||||
if (config.ENABLE_DESCRIPTION_FILTER) {
|
||||
filters.push(hasDescription);
|
||||
}
|
||||
|
||||
if (config.ENABLE_IMAGE_FILTER) {
|
||||
filters.push(hasImage);
|
||||
}
|
||||
|
||||
if (config.ENABLE_AUTHOR_FILTER) {
|
||||
filters.push(hasAuthor);
|
||||
}
|
||||
|
||||
if (config.ENABLE_PUBDATE_FILTER) {
|
||||
filters.push(hasPubDate);
|
||||
}
|
||||
|
||||
if (config.ENABLE_TAGS_FILTER) {
|
||||
filters.push(hasTags);
|
||||
}
|
||||
|
||||
if (config.ENABLE_FILE_EXTENSION_FILTER !== false) {
|
||||
filters.push(hasValidFileExtension);
|
||||
}
|
||||
|
||||
// Add required fields filter
|
||||
if (config.REQUIRED_FIELDS && config.REQUIRED_FIELDS.length > 0) {
|
||||
filters.push(createRequiredFieldsFilter(config.REQUIRED_FIELDS));
|
||||
}
|
||||
|
||||
// Add required tags filter
|
||||
if (config.REQUIRED_TAGS && config.REQUIRED_TAGS.length > 0) {
|
||||
filters.push(createTagFilter(config.REQUIRED_TAGS, true)); // matchAll = true
|
||||
}
|
||||
|
||||
// Add exclude tags filter
|
||||
if (config.EXCLUDE_TAGS && config.EXCLUDE_TAGS.length > 0) {
|
||||
filters.push(createExcludeTagsFilter(config.EXCLUDE_TAGS));
|
||||
}
|
||||
|
||||
// Add future posts filter
|
||||
if (config.FILTER_FUTURE_POSTS) {
|
||||
filters.push(isNotFuture);
|
||||
}
|
||||
|
||||
// Add old posts filter
|
||||
if (config.FILTER_OLD_POSTS && config.OLD_POST_CUTOFF_DAYS) {
|
||||
filters.push(createOldPostFilter(config.OLD_POST_CUTOFF_DAYS));
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function that applies filters based on config
|
||||
* @param collection - Array of collection entries
|
||||
* @param config - Collection filter configuration
|
||||
* @param astroConfig - Optional Astro config
|
||||
* @returns Filtered collection entries
|
||||
*/
|
||||
export function filterCollectionWithConfig<T = any>(
|
||||
collection: CollectionEntry<T>[],
|
||||
config: CollectionFilterConfig,
|
||||
astroConfig?: any
|
||||
): CollectionEntry<T>[] {
|
||||
const filters = buildFiltersFromConfig<T>(config);
|
||||
return filterCollection(collection, filters, astroConfig);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
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 { removeEmptyObjects } from "./objects.js"
|
||||
|
||||
const LLM_CACHE = true
|
||||
|
||||
import {
|
||||
TemplateProps,
|
||||
|
||||
@ -46,7 +46,7 @@ export interface Props {
|
||||
const {
|
||||
images = [],
|
||||
glob: globPattern,
|
||||
maxItems = 50,
|
||||
maxItems = 150,
|
||||
maxWidth = "300px",
|
||||
maxHeight = "400px",
|
||||
entryPath,
|
||||
@ -406,42 +406,37 @@ if (groupingFunction) {
|
||||
@touchcancel="isSwiping = false;"
|
||||
>
|
||||
{finalImages.map((image, index) => (
|
||||
<div
|
||||
x-show={`currentIndex === ${index}`}
|
||||
class="lightbox-container"
|
||||
style="display: grid; grid-template: 'container' 1fr; place-items: center; place-content: center;"
|
||||
>
|
||||
<Img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
placeholder="blurred"
|
||||
format="avif"
|
||||
objectFit="contain"
|
||||
sizes={mergedLightboxSettings.SIZES_LARGE}
|
||||
attributes={{
|
||||
img: {
|
||||
class: "max-w-[90vw] max-h-[90vh] object-contain rounded-lg",
|
||||
style: "display: block; width: auto; height: auto; grid-area: container;"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && (
|
||||
<div
|
||||
class="max-h-[32vh] text-white bg-black/50 rounded-b-lg p-2 overflow-y-auto"
|
||||
style="grid-area: container; place-self: end center; width: 100%;"
|
||||
>
|
||||
{mergedLightboxSettings.SHOW_TITLE && image.title && (
|
||||
<h3 class="text-xl mb-2">
|
||||
<Translate>{image.title}</Translate>
|
||||
</h3>
|
||||
)}
|
||||
{mergedLightboxSettings.SHOW_DESCRIPTION && image.description && (
|
||||
<p>
|
||||
<Translate>{image.description}</Translate>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div x-show={`currentIndex === ${index}`} class="flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<Img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
placeholder="blurred"
|
||||
format="avif"
|
||||
objectFit="contain"
|
||||
sizes={mergedLightboxSettings.SIZES_LARGE}
|
||||
attributes={{
|
||||
img: {
|
||||
class: "max-w-[90vw] max-h-[90vh] object-contain rounded-lg",
|
||||
style: "display: block !important; width: auto; height: auto;"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && (
|
||||
<div class="absolute bottom-0 left-0 right-0 m-[8px] max-h-[32vh] text-white bg-black/50 rounded-lg p2">
|
||||
{mergedLightboxSettings.SHOW_TITLE && image.title && (
|
||||
<h3 class="text-xl mb-2">
|
||||
<Translate>{image.title}</Translate>
|
||||
</h3>
|
||||
)}
|
||||
{mergedLightboxSettings.SHOW_DESCRIPTION && image.description && (
|
||||
<p>
|
||||
<Translate>{image.description}</Translate>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { PAGE_TITLE_ID } from '../../constants';
|
||||
/** Identifier for the page title h1 when it is injected into the ToC. */
|
||||
export const PAGE_TITLE_ID = 'starlight__overview';
|
||||
|
||||
export class StarlightTOC extends HTMLElement {
|
||||
private _current = this.querySelector<HTMLAnchorElement>('a[aria-current="true"]');
|
||||
@ -22,7 +23,7 @@ export class StarlightTOC extends HTMLElement {
|
||||
|
||||
private init = (): void => {
|
||||
/** All the links in the table of contents. */
|
||||
const links = [...this.querySelectorAll('a')];
|
||||
const links = Array.from(this.querySelectorAll('a'));
|
||||
|
||||
/** Test if an element is a table-of-contents heading. */
|
||||
const isHeading = (el: Element): el is HTMLHeadingElement => {
|
||||
|
||||
@ -24,7 +24,7 @@ export function generateToC(
|
||||
|
||||
/** 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);
|
||||
const lastItem = items[items.length - 1];
|
||||
if (!lastItem || lastItem.depth >= item.depth) {
|
||||
items.push(item);
|
||||
} else {
|
||||
|
||||
@ -45,8 +45,7 @@ function getElementHash(element: Element): string {
|
||||
}
|
||||
|
||||
export function storeSidebarState() {
|
||||
console.log('[Sidebar] Storing state...');
|
||||
const items = getSidebarItems();
|
||||
const items = Array.from(getSidebarItems());
|
||||
for (const item of items) {
|
||||
const details = item.querySelector('details');
|
||||
if (details) {
|
||||
@ -54,37 +53,22 @@ export function storeSidebarState() {
|
||||
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`);
|
||||
const items = Array.from(getSidebarItems());
|
||||
|
||||
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}`);
|
||||
|
||||
const details = item.querySelector('details');
|
||||
if (details) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { SidebarGroup, SidebarLink, SortFunction } from './types.js';
|
||||
import path from 'path';
|
||||
import { filterCollection, defaultFilters, type CollectionFilter } from '../../base/collections.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface DirectoryStructure {
|
||||
[key: string]: {
|
||||
@ -9,8 +10,50 @@ interface DirectoryStructure {
|
||||
};
|
||||
}
|
||||
|
||||
// Zod schema for sidebar generation options
|
||||
export const SidebarGenerationOptionsSchema = z.object({
|
||||
currentPath: z.string().optional(),
|
||||
maxDepth: z.number().min(0).max(10).default(2),
|
||||
collapsedByDefault: z.boolean().default(false),
|
||||
currentDepth: z.number().min(0).default(0),
|
||||
sortBy: z.enum(['alphabetical', 'date', 'custom']).default('alphabetical'),
|
||||
customSort: z.any().optional(), // We'll validate this at runtime
|
||||
filters: z.array(z.any()).optional(), // We'll validate this at runtime
|
||||
}).strict();
|
||||
|
||||
// Define the proper TypeScript types
|
||||
export interface SidebarGenerationOptions {
|
||||
currentPath?: string;
|
||||
maxDepth: number;
|
||||
collapsedByDefault: boolean;
|
||||
currentDepth: number;
|
||||
sortBy: 'alphabetical' | 'date' | 'custom';
|
||||
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number;
|
||||
filters?: CollectionFilter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create validated sidebar generation options
|
||||
* @param options - Partial options to validate and fill with defaults
|
||||
* @returns Validated options with defaults applied
|
||||
*/
|
||||
export function createSidebarOptions(options: Partial<SidebarGenerationOptions> = {}): SidebarGenerationOptions {
|
||||
const parsed = SidebarGenerationOptionsSchema.parse(options);
|
||||
|
||||
// Runtime validation for functions
|
||||
if (parsed.customSort && typeof parsed.customSort !== 'function') {
|
||||
throw new Error('customSort must be a function');
|
||||
}
|
||||
if (parsed.filters && (!Array.isArray(parsed.filters) || parsed.filters.some(f => typeof f !== 'function'))) {
|
||||
throw new Error('filters must be an array of functions');
|
||||
}
|
||||
|
||||
return parsed as SidebarGenerationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nested sidebar structure from a content collection directory
|
||||
* @deprecated Use generateLinksFromDirectoryWithConfig with options object instead
|
||||
*/
|
||||
export async function generateLinksFromDirectory(
|
||||
directory: string,
|
||||
@ -18,23 +61,51 @@ export async function generateLinksFromDirectory(
|
||||
maxDepth: number = 2,
|
||||
currentDepth: number = 0
|
||||
): Promise<(SidebarLink | SidebarGroup)[]> {
|
||||
return generateLinksFromDirectoryWithConfig(directory, currentPath, maxDepth, false, currentDepth);
|
||||
return generateLinksFromDirectoryWithConfig(directory, {
|
||||
currentPath,
|
||||
maxDepth,
|
||||
currentDepth,
|
||||
collapsedByDefault: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nested sidebar structure with configuration support
|
||||
* @param directory - The collection directory to generate links from
|
||||
* @param options - Configuration options for sidebar generation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage with defaults
|
||||
* const links = await generateLinksFromDirectoryWithConfig('resources');
|
||||
*
|
||||
* // With custom options
|
||||
* const links = await generateLinksFromDirectoryWithConfig('resources', {
|
||||
* maxDepth: 3,
|
||||
* collapsedByDefault: true,
|
||||
* sortBy: 'date',
|
||||
* filters: [hasTitle, isNotDraft]
|
||||
* });
|
||||
*
|
||||
* // Using the helper function
|
||||
* const options = createSidebarOptions({
|
||||
* maxDepth: 4,
|
||||
* sortBy: 'custom',
|
||||
* customSort: (a, b) => a.label.localeCompare(b.label)
|
||||
* });
|
||||
* const links = await generateLinksFromDirectoryWithConfig('resources', options);
|
||||
* ```
|
||||
*/
|
||||
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
|
||||
directory: string,
|
||||
options: Partial<SidebarGenerationOptions> = {}
|
||||
): Promise<(SidebarLink | SidebarGroup)[]> {
|
||||
try {
|
||||
const entries = await getCollection(directory as any);
|
||||
// Validate and apply defaults to options
|
||||
const validatedOptions = SidebarGenerationOptionsSchema.parse(options);
|
||||
|
||||
const allEntries = await getCollection(directory as any);
|
||||
const entries = filterCollection(allEntries, validatedOptions.filters || defaultFilters);
|
||||
|
||||
// Organize entries by directory structure
|
||||
const structure = organizeByDirectory(entries);
|
||||
@ -42,12 +113,12 @@ export async function generateLinksFromDirectoryWithConfig(
|
||||
return buildSidebarFromStructure(
|
||||
structure,
|
||||
directory,
|
||||
currentPath,
|
||||
maxDepth,
|
||||
currentDepth,
|
||||
collapsedByDefault,
|
||||
sortBy,
|
||||
customSort,
|
||||
validatedOptions.currentPath,
|
||||
validatedOptions.maxDepth,
|
||||
validatedOptions.currentDepth,
|
||||
validatedOptions.collapsedByDefault,
|
||||
validatedOptions.sortBy,
|
||||
validatedOptions.customSort,
|
||||
entries
|
||||
);
|
||||
} catch (error) {
|
||||
@ -108,7 +179,6 @@ function buildSidebarFromStructure(
|
||||
entries?: any[]
|
||||
): (SidebarLink | SidebarGroup)[] {
|
||||
const items: (SidebarLink | SidebarGroup)[] = [];
|
||||
|
||||
// Process root level files first
|
||||
if (structure['']?.files) {
|
||||
const rootFiles = structure[''].files
|
||||
@ -292,12 +362,14 @@ export async function processSidebarGroup(group: SidebarGroup, currentPath?: str
|
||||
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
|
||||
{
|
||||
currentPath,
|
||||
maxDepth,
|
||||
collapsedByDefault: group.autogenerate.collapsed ?? false,
|
||||
currentDepth: 0,
|
||||
sortBy,
|
||||
customSort: group.autogenerate.customSort
|
||||
}
|
||||
);
|
||||
processedGroup.items = items;
|
||||
processedGroup.collapsed = group.autogenerate.collapsed ?? group.collapsed;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Access to extended Astro config
|
||||
import type { SidebarGroup } from '@/components/sidebar/types';
|
||||
import type { SidebarGroup } from '../components/sidebar/types.js';
|
||||
|
||||
// Define extended config interface
|
||||
interface ExtendedAstroConfig {
|
||||
|
||||
@ -4,6 +4,9 @@ export const foo2 = 2
|
||||
// export { default as i18n } from './components/i18n.astro'
|
||||
// export { default as Test } from './components/test.astro'
|
||||
|
||||
// Export collection utilities
|
||||
export * from './base/collections'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user