diff --git a/packages/polymech/package.json b/packages/polymech/package.json
index 144f8fa..12bd6b9 100644
--- a/packages/polymech/package.json
+++ b/packages/polymech/package.json
@@ -28,6 +28,8 @@
"find-up": "^7.0.0",
"github-slugger": "^2.0.0",
"glob": "^11.0.3",
+ "hast-util-select": "^6.0.4",
+ "hast-util-to-string": "^3.0.1",
"html-entities": "^2.5.2",
"imagetools": "file:../imagetools",
"marked": "^16.1.2",
diff --git a/packages/polymech/src/components/FileTree.astro b/packages/polymech/src/components/FileTree.astro
new file mode 100644
index 0000000..a5ea13b
--- /dev/null
+++ b/packages/polymech/src/components/FileTree.astro
@@ -0,0 +1,730 @@
+---
+import { processFileTree } from './rehype-file-tree';
+import path from "node:path";
+import { globBase, globParent, pathInfo } from "@polymech/commons";
+import { Icons } from './Icons';
+import { definitions } from './file-tree-icons';
+import { Img } from "imagetools/components";
+
+export interface Props {
+ glob?: string; // Glob pattern to auto-generate file tree
+ maxDepth?: number; // Maximum directory depth to show
+ showHidden?: boolean; // Show hidden files (starting with .)
+ exclude?: string[]; // Patterns to exclude
+ urlPrefix?: string; // URL prefix for file links (defaults to current dev server)
+ linkFiles?: boolean; // Whether to make files clickable (default: true)
+ view?: 'tree' | 'thumbs'; // Display mode: tree view or thumbnail grid (default: 'tree')
+ thumbSize?: 'small' | 'medium' | 'large'; // Thumbnail size for thumbs view (default: 'medium')
+}
+
+const {
+ glob: globPattern,
+ maxDepth = 5,
+ showHidden = false,
+ exclude = [],
+ urlPrefix,
+ linkFiles = true,
+ view = 'tree',
+ thumbSize = 'medium'
+} = Astro.props;
+
+// Default URL prefix to current dev server if not provided
+const defaultUrlPrefix = `${Astro.url.protocol}//${Astro.url.host}`;
+const finalUrlPrefix = urlPrefix || defaultUrlPrefix;
+
+// Get current content directory dynamically from URL (like RelativeGallery)
+const currentUrl = Astro.url.pathname;
+const pathSegments = currentUrl.split('/').filter(Boolean);
+
+let contentSubdir = 'resources'; // fallback
+if (pathSegments.length >= 1) {
+ contentSubdir = pathSegments[0]; // first segment after domain
+}
+
+const contentDir = path.join(process.cwd(), 'src', 'content', contentSubdir);
+
+let fileTreeHtml = '';
+let basePath = ''; // Declare basePath in outer scope for error handling
+let thumbnailFiles: any[] = []; // Store files data for thumbnail view
+
+// Generate file tree from glob pattern if provided
+if (globPattern) {
+ try {
+ // Use pathInfoEx like RelativeGallery does
+ const globInfo = globBase(globPattern);
+ const parentDir = globParent(globPattern);
+ basePath = path.join(contentDir, parentDir);
+
+ const pathInfoResult = pathInfo(globPattern, false, basePath);
+ const files = pathInfoResult.FILES || [];
+
+ if (files.length > 0) {
+ if (view === 'thumbs') {
+ // For thumbnail view, we'll use a different approach with Astro components
+ // Store the files data for rendering in the template
+ thumbnailFiles = files.map(filePath => {
+ const relativePath = path.relative(basePath, filePath);
+ const fileName = path.basename(filePath);
+ const fileExt = path.extname(filePath).toLowerCase();
+ const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(fileExt);
+
+ // Generate proper image path for imagetools
+ let imagePath = '';
+ if (isImage) {
+ const relativeFromContentDir = path.relative(contentDir, filePath);
+ imagePath = relativeFromContentDir.startsWith('.')
+ ? `/src/content/${contentSubdir}/${relativeFromContentDir.replace(/^\.\//, '')}`
+ : `/src/content/${contentSubdir}/${relativeFromContentDir}`;
+ }
+
+ return {
+ fileName,
+ filePath,
+ relativePath,
+ fileExt,
+ isImage,
+ imagePath: imagePath.replace(/\\/g, '/'),
+ fileUrl: linkFiles ? generateFileUrl(relativePath, basePath, contentDir, finalUrlPrefix) : '',
+ iconName: isImage ? null : getFileIconName(fileName),
+ isMarkdown: ['.md', '.mdx'].includes(fileExt)
+ };
+ });
+ fileTreeHtml = ''; // Will be handled in template
+ } else {
+ // Build tree structure from discovered files
+ const tree = buildFileTreeFromPaths(files, basePath, maxDepth, showHidden, exclude);
+ // Generate HTML directly since we're not going through markdown processing
+ fileTreeHtml = generateFileTreeHTML(tree, basePath, contentDir, finalUrlPrefix, linkFiles);
+ }
+ } else {
+ // No files found - thumbnailFiles will be empty array for thumbnail view
+ if (view === 'thumbs') {
+ thumbnailFiles = [];
+ } else {
+ const searchedPath = basePath.replace(/\\/g, '/');
+ fileTreeHtml = `
No files found matching pattern: ${globPattern}Searched in: ${searchedPath} `;
+ }
+ }
+ } catch (error) {
+ console.warn('FileTree glob pattern failed:', error);
+ if (view === 'thumbs') {
+ thumbnailFiles = [];
+ } else {
+ const searchedPath = basePath ? basePath.replace(/\\/g, '/') : 'unknown path';
+ fileTreeHtml = `Error loading files: ${error.message}Pattern: ${globPattern} Searched in: ${searchedPath} `;
+ }
+ }
+} else {
+ // Use slot content (original behavior)
+ fileTreeHtml = await Astro.slots.render('default');
+}
+
+// Only process through rehype for tree view, and only if there's content to process
+let html = '';
+if (view === 'tree' && fileTreeHtml) {
+ html = processFileTree(fileTreeHtml, 'Directory');
+} else if (view === 'thumbs' && globPattern && thumbnailFiles.length > 0) {
+ // For thumbnail view, HTML is handled directly in the template
+ html = '';
+}
+
+// Helper function to build tree structure from file paths
+function buildFileTreeFromPaths(filePaths: string[], basePath: string, maxDepth: number, showHidden: boolean, exclude: string[]) {
+ const tree = new Map();
+
+ filePaths.forEach(filePath => {
+ const relativePath = path.relative(basePath, filePath);
+ const parts = relativePath.split(path.sep).filter(Boolean);
+
+ // Skip if exceeds max depth
+ if (parts.length > maxDepth) return;
+
+ // Skip hidden files if not showing them
+ if (!showHidden && parts.some(part => part.startsWith('.'))) return;
+
+ // Skip excluded patterns
+ if (exclude.some(pattern => relativePath.includes(pattern))) return;
+
+ let current = tree;
+
+ parts.forEach((part, index) => {
+ if (!current.has(part)) {
+ current.set(part, {
+ isDirectory: index < parts.length - 1,
+ children: new Map(),
+ fullPath: parts.slice(0, index + 1).join('/'),
+ name: part
+ });
+ }
+ current = current.get(part).children;
+ });
+ });
+
+ return tree;
+}
+
+// Helper function to generate HTML markup from tree structure (for processFileTree)
+function generateFileTreeHTML(tree: Map, basePath: string, contentDir: string, urlPrefix: string, linkFiles: boolean): string {
+ const items = Array.from(tree.entries())
+ .sort(([a, aData], [b, bData]) => {
+ // Directories first, then files, both alphabetically
+ if (aData.isDirectory && !bData.isDirectory) return -1;
+ if (!aData.isDirectory && bData.isDirectory) return 1;
+ return a.localeCompare(b);
+ });
+
+ if (items.length === 0) return '';
+
+ let html = '';
+
+ items.forEach(([name, data]) => {
+ if (data.isDirectory) {
+ html += `${name}/`;
+ const childrenHtml = generateFileTreeHTML(data.children, basePath, contentDir, urlPrefix, linkFiles);
+ if (childrenHtml !== '') {
+ html += childrenHtml;
+ }
+ html += ' ';
+ } else {
+ // Generate clickable link for files
+ if (linkFiles) {
+ const relativePath = data.fullPath;
+ const fileUrl = generateFileUrl(relativePath, basePath, contentDir, urlPrefix);
+ const fileExt = path.extname(name).toLowerCase();
+ const isMarkdown = ['.md', '.mdx'].includes(fileExt);
+ const target = isMarkdown ? '_self' : '_blank';
+ const rel = isMarkdown ? '' : 'noopener noreferrer';
+ html += `${name} `;
+ } else {
+ html += `${name} `;
+ }
+ }
+ });
+
+ html += ' ';
+ return html;
+}
+
+// Helper functions for file icons (from rehype-file-tree.ts)
+function getFileIconName(fileName: string): string | undefined {
+ let icon: string | undefined = definitions.files[fileName];
+ if (icon) return icon;
+ icon = getFileIconTypeFromExtension(fileName);
+ if (icon) return icon;
+ for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
+ if (fileName.includes(partial)) return partialIcon;
+ }
+ return icon;
+}
+
+function getFileIconTypeFromExtension(fileName: string): string | undefined {
+ const firstDotIndex = fileName.indexOf('.');
+ if (firstDotIndex === -1) return;
+ let extension = fileName.slice(firstDotIndex);
+ while (extension !== '') {
+ const icon = definitions.extensions[extension];
+ if (icon) return icon;
+ const nextDotIndex = extension.indexOf('.', 1);
+ if (nextDotIndex === -1) return;
+ extension = extension.slice(nextDotIndex);
+ }
+ return;
+}
+
+// Helper function to generate file URLs
+function generateFileUrl(relativePath: string, basePath: string, contentDir: string, urlPrefix: string): string {
+ // Get the actual file path
+ const fullPath = path.join(basePath, relativePath);
+ const fileExt = path.extname(relativePath).toLowerCase();
+
+ // Special handling for markdown/MDX files - link to their rendered Astro pages
+ if ((fileExt === '.md' || fileExt === '.mdx') && urlPrefix.startsWith('http')) {
+ // Get the relative path from the content directory
+ const relativeFromContentDir = path.relative(contentDir, fullPath);
+ // Remove the file extension to get the Astro route
+ const routePath = relativeFromContentDir.replace(/\.(md|mdx)$/, '').replace(/\\/g, '/');
+ // Determine the collection name from contentDir
+ const collectionName = path.basename(contentDir);
+ return `/${collectionName}/${routePath}`;
+ }
+
+ // Handle different URL prefix types for non-markdown files
+ if (urlPrefix.startsWith('vscode://')) {
+ // VS Code protocol - use full system path
+ return `vscode://file/${fullPath.replace(/\\/g, '/')}`;
+ } else if (urlPrefix.includes('github.com') || urlPrefix.includes('gitlab.com')) {
+ // Git repository - use relative path from project root
+ const projectRelativePath = path.relative(process.cwd(), fullPath);
+ return `${urlPrefix}/${projectRelativePath.replace(/\\/g, '/')}`;
+ } else if (urlPrefix.startsWith('file://')) {
+ // File protocol - use full system path
+ return `file:///${fullPath.replace(/\\/g, '/')}`;
+ } else if (urlPrefix.startsWith('http')) {
+ // HTTP/HTTPS - for thumbnails, use the same path logic as images
+ const relativeFromContentDir = path.relative(contentDir, fullPath);
+ const webPath = `/src/content/${path.basename(contentDir)}/${relativeFromContentDir}`;
+ return `${urlPrefix}${webPath.replace(/\\/g, '/')}`;
+ } else {
+ // Default fallback - treat as file:// protocol
+ return `file:///${fullPath.replace(/\\/g, '/')}`;
+ }
+}
+---
+
+{view === 'thumbs' ? (
+
+
+ {thumbnailFiles.length > 0 ? thumbnailFiles.map((file) => (
+
+ )) : (
+
+ No files found matching pattern: {globPattern}
+ Searched in: {basePath.replace(/\\/g, '/')}
+
+ )}
+
+
+) : (
+
+)}
+
+
diff --git a/packages/polymech/src/components/Icons.ts b/packages/polymech/src/components/Icons.ts
new file mode 100644
index 0000000..c001715
--- /dev/null
+++ b/packages/polymech/src/components/Icons.ts
@@ -0,0 +1,203 @@
+import { FileIcons } from './file-tree-icons.js';
+
+export const BuiltInIcons = {
+ 'up-caret':
+ ' ',
+ 'down-caret':
+ ' ',
+ 'right-caret':
+ ' ',
+ 'left-caret':
+ ' ',
+ 'up-arrow':
+ ' ',
+ 'down-arrow':
+ ' ',
+ 'right-arrow':
+ ' ',
+ 'left-arrow':
+ ' ',
+ bars: ' ',
+ translate:
+ ' ',
+ pencil:
+ ' ',
+ pen: ' ',
+ document:
+ ' ',
+ 'add-document':
+ ' ',
+ setting:
+ ' ',
+ external:
+ ' ',
+ download:
+ ' ',
+ 'cloud-download':
+ ' ',
+ moon: ' ',
+ sun: ' ',
+ laptop:
+ ' ',
+ 'open-book':
+ ' ',
+ information:
+ ' ',
+ magnifier:
+ ' ',
+ 'forward-slash':
+ ' ',
+ close:
+ ' ',
+ error:
+ ' ',
+ warning:
+ ' ',
+ 'approve-check-circle':
+ ' ',
+ 'approve-check':
+ ' ',
+ rocket:
+ ' ',
+ star: ' ',
+ puzzle:
+ ' ',
+ 'list-format':
+ ' ',
+ random:
+ ' ',
+ comment:
+ ' ',
+ 'comment-alt':
+ ' ',
+ heart:
+ ' ',
+ github:
+ ' ',
+ gitlab:
+ ' ',
+ bitbucket:
+ ' ',
+ codePen:
+ ' ',
+ farcaster:
+ ' ',
+ discord:
+ ' ',
+ gitter:
+ ' ',
+ twitter:
+ ' ',
+ 'x.com':
+ ' ',
+ mastodon:
+ ' ',
+ codeberg:
+ ' ',
+ youtube:
+ ' ',
+ threads:
+ ' ',
+ linkedin:
+ ' ',
+ twitch:
+ ' ',
+ azureDevOps:
+ ' ',
+ microsoftTeams:
+ ' ',
+ instagram:
+ ' ',
+ stackOverflow:
+ ' ',
+ telegram:
+ ' ',
+ rss: ' ',
+ facebook:
+ ' ',
+ email:
+ ' ',
+ phone:
+ ' ',
+ reddit:
+ ' ',
+ patreon:
+ ' ',
+ signal:
+ ' ',
+ slack:
+ ' ',
+ matrix:
+ ' ',
+ hackerOne:
+ ' ',
+ openCollective:
+ ' ',
+ blueSky:
+ ' ',
+ discourse:
+ ' ',
+ zulip:
+ ' ',
+ pinterest:
+ ' ',
+ tiktok:
+ ' ',
+ astro:
+ ' ',
+ alpine: ' ',
+ pnpm: ' ',
+ biome:
+ ' ',
+ bun: ' ',
+ mdx: ' ',
+ apple:
+ ' ',
+ linux:
+ ' ',
+ homebrew:
+ ' ',
+ nix: ' ',
+ starlight:
+ ' ',
+ pkl: ' ',
+ node: ' ',
+ cloudflare:
+ ' ',
+ vercel: ' ',
+ netlify:
+ ' ',
+ deno: ' ',
+ jsr: ' ',
+ nostr:
+ ' ',
+ backstage:
+ ' ',
+ confluence:
+ ' ',
+ jira: ' ',
+ storybook:
+ ' ',
+ vscode:
+ ' ',
+ jetbrains:
+ ' ',
+ zed: ' ',
+ vim: ' ',
+ figma:
+ ' ',
+ sketch:
+ ' ',
+ npm: ' ',
+ sourcehut:
+ ' ',
+ substack:
+ ' ',
+};
+
+export const Icons = {
+ ...BuiltInIcons,
+ ...FileIcons,
+};
+
+export type StarlightIcon = keyof typeof Icons;
diff --git a/packages/polymech/src/components/MasonryGallery.astro b/packages/polymech/src/components/MasonryGallery.astro
index cfccb34..89012e9 100644
--- a/packages/polymech/src/components/MasonryGallery.astro
+++ b/packages/polymech/src/components/MasonryGallery.astro
@@ -6,33 +6,10 @@ import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js";
import path from "node:path";
import fs from "node:fs";
import { glob } from 'glob';
-import { globBase, globParent, pathInfoEx, pathInfo } from "@polymech/commons";
+import { globBase, pathInfo } from "@polymech/commons";
import { gallery } from "@/base/media.js";
import ExifReader from 'exifreader';
-// Path resolution function (simplified version)
-function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL): string {
- // External URLs and absolute paths
- if (src.startsWith('/') || src.startsWith('http')) {
- return src;
- }
-
- // Relative paths
- if (src.startsWith('.') && entryPath) {
- const contentDir = entryPath.substring(0, entryPath.lastIndexOf('/'));
- const basePath = path.join(process.cwd(), 'src', 'content', contentDir);
-
- try {
- const absolutePath = path.resolve(basePath, src);
- if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
- return path.relative(process.cwd(), absolutePath).replace(/\\/g, '/');
- }
- } catch (e) {
- console.warn(`[MasonryGallery] Error resolving path for ${src}:`, e);
- }
- }
-
- return src;
-}
+import { resolveImagePath } from "../utils/path-resolution.js";
// Extract date from EXIF data or file stats
async function extractImageDate(filePath: string): Promise<{ year: number, dateTaken: Date }> {
@@ -164,27 +141,15 @@ if (globPattern) {
}
}
- // Use entryPath if provided, otherwise derive from URL
- let finalContentPath;
- if (entryPath) {
- // entryPath is like "resources/internal/Masonry", we want the directory part
- const lastSlashIndex = entryPath.lastIndexOf('/');
- finalContentPath = lastSlashIndex > -1 ? entryPath.substring(0, lastSlashIndex) : entryPath;
- } else {
- finalContentPath = contentPath;
- }
+ // 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);
- console.log(`[MasonryGallery] Searching for files with pattern: ${globPattern}`);
- console.log(`[MasonryGallery] Content directory: ${contentDir}`);
-
let matchedFiles = glob.sync(globPattern, { cwd: contentDir, absolute: true });
- console.log(`[MasonryGallery] Found ${matchedFiles.length} files:`, matchedFiles);
if (matchedFiles.length === 0) {
const pathInfo2 = pathInfo(globPattern, false, path.join(contentDir, globBase(globPattern).base));
matchedFiles = pathInfo2.FILES;
- console.log(`[MasonryGallery] Fallback pathInfo found ${matchedFiles.length} files:`, matchedFiles);
}
// Process matched files
@@ -196,11 +161,6 @@ if (globPattern) {
// Convert to a path that resolveImagePath can handle (like "./gallery/image.jpg")
const relativeSrc = `./${relativeFromContentDir.replace(/\\/g, '/')}`;
- console.log(`[MasonryGallery] Processing file: ${filePath}`);
- console.log(`[MasonryGallery] Content dir: ${contentDir}`);
- console.log(`[MasonryGallery] Relative from content: ${relativeFromContentDir}`);
- console.log(`[MasonryGallery] Relative src: ${relativeSrc}`);
-
// Extract date information
const { year, dateTaken } = await extractImageDate(filePath);
@@ -247,12 +207,12 @@ if (globPattern) {
// Apply maxItems constraint and resolve image paths
const resolvedImages = allImages.slice(0, maxItems).map(image => {
- const originalSrc = image.src;
const resolvedSrc = resolveImagePath(image.src, entryPath, Astro.url);
- console.log(`[MasonryGallery] Path resolution: "${originalSrc}" -> "${resolvedSrc}"`);
+ // Ensure the path is absolute (starts with /) for proper browser resolution
+ const absoluteSrc = resolvedSrc.startsWith('/') ? resolvedSrc : `/${resolvedSrc}`;
return {
...image,
- src: resolvedSrc
+ src: absoluteSrc
};
});
@@ -279,15 +239,57 @@ sortedYears.forEach(year => {
});
});
-// Create flat array with year info for lightbox
-const finalImages = resolvedImages;
+// Create flat array with proper year/date sorting for lightbox
+const finalImages = sortedYears.flatMap(year => imagesByYear[year]);
+
+console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3).map(img => ({ src: img.src, title: img.title })));
+
-console.log(`[MasonryGallery] Images grouped by years:`, sortedYears, imagesByYear);
-console.log(`[MasonryGallery] Final images array:`, finalImages.length, finalImages);
---
= 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(); img.src = this.images[this.currentIndex].src; img.onload = () => { this.lightboxLoaded = true; this.open = true; }; } }`}
+ x-data={`{
+ open: false,
+ currentIndex: 0,
+ total: ${finalImages.length},
+ lightboxLoaded: false,
+ touchStartX: 0,
+ touchEndX: 0,
+ minSwipeDistance: 50,
+ isSwiping: false,
+ images: ${JSON.stringify(finalImages)},
+ openLightbox(index) {
+ this.currentIndex = index;
+ this.preloadAndOpen();
+ },
+ handleSwipe() {
+ if (!this.isSwiping) return;
+ const swipeDistance = this.touchEndX - this.touchStartX;
+ if (Math.abs(swipeDistance) >= 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"
@@ -299,7 +301,7 @@ console.log(`[MasonryGallery] Final images array:`, finalImages.length, finalIma
const startIndex = finalImages.findIndex(img => img.year === year);
return (
-
+
{year}
@@ -358,6 +360,10 @@ console.log(`[MasonryGallery] Final images array:`, finalImages.length, finalIma
)}
+
+
+ );
+ })}
);
@@ -491,4 +497,19 @@ console.log(`[MasonryGallery] Final images array:`, finalImages.length, finalIma
.masonry-gallery [x-show="open"] {
z-index: 9999;
}
+
+ /* Year group styling */
+ .year-group {
+ scroll-margin-top: 6rem;
+ }
+
+ /* Sticky year headers */
+ .year-group h2 {
+ position: sticky;
+ top: 1rem;
+ z-index: 20;
+ margin-bottom: 1.5rem;
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(0, 0, 0, 0.05);
+ }
\ No newline at end of file
diff --git a/packages/polymech/src/components/MasonryGallery.md b/packages/polymech/src/components/MasonryGallery.md
deleted file mode 100644
index 965a8db..0000000
--- a/packages/polymech/src/components/MasonryGallery.md
+++ /dev/null
@@ -1,160 +0,0 @@
-# MasonryGallery Component
-
-A responsive masonry gallery component with lightbox functionality, glob pattern support, and configurable constraints.
-
-## Features
-
-- **Masonry Layout**: CSS Grid-based masonry layout with no fixed height
-- **Lightbox**: Full lightbox functionality inherited from GalleryK with swipe support
-- **Glob Support**: Auto-load images using glob patterns (relative to content directory)
-- **Constraints**: Configurable max items, max width, and max height
-- **Responsive**: Adapts to different screen sizes
-- **Metadata**: Supports companion JSON and Markdown files for image metadata
-
-## Props
-
-```typescript
-interface Props {
- images?: Image[]; // Manual image array
- glob?: string; // Glob pattern for auto-loading (e.g., "*.jpg", "**/*.{jpg,png}")
- maxItems?: number; // Maximum number of images (default: 50)
- maxWidth?: string; // Max width per image (default: "300px")
- maxHeight?: string; // Max height per image (default: "400px")
- entryPath?: string; // Content entry path for resolving relative images
- gallerySettings?: { // Gallery display settings
- SIZES_REGULAR?: string;
- SIZES_THUMB?: string;
- SIZES_LARGE?: string;
- SHOW_TITLE?: boolean;
- SHOW_DESCRIPTION?: boolean;
- };
- lightboxSettings?: { // Lightbox display settings
- SIZES_REGULAR?: string;
- SIZES_THUMB?: string;
- SIZES_LARGE?: string;
- SHOW_TITLE?: boolean;
- SHOW_DESCRIPTION?: boolean;
- };
-}
-```
-
-## Usage Examples
-
-### Basic Usage with Manual Images
-
-```astro
----
-import MasonryGallery from "./MasonryGallery.astro";
-
-const images = [
- { src: "/images/photo1.jpg", alt: "Photo 1", title: "Beautiful Landscape" },
- { src: "/images/photo2.jpg", alt: "Photo 2", title: "City Skyline" },
- // ... more images
-];
----
-
-
-```
-
-### Using Glob Patterns
-
-```astro
----
-import MasonryGallery from "./MasonryGallery.astro";
----
-
-
-
-
-
-
-```
-
-### With Custom Settings
-
-```astro
----
-import MasonryGallery from "./MasonryGallery.astro";
-
-const gallerySettings = {
- SHOW_TITLE: true,
- SHOW_DESCRIPTION: true,
- SIZES_REGULAR: "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
-};
-
-const lightboxSettings = {
- SHOW_TITLE: true,
- SHOW_DESCRIPTION: false,
- SIZES_LARGE: "90vw"
-};
----
-
-
-```
-
-## Metadata Support
-
-The component supports companion files for rich metadata:
-
-### JSON Metadata (`image.json`)
-```json
-{
- "alt": "Beautiful mountain landscape at sunset",
- "title": "Mountain Sunset",
- "description": "A breathtaking view of mountains during golden hour"
-}
-```
-
-### Markdown Metadata (`image.md`)
-```markdown
-A detailed description of the image that can include **markdown formatting**.
-
-This will be used as the description if no JSON description is provided.
-```
-
-## Responsive Behavior
-
-- **Desktop**: Minimum 250px columns with auto-fill
-- **Tablet**: Minimum 200px columns
-- **Mobile**: Minimum 150px columns
-
-## Keyboard Controls (in Lightbox)
-
-- **Escape**: Close lightbox
-- **Arrow Left**: Previous image
-- **Arrow Right**: Next image
-
-## Touch Controls (in Lightbox)
-
-- **Swipe Left**: Next image
-- **Swipe Right**: Previous image
-
-## CSS Classes
-
-The component uses the following CSS classes for styling:
-
-- `.masonry-gallery`: Main container
-- `.masonry-container`: Grid container
-- `.masonry-item`: Individual image container
-- `.line-clamp-2`: Text truncation utility
-
-## Browser Support
-
-- Modern browsers with CSS Grid support
-- Graceful fallback for browsers without masonry support
-- Progressive enhancement for touch devices
diff --git a/packages/polymech/src/components/RelativeGallery.astro b/packages/polymech/src/components/RelativeGallery.astro
new file mode 100644
index 0000000..1d5cda8
--- /dev/null
+++ b/packages/polymech/src/components/RelativeGallery.astro
@@ -0,0 +1,178 @@
+---
+import LGallery from "./GalleryK.astro";
+import path from "node:path";
+import fs from "node:fs";
+import { glob } from 'glob';
+import { globBase, pathInfo } from "@polymech/commons";
+import { GalleryImage } from "@/base/images.js";
+import { gallery } from "@/base/media.js";
+import { resolveImagePath } from '@/utils/path-resolution';
+
+export interface Props {
+ images?: GalleryImage[];
+ glob?: string; // New glob pattern support
+ gallerySettings?: any;
+ lightboxSettings?: any;
+ entryPath?: string;
+}
+
+const { images, glob: globPattern, entryPath, ...props } = Astro.props;
+
+// Get current content directory dynamically from URL
+const currentUrl = Astro.url.pathname;
+const pathSegments = currentUrl.split('/').filter(Boolean);
+
+// Handle locale-aware URLs (e.g., /en/resources/test/ vs /resources/test/)
+// Check if first segment is a locale (2-character language code)
+const isLocaleFirst = pathSegments.length > 0 && pathSegments[0].length === 2 && /^[a-z]{2}$/.test(pathSegments[0]);
+
+// Determine content subdirectory (e.g., 'resources', 'blog', etc.)
+let contentSubdir = 'resources'; // fallback
+if (pathSegments.length >= 1) {
+ contentSubdir = isLocaleFirst && pathSegments.length > 1
+ ? pathSegments[1] // Skip locale: /en/resources/test/ -> "resources"
+ : pathSegments[0]; // No locale: /resources/test/ -> "resources"
+}
+
+// Get the nested content directory (e.g., 'cassandra' from /resources/cassandra/home/)
+// We need to distinguish between:
+// 1. /resources/test -> root-level file test.mdx -> contentPath = "resources"
+// 2. /es/resources/test -> root-level file test.mdx with locale -> contentPath = "resources"
+// 3. /resources/cassandra/home -> nested file cassandra/home.mdx -> contentPath = "resources/cassandra"
+// 4. /es/resources/cassandra/home -> nested file with locale -> contentPath = "resources/cassandra"
+let contentPath = contentSubdir;
+
+// Determine the minimum segments needed for nested directories
+// Without locale: resources/folder/file = 3 segments
+// With locale: es/resources/folder/file = 4 segments
+const minNestedSegments = isLocaleFirst ? 4 : 3;
+
+if (pathSegments.length >= minNestedSegments) {
+ // Only treat as nested if we have enough segments for actual subdirectory
+ const nestedDirIndex = isLocaleFirst ? 2 : 1; // Account for locale
+ if (pathSegments.length > nestedDirIndex) {
+ const nestedDir = pathSegments[nestedDirIndex];
+ contentPath = `${contentSubdir}/${nestedDir}`;
+ }
+}
+
+const contentDir = path.join(process.cwd(), 'src', 'content', contentPath);
+
+let allImages: GalleryImage[] = [];
+
+// Handle glob patterns using the gallery function from media.ts
+if (globPattern) {
+ try {
+ // For content collections, we need to create a mock product structure
+ // that the gallery function can work with
+ const mockProductPath = `content/${contentSubdir}`;
+
+ // Since gallery expects a specific directory structure, let's use our custom approach
+ // but leverage the media processing logic where possible
+ 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 each file to get rich metadata (similar to media.ts approach)
+ const globImages: GalleryImage[] = await Promise.all(
+ matchedFiles.map(async (filePath) => {
+ const relativePath = path.relative(process.cwd(), filePath);
+ const fileName = path.basename(filePath, path.extname(filePath));
+ const webPath = `/${relativePath.replace(/\\/g, '/')}`;
+
+ // Create basic image structure
+ const image: GalleryImage = {
+ name: fileName,
+ url: webPath,
+ src: webPath,
+ thumb: webPath,
+ responsive: webPath,
+ alt: `Image: ${fileName}`,
+ title: fileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ description: `Auto-loaded from ${globPattern}`,
+ keywords: '',
+ width: 0,
+ height: 0,
+ gps: { lon: 0, lat: 0 },
+ meta: {
+ format: path.extname(filePath).toLowerCase().slice(1),
+ width: 0,
+ height: 0,
+ space: '',
+ channels: 0,
+ depth: '',
+ density: 0,
+ chromaSubsampling: '',
+ isProgressive: false,
+ resolutionUnit: '',
+ hasProfile: false,
+ hasAlpha: false,
+ orientation: 0,
+ exif: {} as any,
+ json: {
+ alt: `Image: ${fileName}`,
+ keywords: "",
+ title: "",
+ description: ""
+ },
+ markdown: ''
+ }
+ };
+
+ // Try to load companion markdown and JSON files (like media.ts does)
+ const baseDir = path.dirname(filePath);
+ const metaJsonPath = path.join(baseDir, `${fileName}.json`);
+ const metaMarkdownPath = path.join(baseDir, `${fileName}.md`);
+
+ try {
+ if (fs.existsSync(metaJsonPath)) {
+ const metaJson = JSON.parse(fs.readFileSync(metaJsonPath, 'utf8'));
+ image.meta!.json = metaJson;
+ image.alt = metaJson.alt || image.alt;
+ image.title = metaJson.title || image.title;
+ image.description = metaJson.description || image.description;
+ }
+
+ if (fs.existsSync(metaMarkdownPath)) {
+ const markdown = fs.readFileSync(metaMarkdownPath, 'utf8');
+ image.meta!.markdown = markdown;
+ image.description = markdown || image.description;
+ }
+ } catch (e) {
+ console.warn(`Error loading metadata for ${fileName}:`, e);
+ }
+
+ return image;
+ })
+ );
+
+ allImages = [...globImages];
+ } catch (error) {
+ console.warn('Glob pattern failed:', error);
+ allImages = [];
+ }
+}
+
+// Add manual images if provided
+if (images) {
+ allImages = [...allImages, ...images];
+}
+
+// Resolve relative paths in all image sources and convert to LGallery format
+const resolvedImages = allImages.map(image => {
+ const resolvedSrc = resolveImagePath(image.src, entryPath, Astro.url);
+
+ // Convert GalleryImage to LGallery's expected Image interface
+ return {
+ src: resolvedSrc,
+ alt: image.alt || '',
+ title: image.title,
+ description: image.description
+ };
+});
+---
+
+
diff --git a/packages/polymech/src/components/RelativeImage.astro b/packages/polymech/src/components/RelativeImage.astro
new file mode 100644
index 0000000..5fe6dcf
--- /dev/null
+++ b/packages/polymech/src/components/RelativeImage.astro
@@ -0,0 +1,96 @@
+---
+import { Img } from "imagetools/components";
+import { resolveImagePath } from '@/utils/path-resolution';
+
+export interface Props {
+ src: string;
+ alt: string;
+ width?: number;
+ height?: number;
+ class?: string;
+ entryPath?: string;
+ lightbox?: boolean;
+ [key: string]: any;
+}
+
+const {
+ src,
+ alt,
+ width,
+ height,
+ format,
+ placeholder,
+ objectFit,
+ loading,
+ sizes,
+ class: className,
+ entryPath,
+ lightbox = true,
+ ...rest
+} = Astro.props;
+
+const resolvedSrc = resolveImagePath(src, entryPath, Astro.url);
+const imgProps = { alt, width, height, format, placeholder, objectFit, loading, sizes };
+Object.keys(imgProps).forEach(key => imgProps[key] === undefined && delete imgProps[key]);
+---
+
+
+
+
+
diff --git a/packages/polymech/src/components/RelativePicture.astro b/packages/polymech/src/components/RelativePicture.astro
new file mode 100644
index 0000000..25cf0a2
--- /dev/null
+++ b/packages/polymech/src/components/RelativePicture.astro
@@ -0,0 +1,47 @@
+---
+import { Picture } from "imagetools/components";
+
+export interface Props {
+ src: string;
+ alt: string;
+ width?: number;
+ height?: number;
+ class?: string;
+ [key: string]: any;
+}
+
+const { src, class: className, ...props } = Astro.props;
+
+// Get current content directory dynamically from URL
+const currentUrl = Astro.url.pathname;
+const pathSegments = currentUrl.split('/').filter(Boolean);
+
+// Handle locale-aware URLs (e.g., /en/resources/test/ vs /resources/test/)
+// Check if first segment is a locale (2-character language code)
+const isLocaleFirst = pathSegments.length > 0 && pathSegments[0].length === 2 && /^[a-z]{2}$/.test(pathSegments[0]);
+
+// Determine content subdirectory (e.g., 'resources', 'blog', etc.)
+let contentSubdir = 'resources'; // fallback
+if (pathSegments.length >= 1) {
+ contentSubdir = isLocaleFirst && pathSegments.length > 1
+ ? pathSegments[1] // Skip locale: /en/resources/test/ -> "resources"
+ : pathSegments[0]; // No locale: /resources/test/ -> "resources"
+}
+
+// Dynamic path resolver for relative paths in content collections
+let resolvedSrc = src;
+
+if (src.startsWith('./')) {
+ // Convert ./image.jpg to /src/content/{dynamic-subdir}/image.jpg for imagetools
+ resolvedSrc = src.replace('./', `/src/content/${contentSubdir}/`);
+} else if (src.startsWith('../')) {
+ // Handle parent directory references
+ resolvedSrc = src.replace('../', '/src/content/');
+}
+---
+
+
diff --git a/packages/polymech/src/components/conditional.astro b/packages/polymech/src/components/conditional.astro
new file mode 100644
index 0000000..eb2af1c
--- /dev/null
+++ b/packages/polymech/src/components/conditional.astro
@@ -0,0 +1,8 @@
+---
+import DefaultFallback from "@/components/Default.astro"
+const { expression = false, fallback = DefaultFallback } = Astro.props
+const currentLocal = Astro.currentLocale || "en"
+---
+
diff --git a/packages/polymech/src/components/file-tree-icons.ts b/packages/polymech/src/components/file-tree-icons.ts
new file mode 100644
index 0000000..a56af50
--- /dev/null
+++ b/packages/polymech/src/components/file-tree-icons.ts
@@ -0,0 +1,782 @@
+/**
+ * This file was generated by the `file-icons-generator` package.
+ * Do not edit this file directly as it will be overwritten.
+ */
+
+import type { Definitions } from './rehype-file-tree.ts';
+
+/**
+ * Based on https://github.com/elviswolcott/seti-icons which
+ * is derived from https://github.com/jesseweed/seti-ui/
+ *
+ * Copyright (c) 2014 Jesse Weed
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+export const definitions: Definitions = {
+ files: {
+ 'astro.config.js': 'astro',
+ 'astro.config.mjs': 'astro',
+ 'astro.config.cjs': 'astro',
+ 'astro.config.ts': 'astro',
+ 'pnpm-debug.log': 'pnpm',
+ 'pnpm-lock.yaml': 'pnpm',
+ 'pnpm-workspace.yaml': 'pnpm',
+ 'biome.json': 'biome',
+ 'bun.lockb': 'bun',
+ COMMIT_EDITMSG: 'seti:git',
+ MERGE_MSG: 'seti:git',
+ 'karma.conf.js': 'seti:karma',
+ 'karma.conf.cjs': 'seti:karma',
+ 'karma.conf.mjs': 'seti:karma',
+ 'karma.conf.coffee': 'seti:karma',
+ 'README.md': 'seti:info',
+ 'README.txt': 'seti:info',
+ README: 'seti:info',
+ 'CHANGELOG.md': 'seti:clock',
+ 'CHANGELOG.txt': 'seti:clock',
+ CHANGELOG: 'seti:clock',
+ 'CHANGES.md': 'seti:clock',
+ 'CHANGES.txt': 'seti:clock',
+ CHANGES: 'seti:clock',
+ 'VERSION.md': 'seti:clock',
+ 'VERSION.txt': 'seti:clock',
+ VERSION: 'seti:clock',
+ mvnw: 'seti:maven',
+ 'pom.xml': 'seti:maven',
+ 'tsconfig.json': 'seti:tsconfig',
+ 'vite.config.js': 'seti:vite',
+ 'vite.config.ts': 'seti:vite',
+ 'vite.config.mjs': 'seti:vite',
+ 'vite.config.mts': 'seti:vite',
+ 'vite.config.cjs': 'seti:vite',
+ 'vite.config.cts': 'seti:vite',
+ 'swagger.json': 'seti:json',
+ 'swagger.yml': 'seti:json',
+ 'swagger.yaml': 'seti:json',
+ 'mime.types': 'seti:config',
+ Jenkinsfile: 'seti:jenkins',
+ 'babel.config.js': 'seti:babel',
+ 'babel.config.json': 'seti:babel',
+ 'babel.config.cjs': 'seti:babel',
+ BUILD: 'seti:bazel',
+ 'BUILD.bazel': 'seti:bazel',
+ WORKSPACE: 'seti:bazel',
+ 'WORKSPACE.bazel': 'seti:bazel',
+ 'bower.json': 'seti:bower',
+ 'Bower.json': 'seti:bower',
+ 'eslint.config.js': 'seti:eslint',
+ 'firebase.json': 'seti:firebase',
+ geckodriver: 'seti:firefox',
+ 'Gruntfile.js': 'seti:grunt',
+ 'gruntfile.babel.js': 'seti:grunt',
+ 'Gruntfile.babel.js': 'seti:grunt',
+ 'gruntfile.js': 'seti:grunt',
+ 'Gruntfile.coffee': 'seti:grunt',
+ 'gruntfile.coffee': 'seti:grunt',
+ 'ionic.config.json': 'seti:ionic',
+ 'Ionic.config.json': 'seti:ionic',
+ 'ionic.project': 'seti:ionic',
+ 'Ionic.project': 'seti:ionic',
+ 'platformio.ini': 'seti:platformio',
+ 'rollup.config.js': 'seti:rollup',
+ 'sass-lint.yml': 'seti:sass',
+ 'stylelint.config.js': 'seti:stylelint',
+ 'stylelint.config.cjs': 'seti:stylelint',
+ 'stylelint.config.mjs': 'seti:stylelint',
+ 'yarn.clean': 'seti:yarn',
+ 'yarn.lock': 'seti:yarn',
+ 'webpack.config.js': 'seti:webpack',
+ 'webpack.config.cjs': 'seti:webpack',
+ 'webpack.config.mjs': 'seti:webpack',
+ 'webpack.config.ts': 'seti:webpack',
+ 'webpack.config.build.js': 'seti:webpack',
+ 'webpack.config.build.cjs': 'seti:webpack',
+ 'webpack.config.build.mjs': 'seti:webpack',
+ 'webpack.config.build.ts': 'seti:webpack',
+ 'webpack.common.js': 'seti:webpack',
+ 'webpack.common.cjs': 'seti:webpack',
+ 'webpack.common.mjs': 'seti:webpack',
+ 'webpack.common.ts': 'seti:webpack',
+ 'webpack.dev.js': 'seti:webpack',
+ 'webpack.dev.cjs': 'seti:webpack',
+ 'webpack.dev.mjs': 'seti:webpack',
+ 'webpack.dev.ts': 'seti:webpack',
+ 'webpack.prod.js': 'seti:webpack',
+ 'webpack.prod.cjs': 'seti:webpack',
+ 'webpack.prod.mjs': 'seti:webpack',
+ 'webpack.prod.ts': 'seti:webpack',
+ 'npm-debug.log': 'seti:npm_ignored',
+ },
+ extensions: {
+ '.astro': 'astro',
+ '.mdx': 'mdx',
+ '.pkl': 'pkl',
+ '.bsl': 'seti:bsl',
+ '.mdo': 'seti:mdo',
+ '.cls': 'seti:salesforce',
+ '.apex': 'seti:salesforce',
+ '.asm': 'seti:asm',
+ '.s': 'seti:asm',
+ '.bicep': 'seti:bicep',
+ '.bzl': 'seti:bazel',
+ '.bazel': 'seti:bazel',
+ '.BUILD': 'seti:bazel',
+ '.WORKSPACE': 'seti:bazel',
+ '.bazelignore': 'seti:bazel',
+ '.bazelversion': 'seti:bazel',
+ '.c': 'seti:c',
+ '.h': 'seti:c',
+ '.m': 'seti:c',
+ '.cs': 'seti:c-sharp',
+ '.cshtml': 'seti:html',
+ '.aspx': 'seti:html',
+ '.ascx': 'seti:html',
+ '.asax': 'seti:html',
+ '.master': 'seti:html',
+ '.cc': 'seti:cpp',
+ '.cpp': 'seti:cpp',
+ '.cxx': 'seti:cpp',
+ '.c++': 'seti:cpp',
+ '.hh': 'seti:cpp',
+ '.hpp': 'seti:cpp',
+ '.hxx': 'seti:cpp',
+ '.h++': 'seti:cpp',
+ '.mm': 'seti:cpp',
+ '.clj': 'seti:clojure',
+ '.cljs': 'seti:clojure',
+ '.cljc': 'seti:clojure',
+ '.edn': 'seti:clojure',
+ '.cfc': 'seti:coldfusion',
+ '.cfm': 'seti:coldfusion',
+ '.coffee': 'seti:cjsx',
+ '.litcoffee': 'seti:cjsx',
+ '.config': 'seti:config',
+ '.cfg': 'seti:config',
+ '.conf': 'seti:config',
+ '.cr': 'seti:crystal',
+ '.ecr': 'seti:crystal_embedded',
+ '.slang': 'seti:crystal_embedded',
+ '.cson': 'seti:json',
+ '.css': 'seti:css',
+ '.css.map': 'seti:css',
+ '.sss': 'seti:css',
+ '.csv': 'seti:csv',
+ '.xls': 'seti:xls',
+ '.xlsx': 'seti:xls',
+ '.cu': 'seti:cu',
+ '.cuh': 'seti:cu',
+ '.hu': 'seti:cu',
+ '.cake': 'seti:cake',
+ '.ctp': 'seti:cake_php',
+ '.d': 'seti:d',
+ '.doc': 'seti:word',
+ '.docx': 'seti:word',
+ '.ejs': 'seti:ejs',
+ '.ex': 'seti:elixir',
+ '.exs': 'seti:elixir_script',
+ '.elm': 'seti:elm',
+ '.ico': 'seti:favicon',
+ '.fs': 'seti:f-sharp',
+ '.fsx': 'seti:f-sharp',
+ '.gitignore': 'seti:git',
+ '.gitconfig': 'seti:git',
+ '.gitkeep': 'seti:git',
+ '.gitattributes': 'seti:git',
+ '.gitmodules': 'seti:git',
+ '.go': 'seti:go',
+ '.slide': 'seti:go',
+ '.article': 'seti:go',
+ '.gd': 'seti:godot',
+ '.godot': 'seti:godot',
+ '.tres': 'seti:godot',
+ '.tscn': 'seti:godot',
+ '.gradle': 'seti:gradle',
+ '.groovy': 'seti:grails',
+ '.gsp': 'seti:grails',
+ '.gql': 'seti:graphql',
+ '.graphql': 'seti:graphql',
+ '.graphqls': 'seti:graphql',
+ '.hack': 'seti:hacklang',
+ '.haml': 'seti:haml',
+ '.handlebars': 'seti:mustache',
+ '.hbs': 'seti:mustache',
+ '.hjs': 'seti:mustache',
+ '.hs': 'seti:haskell',
+ '.lhs': 'seti:haskell',
+ '.hx': 'seti:haxe',
+ '.hxs': 'seti:haxe',
+ '.hxp': 'seti:haxe',
+ '.hxml': 'seti:haxe',
+ '.html': 'seti:html',
+ '.jade': 'seti:jade',
+ '.java': 'seti:java',
+ '.class': 'seti:java',
+ '.classpath': 'seti:java',
+ '.properties': 'seti:java',
+ '.js': 'seti:javascript',
+ '.js.map': 'seti:javascript',
+ '.cjs': 'seti:javascript',
+ '.cjs.map': 'seti:javascript',
+ '.mjs': 'seti:javascript',
+ '.mjs.map': 'seti:javascript',
+ '.spec.js': 'seti:javascript',
+ '.spec.cjs': 'seti:javascript',
+ '.spec.mjs': 'seti:javascript',
+ '.test.js': 'seti:javascript',
+ '.test.cjs': 'seti:javascript',
+ '.test.mjs': 'seti:javascript',
+ '.es': 'seti:javascript',
+ '.es5': 'seti:javascript',
+ '.es6': 'seti:javascript',
+ '.es7': 'seti:javascript',
+ '.jinja': 'seti:jinja',
+ '.jinja2': 'seti:jinja',
+ '.json': 'seti:json',
+ '.jl': 'seti:julia',
+ '.kt': 'seti:kotlin',
+ '.kts': 'seti:kotlin',
+ '.dart': 'seti:dart',
+ '.less': 'seti:json',
+ '.liquid': 'seti:liquid',
+ '.ls': 'seti:livescript',
+ '.lua': 'seti:lua',
+ '.markdown': 'seti:markdown',
+ '.md': 'seti:markdown',
+ '.argdown': 'seti:argdown',
+ '.ad': 'seti:argdown',
+ '.mustache': 'seti:mustache',
+ '.stache': 'seti:mustache',
+ '.nim': 'seti:nim',
+ '.nims': 'seti:nim',
+ '.github-issues': 'seti:github',
+ '.ipynb': 'seti:notebook',
+ '.njk': 'seti:nunjucks',
+ '.nunjucks': 'seti:nunjucks',
+ '.nunjs': 'seti:nunjucks',
+ '.nunj': 'seti:nunjucks',
+ '.njs': 'seti:nunjucks',
+ '.nj': 'seti:nunjucks',
+ '.npm-debug.log': 'seti:npm',
+ '.npmignore': 'seti:npm',
+ '.npmrc': 'seti:npm',
+ '.ml': 'seti:ocaml',
+ '.mli': 'seti:ocaml',
+ '.cmx': 'seti:ocaml',
+ '.cmxa': 'seti:ocaml',
+ '.odata': 'seti:odata',
+ '.pl': 'seti:perl',
+ '.php': 'seti:php',
+ '.php.inc': 'seti:php',
+ '.pipeline': 'seti:pipeline',
+ '.pddl': 'seti:pddl',
+ '.plan': 'seti:plan',
+ '.happenings': 'seti:happenings',
+ '.ps1': 'seti:powershell',
+ '.psd1': 'seti:powershell',
+ '.psm1': 'seti:powershell',
+ '.prisma': 'seti:prisma',
+ '.pug': 'seti:pug',
+ '.pp': 'seti:puppet',
+ '.epp': 'seti:puppet',
+ '.purs': 'seti:purescript',
+ '.py': 'seti:python',
+ '.jsx': 'seti:react',
+ '.spec.jsx': 'seti:react',
+ '.test.jsx': 'seti:react',
+ '.cjsx': 'seti:react',
+ '.tsx': 'seti:react',
+ '.spec.tsx': 'seti:react',
+ '.test.tsx': 'seti:react',
+ '.res': 'seti:rescript',
+ '.resi': 'seti:rescript',
+ '.R': 'seti:R',
+ '.rmd': 'seti:R',
+ '.rb': 'seti:ruby',
+ '.erb': 'seti:html',
+ '.erb.html': 'seti:html',
+ '.html.erb': 'seti:html',
+ '.rs': 'seti:rust',
+ '.sass': 'seti:sass',
+ '.scss': 'seti:sass',
+ '.springBeans': 'seti:spring',
+ '.slim': 'seti:slim',
+ '.smarty.tpl': 'seti:smarty',
+ '.tpl': 'seti:smarty',
+ '.sbt': 'seti:sbt',
+ '.scala': 'seti:scala',
+ '.sol': 'seti:ethereum',
+ '.styl': 'seti:stylus',
+ '.svelte': 'seti:svelte',
+ '.swift': 'seti:swift',
+ '.sql': 'seti:db',
+ '.soql': 'seti:db',
+ '.tf': 'seti:terraform',
+ '.tf.json': 'seti:terraform',
+ '.tfvars': 'seti:terraform',
+ '.tfvars.json': 'seti:terraform',
+ '.tex': 'seti:tex',
+ '.sty': 'seti:tex',
+ '.dtx': 'seti:tex',
+ '.ins': 'seti:tex',
+ '.txt': 'seti:default',
+ '.toml': 'seti:config',
+ '.twig': 'seti:twig',
+ '.ts': 'seti:typescript',
+ '.spec.ts': 'seti:typescript',
+ '.test.ts': 'seti:typescript',
+ '.vala': 'seti:vala',
+ '.vapi': 'seti:vala',
+ '.component': 'seti:html',
+ '.vue': 'seti:vue',
+ '.wasm': 'seti:wasm',
+ '.wat': 'seti:wat',
+ '.xml': 'seti:xml',
+ '.yml': 'seti:yml',
+ '.yaml': 'seti:yml',
+ '.pro': 'seti:prolog',
+ '.zig': 'seti:zig',
+ '.jar': 'seti:zip',
+ '.zip': 'seti:zip',
+ '.wgt': 'seti:wgt',
+ '.ai': 'seti:illustrator',
+ '.psd': 'seti:photoshop',
+ '.pdf': 'seti:pdf',
+ '.eot': 'seti:font',
+ '.ttf': 'seti:font',
+ '.woff': 'seti:font',
+ '.woff2': 'seti:font',
+ '.otf': 'seti:font',
+ '.avif': 'seti:image',
+ '.gif': 'seti:image',
+ '.jpg': 'seti:image',
+ '.jpeg': 'seti:image',
+ '.png': 'seti:image',
+ '.pxm': 'seti:image',
+ '.svg': 'seti:svg',
+ '.svgx': 'seti:image',
+ '.tiff': 'seti:image',
+ '.webp': 'seti:image',
+ '.sublime-project': 'seti:sublime',
+ '.sublime-workspace': 'seti:sublime',
+ '.code-search': 'seti:code-search',
+ '.sh': 'seti:shell',
+ '.zsh': 'seti:shell',
+ '.fish': 'seti:shell',
+ '.zshrc': 'seti:shell',
+ '.bashrc': 'seti:shell',
+ '.mov': 'seti:video',
+ '.ogv': 'seti:video',
+ '.webm': 'seti:video',
+ '.avi': 'seti:video',
+ '.mpg': 'seti:video',
+ '.mp4': 'seti:video',
+ '.mp3': 'seti:audio',
+ '.ogg': 'seti:audio',
+ '.wav': 'seti:audio',
+ '.flac': 'seti:audio',
+ '.3ds': 'seti:svg',
+ '.3dm': 'seti:svg',
+ '.stl': 'seti:svg',
+ '.obj': 'seti:svg',
+ '.dae': 'seti:svg',
+ '.bat': 'seti:windows',
+ '.cmd': 'seti:windows',
+ '.babelrc': 'seti:babel',
+ '.babelrc.js': 'seti:babel',
+ '.babelrc.cjs': 'seti:babel',
+ '.bazelrc': 'seti:bazel',
+ '.bowerrc': 'seti:bower',
+ '.codeclimate.yml': 'seti:code-climate',
+ '.eslintrc': 'seti:eslint',
+ '.eslintrc.js': 'seti:eslint',
+ '.eslintrc.cjs': 'seti:eslint',
+ '.eslintrc.yaml': 'seti:eslint',
+ '.eslintrc.yml': 'seti:eslint',
+ '.eslintrc.json': 'seti:eslint',
+ '.eslintignore': 'seti:eslint',
+ '.firebaserc': 'seti:firebase',
+ '.gitlab-ci.yml': 'seti:gitlab',
+ '.jshintrc': 'seti:javascript',
+ '.jscsrc': 'seti:javascript',
+ '.stylelintrc': 'seti:stylelint',
+ '.stylelintrc.json': 'seti:stylelint',
+ '.stylelintrc.yaml': 'seti:stylelint',
+ '.stylelintrc.yml': 'seti:stylelint',
+ '.stylelintrc.js': 'seti:stylelint',
+ '.stylelintignore': 'seti:stylelint',
+ '.direnv': 'seti:config',
+ '.env': 'seti:config',
+ '.static': 'seti:config',
+ '.editorconfig': 'seti:config',
+ '.slugignore': 'seti:config',
+ '.tmp': 'seti:clock',
+ '.htaccess': 'seti:config',
+ '.key': 'seti:lock',
+ '.cert': 'seti:lock',
+ '.cer': 'seti:lock',
+ '.crt': 'seti:lock',
+ '.pem': 'seti:lock',
+ '.DS_Store': 'seti:ignored',
+ },
+ partials: {
+ mix: 'seti:hex',
+ Gemfile: 'seti:ruby',
+ gemfile: 'seti:ruby',
+ dockerfile: 'seti:docker',
+ Dockerfile: 'seti:docker',
+ DOCKERFILE: 'seti:docker',
+ '.dockerignore': 'seti:docker',
+ 'docker-healthcheck': 'seti:docker',
+ 'docker-compose.yml': 'seti:docker',
+ 'docker-compose.yaml': 'seti:docker',
+ 'docker-compose.override.yml': 'seti:docker',
+ 'docker-compose.override.yaml': 'seti:docker',
+ GULPFILE: 'seti:gulp',
+ Gulpfile: 'seti:gulp',
+ gulpfile: 'seti:gulp',
+ 'gulpfile.js': 'seti:gulp',
+ LICENSE: 'seti:license',
+ LICENCE: 'seti:license',
+ 'LICENSE.txt': 'seti:license',
+ 'LICENCE.txt': 'seti:license',
+ 'LICENSE.md': 'seti:license',
+ 'LICENCE.md': 'seti:license',
+ COPYING: 'seti:license',
+ 'COPYING.txt': 'seti:license',
+ 'COPYING.md': 'seti:license',
+ COMPILING: 'seti:license',
+ 'COMPILING.txt': 'seti:license',
+ 'COMPILING.md': 'seti:license',
+ CONTRIBUTING: 'seti:license',
+ 'CONTRIBUTING.txt': 'seti:license',
+ 'CONTRIBUTING.md': 'seti:license',
+ MAKEFILE: 'seti:makefile',
+ Makefile: 'seti:makefile',
+ makefile: 'seti:makefile',
+ QMAKEFILE: 'seti:makefile',
+ QMakefile: 'seti:makefile',
+ qmakefile: 'seti:makefile',
+ OMAKEFILE: 'seti:makefile',
+ OMakefile: 'seti:makefile',
+ omakefile: 'seti:makefile',
+ 'CMAKELISTS.TXT': 'seti:makefile',
+ 'CMAKELISTS.txt': 'seti:makefile',
+ 'CMakeLists.txt': 'seti:makefile',
+ 'cmakelists.txt': 'seti:makefile',
+ Procfile: 'seti:heroku',
+ TODO: 'seti:todo',
+ 'TODO.txt': 'seti:todo',
+ 'TODO.md': 'seti:todo',
+ },
+};
+
+export const FileIcons = {
+ 'seti:folder':
+ ' ',
+ 'seti:bsl':
+ ' ',
+ 'seti:mdo':
+ ' ',
+ 'seti:salesforce':
+ ' ',
+ 'seti:asm':
+ ' ',
+ 'seti:bicep':
+ ' ',
+ 'seti:bazel':
+ ' ',
+ 'seti:c':
+ ' ',
+ 'seti:c-sharp':
+ ' ',
+ 'seti:html':
+ ' ',
+ 'seti:cpp':
+ ' ',
+ 'seti:clojure':
+ ' ',
+ 'seti:coldfusion':
+ ' ',
+ 'seti:config':
+ ' ',
+ 'seti:crystal':
+ ' ',
+ 'seti:crystal_embedded':
+ ' ',
+ 'seti:json':
+ ' ',
+ 'seti:css':
+ ' ',
+ 'seti:csv':
+ ' ',
+ 'seti:xls':
+ ' ',
+ 'seti:cu':
+ ' ',
+ 'seti:cake':
+ ' ',
+ 'seti:cake_php':
+ ' ',
+ 'seti:d':
+ ' ',
+ 'seti:word':
+ ' ',
+ 'seti:elixir':
+ ' ',
+ 'seti:elixir_script':
+ ' ',
+ 'seti:hex':
+ ' ',
+ 'seti:elm':
+ ' ',
+ 'seti:favicon':
+ ' ',
+ 'seti:f-sharp':
+ ' ',
+ 'seti:git':
+ ' ',
+ 'seti:go':
+ ' ',
+ 'seti:godot':
+ ' ',
+ 'seti:gradle':
+ ' ',
+ 'seti:grails':
+ ' ',
+ 'seti:graphql':
+ ' ',
+ 'seti:hacklang':
+ ' ',
+ 'seti:haml':
+ ' ',
+ 'seti:mustache':
+ ' ',
+ 'seti:haskell':
+ ' ',
+ 'seti:haxe':
+ ' ',
+ 'seti:jade':
+ ' ',
+ 'seti:java':
+ ' ',
+ 'seti:javascript':
+ ' ',
+ 'seti:jinja':
+ ' ',
+ 'seti:julia':
+ ' ',
+ 'seti:karma':
+ ' ',
+ 'seti:kotlin':
+ ' ',
+ 'seti:dart':
+ ' ',
+ 'seti:liquid':
+ ' ',
+ 'seti:livescript':
+ ' ',
+ 'seti:lua':
+ ' ',
+ 'seti:markdown':
+ ' ',
+ 'seti:argdown':
+ ' ',
+ 'seti:info':
+ ' ',
+ 'seti:clock':
+ ' ',
+ 'seti:maven':
+ ' ',
+ 'seti:nim':
+ ' ',
+ 'seti:github':
+ ' ',
+ 'seti:notebook':
+ ' ',
+ 'seti:nunjucks':
+ ' ',
+ 'seti:npm':
+ ' ',
+ 'seti:ocaml':
+ ' ',
+ 'seti:odata':
+ ' ',
+ 'seti:perl':
+ ' ',
+ 'seti:php':
+ ' ',
+ 'seti:pipeline':
+ ' ',
+ 'seti:pddl':
+ ' ',
+ 'seti:plan':
+ ' ',
+ 'seti:happenings':
+ ' ',
+ 'seti:powershell':
+ ' ',
+ 'seti:prisma':
+ ' ',
+ 'seti:pug':
+ ' ',
+ 'seti:puppet':
+ ' ',
+ 'seti:purescript':
+ ' ',
+ 'seti:python':
+ ' ',
+ 'seti:react':
+ ' ',
+ 'seti:rescript':
+ ' ',
+ 'seti:R':
+ ' ',
+ 'seti:ruby':
+ ' ',
+ 'seti:rust':
+ ' ',
+ 'seti:sass':
+ ' ',
+ 'seti:spring':
+ ' ',
+ 'seti:slim':
+ ' ',
+ 'seti:smarty':
+ ' ',
+ 'seti:sbt':
+ ' ',
+ 'seti:scala':
+ ' ',
+ 'seti:ethereum':
+ ' ',
+ 'seti:stylus':
+ ' ',
+ 'seti:svelte':
+ ' ',
+ 'seti:swift':
+ ' ',
+ 'seti:db':
+ ' ',
+ 'seti:terraform':
+ ' ',
+ 'seti:tex':
+ ' ',
+ 'seti:default':
+ ' ',
+ 'seti:twig':
+ ' ',
+ 'seti:typescript':
+ ' ',
+ 'seti:tsconfig':
+ ' ',
+ 'seti:vala':
+ ' ',
+ 'seti:vite':
+ ' ',
+ 'seti:vue':
+ ' ',
+ 'seti:wasm':
+ ' ',
+ 'seti:wat':
+ ' ',
+ 'seti:xml':
+ ' ',
+ 'seti:yml':
+ ' ',
+ 'seti:prolog':
+ ' ',
+ 'seti:zig':
+ ' ',
+ 'seti:zip':
+ ' ',
+ 'seti:wgt':
+ ' ',
+ 'seti:illustrator':
+ ' ',
+ 'seti:photoshop':
+ ' ',
+ 'seti:pdf':
+ ' ',
+ 'seti:font':
+ ' ',
+ 'seti:image':
+ ' ',
+ 'seti:svg':
+ ' ',
+ 'seti:sublime':
+ ' ',
+ 'seti:code-search':
+ ' ',
+ 'seti:shell':
+ ' ',
+ 'seti:video':
+ ' ',
+ 'seti:audio':
+ ' ',
+ 'seti:windows':
+ ' ',
+ 'seti:jenkins':
+ ' ',
+ 'seti:babel':
+ ' ',
+ 'seti:bower':
+ ' ',
+ 'seti:docker':
+ ' ',
+ 'seti:code-climate':
+ ' ',
+ 'seti:eslint':
+ ' ',
+ 'seti:firebase':
+ ' ',
+ 'seti:firefox':
+ ' ',
+ 'seti:gitlab':
+ ' ',
+ 'seti:grunt':
+ ' ',
+ 'seti:gulp':
+ ' ',
+ 'seti:ionic':
+ ' ',
+ 'seti:platformio':
+ ' ',
+ 'seti:rollup':
+ ' ',
+ 'seti:stylelint':
+ ' ',
+ 'seti:yarn':
+ ' ',
+ 'seti:webpack':
+ ' ',
+ 'seti:lock':
+ ' ',
+ 'seti:license':
+ ' ',
+ 'seti:makefile':
+ ' ',
+ 'seti:heroku':
+ ' ',
+ 'seti:todo':
+ ' ',
+ 'seti:ignored':
+ ' ',
+};
diff --git a/packages/polymech/src/components/readme.astro b/packages/polymech/src/components/readme.astro
new file mode 100644
index 0000000..2c8fd99
--- /dev/null
+++ b/packages/polymech/src/components/readme.astro
@@ -0,0 +1,52 @@
+---
+import { renderMarkup } from "@/model/component.js";
+import { render, component } from "@/base/index.js";
+import { translate } from "@/base/i18n.js";
+import { I18N_SOURCE_LANGUAGE, ASSET_URL } from "config/config.js";
+import { fromMarkdown } from "mdast-util-from-markdown";
+import { toString } from "mdast-util-to-string";
+// https://github.com/syntax-tree/mdast-util-mdxjs-esm?tab=readme-ov-file#when-to-use-this
+// https://github.com/syntax-tree/mdast-util-to-markdown?tab=readme-ov-file#list-of-extensions
+// https://chatgpt.com/c/67ba55c7-4c04-8001-b26b-bfa3a89aafb1
+import { toMarkdown } from "mdast-util-to-markdown";
+import { visit } from "unist-util-visit";
+import { Root, RootContent, Heading, Image } from "mdast";
+import { Img } from "imagetools/components";
+
+interface Props {
+ markdown: string;
+ className?: string;
+ baseImageUrl?: string;
+ translate?: boolean;
+ data?: any;
+}
+const {
+ markdown,
+ className = "",
+ baseImageUrl = "",
+ translate = false,
+ data = {},
+ ...rest
+} = Astro.props as Props;
+
+const processImageUrls = (content: string, data: Record) => {
+ const tree = fromMarkdown(content) as Root;
+ visit(tree, "image", (node: Image) => {
+ if (!node.url.startsWith("http") && !node.url.startsWith("/")) {
+ node.url = ASSET_URL(node.url, data);
+ }
+ })
+ const markup = toMarkdown(tree);
+ return markup;
+}
+
+const ReadmeContent = await component(
+ processImageUrls(markdown, data),
+ Astro.currentLocale,
+ {},
+)
+---
+
+
+
+
diff --git a/packages/polymech/src/components/rehype-file-tree.ts b/packages/polymech/src/components/rehype-file-tree.ts
new file mode 100644
index 0000000..73d2453
--- /dev/null
+++ b/packages/polymech/src/components/rehype-file-tree.ts
@@ -0,0 +1,260 @@
+import { AstroError } from 'astro/errors';
+import type { Element, ElementContent, Text } from 'hast';
+import { type Child, h, s } from 'hastscript';
+import { select } from 'hast-util-select';
+import { fromHtml } from 'hast-util-from-html';
+import { toString } from 'hast-util-to-string';
+import { rehype } from 'rehype';
+import { CONTINUE, SKIP, visit } from 'unist-util-visit';
+import { Icons, type StarlightIcon } from './Icons.js';
+import { definitions } from './file-tree-icons.js';
+
+declare module 'vfile' {
+ interface DataMap {
+ directoryLabel: string;
+ }
+}
+
+const folderIcon = makeSVGIcon(Icons['seti:folder']);
+const defaultFileIcon = makeSVGIcon(Icons['seti:default']);
+
+/**
+ * Process the HTML for a file tree to create the necessary markup for each file and directory
+ * including icons.
+ * @param html Inner HTML passed to the `` component.
+ * @param directoryLabel The localized label for a directory.
+ * @returns The processed HTML for the file tree.
+ */
+export function processFileTree(html: string, directoryLabel: string) {
+ const file = fileTreeProcessor.processSync({ data: { directoryLabel }, value: html });
+
+ return file.toString();
+}
+
+/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
+const fileTreeProcessor = rehype()
+ .data('settings', { fragment: true })
+ .use(function fileTree() {
+ return (tree: Element, file) => {
+ const { directoryLabel } = file.data;
+
+ // validateFileTree(tree);
+
+ visit(tree, 'element', (node) => {
+ // Strip nodes that only contain newlines.
+ node.children = node.children.filter(
+ (child) => child.type === 'comment' || child.type !== 'text' || !/^\n+$/.test(child.value)
+ );
+
+ // Skip over non-list items.
+ if (node.tagName !== 'li') return CONTINUE;
+
+ const [firstChild, ...otherChildren] = node.children;
+
+ // Keep track of comments associated with the current file or directory.
+ const comment: Child[] = [];
+
+ // Handle both text nodes and link elements for file names
+ let filename = '';
+ let isLinked = false;
+
+ if (firstChild?.type === 'text') {
+ // Extract text comment that follows the file name, e.g. `README.md This is a comment`
+ const [filenameText, ...fragments] = firstChild.value.split(' ');
+ firstChild.value = filenameText || '';
+ filename = filenameText || '';
+ const textComment = fragments.join(' ').trim();
+ if (textComment.length > 0) {
+ comment.push(fragments.join(' '));
+ }
+ } else if (firstChild?.type === 'element' && firstChild.tagName === 'a') {
+ // Handle linked files - preserve the link
+ isLinked = true;
+ filename = toString(firstChild);
+ }
+
+ // Comments may not always be entirely part of the first child text node,
+ // e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
+ // nodes would also be children of the list item node.
+ const subTreeIndex = otherChildren.findIndex(
+ (child) => child.type === 'element' && child.tagName === 'ul'
+ );
+ const commentNodes =
+ subTreeIndex > -1 ? otherChildren.slice(0, subTreeIndex) : [...otherChildren];
+ otherChildren.splice(0, subTreeIndex > -1 ? subTreeIndex : otherChildren.length);
+ comment.push(...commentNodes);
+
+ const firstChildTextContent = filename || (firstChild ? toString(firstChild) : '');
+
+ // Decide a node is a directory if it ends in a `/` or contains another list.
+ const isDirectory =
+ /\/\s*$/.test(firstChildTextContent) ||
+ otherChildren.some((child) => child.type === 'element' && child.tagName === 'ul');
+ // A placeholder is a node that only contains 3 dots or an ellipsis.
+ const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
+ // A node is highlighted if its first child is bold text, e.g. `**README.md**`.
+ const isHighlighted = firstChild?.type === 'element' && firstChild.tagName === 'strong';
+
+ // Create an icon for the file or directory (placeholder do not have icons).
+ const icon = h('span', isDirectory ? folderIcon : getFileIcon(firstChildTextContent));
+ if (isDirectory) {
+ // Add a screen reader only label for directories before the icon so that it is announced
+ // as such before reading the directory name.
+ icon.children.unshift(h('span', { class: 'sr-only' }, directoryLabel));
+ }
+
+ // Add classes and data attributes to the list item node.
+ node.properties.class = isDirectory ? 'directory' : 'file';
+ if (isPlaceholder) node.properties.class += ' empty';
+
+ // Create the tree entry node that contains the icon, file name and comment which will end up
+ // as the list item's children.
+ const treeEntryChildren: Child[] = [
+ h('span', { class: isHighlighted ? 'highlight' : '' }, [
+ isPlaceholder ? null : icon,
+ firstChild,
+ ]),
+ ];
+
+ if (comment.length > 0) {
+ treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
+ }
+
+ const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);
+
+ if (isDirectory) {
+ const hasContents = otherChildren.length > 0;
+
+ node.children = [
+ h('details', { open: hasContents }, [
+ h('summary', treeEntry),
+ ...(hasContents ? otherChildren : [h('ul', h('li', '…'))]),
+ ]),
+ ];
+
+ // Continue down the tree.
+ return CONTINUE;
+ }
+
+ node.children = [treeEntry, ...otherChildren];
+
+ // Files can’t contain further files or directories, so skip iterating children.
+ return SKIP;
+ });
+ };
+ });
+
+/** Make a text node with the pass string as its contents. */
+function makeText(value = ''): Text {
+ return { type: 'text', value };
+}
+
+/** Make a node containing an SVG icon from the passed HTML string. */
+function makeSVGIcon(svgString: string) {
+ return s(
+ 'svg',
+ {
+ width: 16,
+ height: 16,
+ class: 'tree-icon',
+ 'aria-hidden': 'true',
+ viewBox: '0 0 24 24',
+ },
+ fromHtml(svgString, { fragment: true })
+ );
+}
+
+/** Return the icon for a file based on its file name. */
+function getFileIcon(fileName: string) {
+ const name = getFileIconName(fileName);
+ if (!name) return defaultFileIcon;
+ if (name in Icons) {
+ const path = Icons[name as StarlightIcon];
+ return makeSVGIcon(path);
+ }
+ return defaultFileIcon;
+}
+
+/** Return the icon name for a file based on its file name. */
+function getFileIconName(fileName: string) {
+ let icon: string | undefined = definitions.files[fileName];
+ if (icon) return icon;
+ icon = getFileIconTypeFromExtension(fileName);
+ if (icon) return icon;
+ for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
+ if (fileName.includes(partial)) return partialIcon;
+ }
+ return icon;
+}
+
+/**
+ * Get an icon from a file name based on its extension.
+ * Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
+ * `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
+ * is not found.
+ */
+function getFileIconTypeFromExtension(fileName: string) {
+ const firstDotIndex = fileName.indexOf('.');
+ if (firstDotIndex === -1) return;
+ let extension = fileName.slice(firstDotIndex);
+ while (extension !== '') {
+ const icon = definitions.extensions[extension];
+ if (icon) return icon;
+ const nextDotIndex = extension.indexOf('.', 1);
+ if (nextDotIndex === -1) return;
+ extension = extension.slice(nextDotIndex);
+ }
+ return;
+}
+
+/** Validate that the user provided HTML for a file tree is valid. */
+function validateFileTree(tree: Element) {
+ const rootElements = tree.children.filter(isElementNode);
+ const [rootElement] = rootElements;
+
+ if (rootElements.length === 0) {
+ throwFileTreeValidationError(
+ 'The `` component expects its content to be a single unordered list but found no child elements.'
+ );
+ }
+
+ if (rootElements.length !== 1) {
+ throwFileTreeValidationError(
+ `The \`\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
+ .map((element) => `\`<${element.tagName}>\``)
+ .join(' - ')}.`
+ );
+ }
+
+ if (!rootElement || rootElement.tagName !== 'ul') {
+ throwFileTreeValidationError(
+ `The \`\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
+ );
+ }
+
+ const listItemElement = select('li', rootElement);
+
+ if (!listItemElement) {
+ throwFileTreeValidationError(
+ 'The `` component expects its content to be an unordered list with at least one list item.'
+ );
+ }
+}
+
+function isElementNode(node: ElementContent): node is Element {
+ return node.type === 'element';
+}
+
+/** Throw a validation error for a file tree linking to the documentation. */
+function throwFileTreeValidationError(message: string): never {
+ throw new AstroError(
+ message,
+ 'To learn more about the `` component, see https://starlight.astro.build/components/file-tree/'
+ );
+}
+
+export interface Definitions {
+ files: Record;
+ extensions: Record;
+ partials: Record;
+}
diff --git a/packages/polymech/src/utils/path-resolution.ts b/packages/polymech/src/utils/path-resolution.ts
new file mode 100644
index 0000000..8421b19
--- /dev/null
+++ b/packages/polymech/src/utils/path-resolution.ts
@@ -0,0 +1,117 @@
+import fs from 'fs';
+import path from 'path';
+import { resolveVariables, resolve } from '@polymech/commons'
+// --- Debug Configuration ---
+const enableDebugSuccess = false;
+const enableDebugErrors = false;
+
+/**
+ * Checks if a given file path exists and is a file.
+ * @param {string} basePath The absolute base directory to check within.
+ * @param {string} relativePath The relative path of the file from the base path.
+ * @returns {string | null} The project-relative path if the file exists, otherwise null.
+ */
+function checkFilePath(basePath: string, relativePath: string): string | null {
+ try {
+ const absolutePath = path.resolve(basePath, relativePath);
+ if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
+ // Return path relative to project root, using forward slashes for Astro's asset handling.
+ // This ensures compatibility regardless of deployment environment (e.g., subdirectories).
+ return path.relative(process.cwd(), absolutePath).replace(/\\/g, '/');
+ }
+ } catch (e) {
+ if (enableDebugErrors) {
+ console.error(`[resolveImagePath] Filesystem error while checking path at "${basePath}":`, e);
+ }
+ }
+ return null;
+}
+
+/**
+ * Resolves the path of an image, handling various scenarios like relative paths in content,
+ * absolute paths from the /public folder, and external URLs. It's designed to be robust
+ * for Astro's content collections, especially in i18n setups.
+ *
+ * @param {string} src The image source path from the content (e.g., "./images/foo.jpg", "/logo.png").
+ * @param {string} [entryPath] The content collection entry path (e.g., "resources/cassandra/home").
+ * This is the most reliable way to resolve relative paths.
+ * @param {URL} [astroUrl] The Astro page URL object, used as a fallback for path resolution.
+ * @returns {string} The resolved image path, typically relative to the project root for local images.
+ */
+export function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL): string {
+ if (enableDebugSuccess) {
+ console.debug(`[resolveImagePath] Attempting to resolve: src="${src}", entryPath="${entryPath}", url="${astroUrl?.pathname}"`);
+ }
+
+ // --- Case 1: Absolute paths or external URLs ---
+ // Handles external images (http://, https://) and images in the /public directory (/).
+ // These paths don't need resolution and are returned as-is.
+ if (src.startsWith('/') || src.startsWith('http')) {
+ if (enableDebugSuccess) {
+ console.debug(`[resolveImagePath] Path is absolute or external. Returning as-is: "${src}"`);
+ }
+ return src;
+ }
+
+ // --- Case 2: Relative paths (e.g., ./images/foo.jpg or ../shared/foo.jpg) ---
+ if (src.startsWith('.')) {
+ let basePath: string | undefined;
+ let strategy: 'entryPath' | 'URL' | undefined;
+
+ // --- Strategy 2.1: Use entryPath (most reliable) ---
+ if (entryPath) {
+ strategy = 'entryPath';
+ const contentDir = entryPath.substring(0, entryPath.lastIndexOf('/'));
+ basePath = path.join(process.cwd(), 'src', 'content', contentDir);
+ }
+ // --- Strategy 2.2: Fallback to URL parsing ---
+ else if (astroUrl) {
+ strategy = 'URL';
+ if (enableDebugErrors) console.warn(`[resolveImagePath] [INFO-URL] No entryPath provided. Falling back to URL-based resolution for "${src}". This is less reliable.`);
+
+ const isFolderUrl = astroUrl.pathname.endsWith('/');
+ const pathSegments = astroUrl.pathname.split('/').filter(p => p);
+ const hasLocale = pathSegments.length > 0 && /^[a-z]{2}$/.test(pathSegments[0]);
+ if (hasLocale) pathSegments.shift();
+
+ if (pathSegments.length >= 1) {
+ const contentDirGuess = isFolderUrl ? pathSegments.join('/') : pathSegments.slice(0, -1).join('/');
+ basePath = path.join(process.cwd(), 'src', 'content', contentDirGuess);
+ }
+ }
+
+ if (basePath && strategy) {
+ // Check in the determined base directory
+ let resolvedPath = checkFilePath(basePath, src);
+ if (resolvedPath) {
+ if (enableDebugSuccess) console.log(`[resolveImagePath] [SUCCESS-${strategy}] Resolved "${src}" to "${resolvedPath}"`);
+ return resolvedPath;
+ }
+
+ // Parent Directory Check: If not found, check one level
+
+ if (enableDebugErrors) console.warn(`[resolveImagePath] [WARN-${strategy}] Not found in "${basePath}". Checking parent directory.`);
+ const parentBasePath = path.resolve(basePath, '..');
+ resolvedPath = checkFilePath(parentBasePath, src);
+ if (resolvedPath) {
+ if (enableDebugSuccess) console.log(`[resolveImagePath] [SUCCESS-${strategy}-Parent] Resolved "${src}" to "${resolvedPath}"`);
+ return resolvedPath;
+ }
+
+ if (enableDebugErrors) {
+ console.warn(`[resolveImagePath] [WARN-${strategy}] Final path check failed for "${src}". Base path checked: "${basePath}", Parent path checked: "${parentBasePath}"`);
+ }
+ }
+
+ if (enableDebugErrors) {
+ console.error(`[resolveImagePath] [FAILURE] Could not resolve relative path "${src}". Returning original.`);
+ }
+ return src;
+ }
+
+ // --- Fallback for unrecognized path formats ---
+ if (enableDebugErrors) {
+ console.warn(`[resolveImagePath] Path format for "${src}" is ambiguous (not absolute or relative). Returning as-is.`);
+ }
+ return src;
+}