From 5c8fb0015ceed205d3d2cf90802b379164d9dd4c Mon Sep 17 00:00:00 2001 From: babayaga Date: Tue, 19 Aug 2025 15:10:10 +0200 Subject: [PATCH] components --- packages/polymech/package.json | 2 + .../polymech/src/components/FileTree.astro | 730 ++++++++++++++++ packages/polymech/src/components/Icons.ts | 203 +++++ .../src/components/MasonryGallery.astro | 127 +-- .../polymech/src/components/MasonryGallery.md | 160 ---- .../src/components/RelativeGallery.astro | 178 ++++ .../src/components/RelativeImage.astro | 96 +++ .../src/components/RelativePicture.astro | 47 ++ .../polymech/src/components/conditional.astro | 8 + .../src/components/file-tree-icons.ts | 782 ++++++++++++++++++ packages/polymech/src/components/readme.astro | 52 ++ .../src/components/rehype-file-tree.ts | 260 ++++++ .../polymech/src/utils/path-resolution.ts | 117 +++ 13 files changed, 2549 insertions(+), 213 deletions(-) create mode 100644 packages/polymech/src/components/FileTree.astro create mode 100644 packages/polymech/src/components/Icons.ts delete mode 100644 packages/polymech/src/components/MasonryGallery.md create mode 100644 packages/polymech/src/components/RelativeGallery.astro create mode 100644 packages/polymech/src/components/RelativeImage.astro create mode 100644 packages/polymech/src/components/RelativePicture.astro create mode 100644 packages/polymech/src/components/conditional.astro create mode 100644 packages/polymech/src/components/file-tree-icons.ts create mode 100644 packages/polymech/src/components/readme.astro create mode 100644 packages/polymech/src/components/rehype-file-tree.ts create mode 100644 packages/polymech/src/utils/path-resolution.ts 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 = ``; + } + } + } catch (error) { + console.warn('FileTree glob pattern failed:', error); + if (view === 'thumbs') { + thumbnailFiles = []; + } else { + const searchedPath = basePath ? basePath.replace(/\\/g, '/') : 'unknown path'; + fileTreeHtml = ``; + } + } +} 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 = ''; + 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) => ( +
+ {linkFiles && file.fileUrl ? ( + +
+ {file.isImage ? ( +
+ {file.fileName} +
+ ) : ( +
+ '} /> +
+ )} +
{file.fileName}
+
+
+ ) : ( +
+ {file.isImage ? ( +
+ {file.fileName} +
+ ) : ( +
+ '} /> +
+ )} +
{file.fileName}
+
+ )} +
+ )) : ( +
+ 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" +--- +
+ {expression ? :
} +
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; +}