From 48c77f446abaabbcbea5f3159e3093411094dbc7 Mon Sep 17 00:00:00 2001 From: babayaga Date: Sat, 23 Aug 2025 18:11:49 +0200 Subject: [PATCH] refactor site2 --- packages/polymech/package.json | 6 + .../polymech/plugins/rehype-custom-img.mjs | 36 ++ .../polymech/plugins/remark-reading-time.mjs | 30 + packages/polymech/src/app/config.ts | 32 + packages/polymech/src/base/collections.ts | 220 +++++-- .../components/ArticleStructuredData.astro | 3 +- .../polymech/src/components/BaseHead.astro | 182 ++++++ .../polymech/src/components/Breadcrumb.astro | 4 +- .../polymech/src/components/GalleryK.astro | 16 +- .../src/components/global/Footer.astro | 118 ++++ .../src/components/global/Navigation.astro | 32 + .../src/components/global/ThemeToggle.astro | 86 +++ .../components/resources/ResourceCard.astro | 269 ++++++++ .../src/components/sidebar/SidebarGroup.astro | 16 +- .../components/sidebar/TableOfContents.astro | 2 +- .../sidebar/TableOfContentsWithScroll.astro | 2 +- .../polymech/src/layouts/BaseLayout.astro | 19 + packages/polymech/src/layouts/Resources.astro | 599 ++++++++++++++++++ .../polymech/src/layouts/StoreLayout.astro | 368 +++++++++++ .../polymech/src/layouts/WithSidebar.astro | 36 ++ packages/polymech/src/layouts/Wrapper.astro | 17 + packages/polymech/src/model/json-ld.ts | 52 ++ packages/polymech/src/model/merchant.ts | 33 + packages/polymech/src/model/registry.ts | 23 + packages/polymech/src/model/rss.ts | 12 + packages/polymech/src/pages/404.astro | 110 ++++ packages/polymech/src/pages/[locale].astro | 66 ++ .../pages/[locale]/resources/[...slug].astro | 347 ++++++++++ .../pages/[locale]/resources/tags/[tag].astro | 86 +++ .../src/pages/[locale]/store/[...path].astro | 215 +++++++ packages/polymech/src/pages/index.astro | 76 +++ packages/polymech/src/pages/rss.xml.js | 23 + packages/polymech/tsconfig.json | 83 ++- 33 files changed, 3114 insertions(+), 105 deletions(-) create mode 100644 packages/polymech/plugins/rehype-custom-img.mjs create mode 100644 packages/polymech/plugins/remark-reading-time.mjs create mode 100644 packages/polymech/src/components/BaseHead.astro create mode 100644 packages/polymech/src/components/global/Footer.astro create mode 100644 packages/polymech/src/components/global/Navigation.astro create mode 100644 packages/polymech/src/components/global/ThemeToggle.astro create mode 100644 packages/polymech/src/components/resources/ResourceCard.astro create mode 100644 packages/polymech/src/layouts/BaseLayout.astro create mode 100644 packages/polymech/src/layouts/Resources.astro create mode 100644 packages/polymech/src/layouts/StoreLayout.astro create mode 100644 packages/polymech/src/layouts/WithSidebar.astro create mode 100644 packages/polymech/src/layouts/Wrapper.astro create mode 100644 packages/polymech/src/model/json-ld.ts create mode 100644 packages/polymech/src/model/merchant.ts create mode 100644 packages/polymech/src/model/registry.ts create mode 100644 packages/polymech/src/model/rss.ts create mode 100644 packages/polymech/src/pages/404.astro create mode 100644 packages/polymech/src/pages/[locale].astro create mode 100644 packages/polymech/src/pages/[locale]/resources/[...slug].astro create mode 100644 packages/polymech/src/pages/[locale]/resources/tags/[tag].astro create mode 100644 packages/polymech/src/pages/[locale]/store/[...path].astro create mode 100644 packages/polymech/src/pages/index.astro create mode 100644 packages/polymech/src/pages/rss.xml.js diff --git a/packages/polymech/package.json b/packages/polymech/package.json index c967849..fb41da5 100644 --- a/packages/polymech/package.json +++ b/packages/polymech/package.json @@ -8,7 +8,9 @@ }, "exports": { ".": "./dist/index.js", + "./plugins/*": "./plugins/*", "./base/*": "./dist/base/*", + "./model/*": "./dist/model/*", "./config/*": "./dist/config/*", "./components/*": "./src/components/*" }, @@ -19,6 +21,7 @@ "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/react": "^4.3.0", + "@astrojs/rss": "^4.0.12", "@polymech/cache": "file:../../../polymech-mono/packages/cache", "@polymech/cad": "file:../../../polymech-mono/packages/cad", "@polymech/commons": "file:../../../polymech-mono/packages/commons", @@ -36,15 +39,18 @@ "html-entities": "^2.5.2", "imagetools": "file:../imagetools", "marked": "^16.1.2", + "mdast-util-to-string": "^4.0.0", "node-xlsx": "^0.24.0", "p-map": "^7.0.3", "react-jsx-parser": "^2.4.0", + "reading-time": "^1.5.0", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "showdown": "^2.1.0", "tslog": "^4.9.3", "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", "yargs": "^18.0.0" } } diff --git a/packages/polymech/plugins/rehype-custom-img.mjs b/packages/polymech/plugins/rehype-custom-img.mjs new file mode 100644 index 0000000..9dd07bb --- /dev/null +++ b/packages/polymech/plugins/rehype-custom-img.mjs @@ -0,0 +1,36 @@ +import { visit } from 'unist-util-visit'; +import path from 'path'; + +export default function rehypeCustomImg() { + return (tree, file) => { + if(!file.path.endsWith('.mdx')) { + return; + } + + const contentDir = path.join(process.cwd(), 'src', 'content'); + const entryPath = path.relative(contentDir, file.history[0]).replace(/\\/g, '/'); + + // 1. Add the import statement for RelativeImage. + tree.children.unshift({ + type: 'mdxjsEsm', + value: "import RelativeImage from '~/components/imagetools/RelativeImage.astro';" + }); + + // 2. Visit all JSX nodes and inject entryPath into tags. + visit(tree, 'mdxJsxFlowElement', (node) => { + if (node.name === 'img') { + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'entryPath', + value: entryPath + }); + } + }); + + // 3. Export the components mapping. + tree.children.push({ + type: 'mdxjsEsm', + value: 'export const components = { img: RelativeImage };' + }); + }; +} diff --git a/packages/polymech/plugins/remark-reading-time.mjs b/packages/polymech/plugins/remark-reading-time.mjs new file mode 100644 index 0000000..b2e0e04 --- /dev/null +++ b/packages/polymech/plugins/remark-reading-time.mjs @@ -0,0 +1,30 @@ +import getReadingTime from 'reading-time'; +import { toString } from 'mdast-util-to-string'; + +export function remarkReadingTime() { + return function (tree, { data }) { + const textOnPage = toString(tree); + const readingTime = getReadingTime(textOnPage); + + // Round up minutes and remove seconds + const roundedMinutes = Math.ceil(readingTime.minutes); + const friendlyText = `${roundedMinutes} min read`; + + // Ensure data.astro exists + if (!data.astro) { + data.astro = {}; + } + + // Set the reading time in the frontmatter + data.astro.frontmatter = data.astro.frontmatter || {}; + data.astro.frontmatter.minutesRead = friendlyText; + + // Also try setting it directly on data for compatibility + data.minutesRead = friendlyText; + + // Store the raw rounded minutes for translation later + data.astro.frontmatter.rawMinutes = roundedMinutes; + data.rawMinutes = roundedMinutes; + + }; +} diff --git a/packages/polymech/src/app/config.ts b/packages/polymech/src/app/config.ts index 11f2b1b..c880602 100644 --- a/packages/polymech/src/app/config.ts +++ b/packages/polymech/src/app/config.ts @@ -165,4 +165,36 @@ export const IMAGE_SETTINGS = SIZES_LARGE: O_IMAGE.sizes_large, SIZES_REGULAR: O_IMAGE.sizes } +} + +///////////////////////////////////////////// +// +// Collection Filters + +// Collection filter configuration +export const COLLECTION_FILTERS = { + // Enable/disable specific default filters + ENABLE_VALID_FRONTMATTER_CHECK: true, + ENABLE_FOLDER_FILTER: true, + ENABLE_DRAFT_FILTER: true, + ENABLE_TITLE_FILTER: true, // Enabled by default to filter out "Untitled" entries + + // Content validation filters + ENABLE_BODY_FILTER: false, // Require entries to have body content + ENABLE_DESCRIPTION_FILTER: false, // Require entries to have descriptions + ENABLE_IMAGE_FILTER: false, // Require entries to have images + ENABLE_AUTHOR_FILTER: false, // Require entries to have real authors (not "Unknown") + ENABLE_PUBDATE_FILTER: false, // Require entries to have valid publication dates + ENABLE_TAGS_FILTER: false, // Require entries to have tags + ENABLE_FILE_EXTENSION_FILTER: true, // Require valid .md/.mdx extensions + + // Additional filter settings + REQUIRED_FIELDS: [], // Array of required frontmatter fields + REQUIRED_TAGS: [], // Array of required tags + EXCLUDE_TAGS: [], // Array of tags to exclude + + // Date filtering + FILTER_FUTURE_POSTS: false, // Filter out posts with future publication dates + FILTER_OLD_POSTS: false, // Filter out posts older than a certain date + OLD_POST_CUTOFF_DAYS: 365, // Days to consider a post "old" } \ No newline at end of file diff --git a/packages/polymech/src/base/collections.ts b/packages/polymech/src/base/collections.ts index 5a7a6ef..2216ef4 100644 --- a/packages/polymech/src/base/collections.ts +++ b/packages/polymech/src/base/collections.ts @@ -1,6 +1,8 @@ import type { CollectionEntry } from 'astro:content'; import { isFolder } from '@polymech/commons'; import { parseFrontmatter } from '@astrojs/markdown-remark'; +import { translate } from './i18n.js'; +import { I18N_SOURCE_LANGUAGE } from "config/config.js"; // Filter function type export type CollectionFilter = (entry: CollectionEntry, astroConfig?: any) => boolean; @@ -32,7 +34,7 @@ export const hasValidFrontMatter: CollectionFilter = (entry) => { // For MD/MDX files, Astro automatically parses frontmatter // If data exists and is not empty, consider it valid if (!entry.data) return false; - + // Check for basic required fields (can be customized) // At minimum, we expect some data to exist return typeof entry.data === 'object' && Object.keys(entry.data).length > 0; @@ -49,7 +51,7 @@ export const hasValidParsedFrontMatter: CollectionFilter = (entry) => { if (entry.data && typeof entry.data === 'object' && Object.keys(entry.data).length > 0) { return true; } - + // For entries that might need raw frontmatter parsing // This is more applicable when working with raw markdown content // In most Astro cases, entry.data is already parsed @@ -94,22 +96,22 @@ export function createRawFrontmatterValidator( try { const rawContent = rawContentGetter(entry); if (!rawContent) return false; - + const parsed = parseFrontmatter(rawContent); - + // Check if frontmatter exists and is valid if (!parsed.frontmatter || typeof parsed.frontmatter !== 'object') { return false; } - + // Apply custom validator if provided if (validator) { return validator(parsed.frontmatter); } - + // Default validation: frontmatter should have at least one property return Object.keys(parsed.frontmatter).length > 0; - + } catch (error) { console.warn(`Raw frontmatter parsing failed for entry ${entry.id}:`, error); return false; @@ -132,25 +134,25 @@ export function createFileBasedFrontmatterValidator( console.warn(`No filePath available for entry ${entry.id}`); return false; } - + // This would require fs import in a Node.js environment // For now, we'll rely on the entry.data which is already parsed // In a real implementation, you could use: // const fs = await import('fs'); // const rawContent = fs.readFileSync(entry.filePath, 'utf-8'); // const parsed = parseFrontmatter(rawContent); - + // Fallback to using the already parsed data if (!entry.data || typeof entry.data !== 'object') { return false; } - + if (validator) { return validator(entry.data); } - + return Object.keys(entry.data).length > 0; - + } catch (error) { console.warn(`File-based frontmatter validation failed for entry ${entry.id}:`, error); return false; @@ -172,9 +174,9 @@ export const isNotDraft: CollectionFilter = (entry) => { export const hasTitle: CollectionFilter = (entry) => { // Check if entry has a title and it's not the default "Untitled" - return !!entry.data?.title && - entry.data.title.trim() !== '' && - entry.data.title.trim() !== 'Untitled'; + return !!entry.data?.title && + entry.data.title.trim() !== '' && + entry.data.title.trim() !== 'Untitled'; }; export const hasBody: CollectionFilter = (entry) => { @@ -185,7 +187,7 @@ export const hasBody: CollectionFilter = (entry) => { export const hasValidFileExtension: CollectionFilter = (entry) => { // Check if the entry has a valid markdown/mdx file extension if (!entry.filePath) return true; // If no filePath, assume it's valid - + const validExtensions = ['.md', '.mdx']; return validExtensions.some(ext => entry.filePath.endsWith(ext)); }; @@ -202,15 +204,15 @@ export const hasDescription: CollectionFilter = (entry) => { export const hasAuthor: CollectionFilter = (entry) => { // Check if entry has an author (and it's not the default "Unknown") - return !!entry.data?.author && - entry.data.author.trim() !== '' && - entry.data.author !== 'Unknown'; + return !!entry.data?.author && + entry.data.author.trim() !== '' && + entry.data.author !== 'Unknown'; }; export const hasPubDate: CollectionFilter = (entry) => { // Check if entry has a valid publication date if (!entry.data?.pubDate) return false; - + try { const date = new Date(entry.data.pubDate); return !isNaN(date.getTime()); @@ -278,7 +280,7 @@ export function combineFilters( export function createRequiredFieldsFilter(requiredFields: string[]): CollectionFilter { return (entry) => { if (!entry.data) return false; - return requiredFields.every(field => + return requiredFields.every(field => entry.data[field] !== undefined && entry.data[field] !== null && entry.data[field] !== '' ); }; @@ -294,7 +296,7 @@ export function createTagFilter(requiredTags: string[], matchAll: boole return (entry) => { const entryTags = entry.data?.tags || []; if (!Array.isArray(entryTags)) return false; - + if (matchAll) { return requiredTags.every(tag => entryTags.includes(tag)); } else { @@ -313,12 +315,12 @@ export function createDateFilter(beforeDate?: Date, afterDate?: Date): return (entry) => { const pubDate = entry.data?.pubDate; if (!pubDate) return false; - + const entryDate = new Date(pubDate); - + if (beforeDate && entryDate > beforeDate) return false; if (afterDate && entryDate < afterDate) return false; - + return true; }; } @@ -332,7 +334,7 @@ export function createExcludeTagsFilter(excludeTags: string[]): Collect return (entry) => { const entryTags = entry.data?.tags || []; if (!Array.isArray(entryTags)) return true; - + return !excludeTags.some(tag => entryTags.includes(tag)); }; } @@ -343,10 +345,10 @@ export function createExcludeTagsFilter(excludeTags: string[]): Collect export const isNotFuture: CollectionFilter = (entry) => { const pubDate = entry.data?.pubDate; if (!pubDate) return true; // If no date, include it - + const entryDate = new Date(pubDate); const now = new Date(); - + return entryDate <= now; }; @@ -359,11 +361,11 @@ export function createOldPostFilter(cutoffDays: number): CollectionFilt return (entry) => { const pubDate = entry.data?.pubDate; if (!pubDate) return true; // If no date, include it - + const entryDate = new Date(pubDate); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - cutoffDays); - + return entryDate > cutoffDate; }; } @@ -375,78 +377,78 @@ export function createOldPostFilter(cutoffDays: number): CollectionFilt */ export function buildFiltersFromConfig(config: CollectionFilterConfig): CollectionFilter[] { const filters: CollectionFilter[] = []; - + // Add default filters based on config if (config.ENABLE_VALID_FRONTMATTER_CHECK !== false) { filters.push(hasValidFrontMatter); } - + if (config.ENABLE_FOLDER_FILTER !== false) { filters.push(isNotFolder); } - + if (config.ENABLE_DRAFT_FILTER !== false) { filters.push(isNotDraft); } - + if (config.ENABLE_TITLE_FILTER) { filters.push(hasTitle); } - + // Add content validation filters if (config.ENABLE_BODY_FILTER) { filters.push(hasBody); } - + if (config.ENABLE_DESCRIPTION_FILTER) { filters.push(hasDescription); } - + if (config.ENABLE_IMAGE_FILTER) { filters.push(hasImage); } - + if (config.ENABLE_AUTHOR_FILTER) { filters.push(hasAuthor); } - + if (config.ENABLE_PUBDATE_FILTER) { filters.push(hasPubDate); } - + if (config.ENABLE_TAGS_FILTER) { filters.push(hasTags); } - + if (config.ENABLE_FILE_EXTENSION_FILTER !== false) { filters.push(hasValidFileExtension); } - + // Add required fields filter if (config.REQUIRED_FIELDS && config.REQUIRED_FIELDS.length > 0) { filters.push(createRequiredFieldsFilter(config.REQUIRED_FIELDS)); } - + // Add required tags filter if (config.REQUIRED_TAGS && config.REQUIRED_TAGS.length > 0) { filters.push(createTagFilter(config.REQUIRED_TAGS, true)); // matchAll = true } - + // Add exclude tags filter if (config.EXCLUDE_TAGS && config.EXCLUDE_TAGS.length > 0) { filters.push(createExcludeTagsFilter(config.EXCLUDE_TAGS)); } - + // Add future posts filter if (config.FILTER_FUTURE_POSTS) { filters.push(isNotFuture); } - + // Add old posts filter if (config.FILTER_OLD_POSTS && config.OLD_POST_CUTOFF_DAYS) { filters.push(createOldPostFilter(config.OLD_POST_CUTOFF_DAYS)); } - + return filters; } @@ -465,3 +467,127 @@ export function filterCollectionWithConfig( const filters = buildFiltersFromConfig(config); return filterCollection(collection, filters, astroConfig); } + +// Utility functions +export async function generateBreadcrumbs( + folderPath: string | undefined, + locale: string, + collectionName: string +): Promise> { + const collectionLabel = await translate(collectionName, I18N_SOURCE_LANGUAGE, locale); + const breadcrumbs = [ + { + label: collectionLabel, + href: `/${locale}/${collectionName}/`, + isLast: !folderPath + } + ] as Array<{ label: string; href: string | undefined; isLast: boolean }>; + + if (folderPath) { + const segments = folderPath.split('/').filter(segment => segment !== ''); + + segments.forEach((segment, index, arr) => { + const isLast = index === arr.length - 1; + const segmentPath = arr.slice(0, index + 1).join('/'); + const label = segment + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + breadcrumbs.push({ + label, + href: isLast ? undefined : `/${locale}/${collectionName}/${segmentPath}/`, + isLast + }); + }); + } + + return breadcrumbs; +} + +export async function calculateReadingTime( + entry: any, + rendered: any, + locale: string +): Promise { + // Try multiple possible locations for the reading time + const readingTime = rendered.remarkPluginFrontmatter?.minutesRead || + rendered.remarkPluginFrontmatter?.data?.minutesRead || + rendered.remarkPluginFrontmatter?.data?.astro?.frontmatter?.minutesRead; + + // Get raw minutes for translation + const rawMinutes = rendered.remarkPluginFrontmatter?.rawMinutes || + rendered.remarkPluginFrontmatter?.data?.rawMinutes || + rendered.remarkPluginFrontmatter?.data?.astro?.frontmatter?.rawMinutes; + + if (rawMinutes) { + // Use raw minutes to create translated reading time + return await translate(`${rawMinutes} min read`, I18N_SOURCE_LANGUAGE, locale); + } else if (readingTime) { + // Fallback to the English text from plugin + return readingTime; + } else { + // Fallback: If no reading time from plugin, calculate it manually + const textContent = entry.body || ''; + const wordCount = textContent.split(/\s+/).length; + const estimatedMinutes = Math.ceil(wordCount / 200); // Rough estimate: 200 words per minute + return await translate(`${estimatedMinutes} min read`, I18N_SOURCE_LANGUAGE, locale); + } +} + +export async function getStaticPaths_fs(get, collectionName: string, languages: string[] = ['en'], filters: CollectionFilterConfig = {}) { + const allResourceEntries = await get(collectionName); + const resourceEntries = filterCollectionWithConfig(allResourceEntries, filters); + const paths: any[] = []; + languages.forEach((lang) => { + resourceEntries.forEach((entry) => { + paths.push({ + params: { + locale: lang, + slug: entry.id, + }, + props: { + entry, + locale: lang, + type: 'article', + // Pass the crucial entryPath for image resolution + entryPath: `${collectionName}/${entry.id}`, + } + }); + }); + + // Add folder paths + const folders = new Set(); + resourceEntries.forEach(post => { + const segments = post.id.split('/').filter(segment => segment !== ''); + if (segments.length > 1) { + // Add all possible folder paths + for (let i = 1; i < segments.length; i++) { + const folderPath = segments.slice(0, i).join('/'); + folders.add(folderPath); + } + } + }); + + // Add folder paths with trailing slash + Array.from(folders).forEach(folder => { + paths.push({ + params: { + locale: lang, + slug: folder + '/', + }, + props: { + folderPath: folder, + posts: resourceEntries.filter(post => { + const postFolder = post.id.split('/').slice(0, -1).join('/'); + return postFolder === folder; + }), + locale: lang, + type: 'folder' + } + }); + }); + }); + + return paths; +} diff --git a/packages/polymech/src/components/ArticleStructuredData.astro b/packages/polymech/src/components/ArticleStructuredData.astro index f7adcce..ee0cfea 100644 --- a/packages/polymech/src/components/ArticleStructuredData.astro +++ b/packages/polymech/src/components/ArticleStructuredData.astro @@ -1,6 +1,6 @@ --- import config from "@/app/config.json" -import { get } from "@/model/registry.js" +import { get } from "../model/registry.js" import { default_image } from "@/app/config.js" const { frontmatter } = Astro.props @@ -13,6 +13,7 @@ const itemData = frontmatter ? await get("json-ld", {} as any, frontmatter, url: pageUrl.href, }) : null + const meta = config.metadata || { } let data = itemData || { diff --git a/packages/polymech/src/components/BaseHead.astro b/packages/polymech/src/components/BaseHead.astro new file mode 100644 index 0000000..a6c6432 --- /dev/null +++ b/packages/polymech/src/components/BaseHead.astro @@ -0,0 +1,182 @@ +--- +import "../styles/flowbite.css" + +import "../styles/global.css" +import "../styles/custom.scss" + +import { I18N_SOURCE_LANGUAGE } from "config/config.js" +import { translate } from '@polymech/astro-base/base/i18n.js' +import { item_defaults } from '@/base/index.js' + +import { LANGUAGES_PROD } from "config/config.js" +import config from "config/config.json" +import { plainify } from "@polymech/astro-base/base/strings.js" + +import { sync as read } from '@polymech/fs/read' + +import { AstroSeo } from "@astrolib/seo" + +import StructuredData from '@polymech/astro-base/components/ArticleStructuredData.astro' +import Hreflang from '@polymech/astro-base/components/hreflang.astro' + +export interface Props { + title?: string; + meta_title?: string; + description?: string; + image?: string; + noindex?: boolean; + canonical?: string; + view: string; + path: string; + frontmatter?: { + title?: string; + description?: string; + keywords?: string[]; + images?: { url: string; alt?: string }[]; + }; +} +const env = import.meta.env +const { frontmatter, view, path } = Astro.props +const { url } = Astro.request +const REDIRECT = false //import.meta.env.I18N_REDIRECT +const _url = Astro.url + +const canonicalUrl = _url.origin +const canonicalURL = new URL(Astro.url.pathname, Astro.site) + +const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE + +const hreflangs = LANGUAGES_PROD.filter((lang)=>lang!==Astro.currentLocale).map((lang) => ({ + lang, + url: `${canonicalUrl}/${lang}/${view}/${path}`, +})) + +const image = frontmatter?.image || config.site.image +const image_url = image.url +const image_alt = image.alt + +const title = frontmatter?.title || config.site.title +const description = frontmatter?.description || config.metadata.description + +let system_keywords = "" +const item_config = frontmatter as any || {} +if(item_config.PRODUCT_ROOT){ + const defaultsJson = await item_defaults(item_config.PRODUCT_ROOT); + let defaults:Record = read(defaultsJson, 'json') || {} + const defaultsKeywords = (defaults.keywords || "").split(',').map(k => k.trim()).filter(Boolean) + const configKeywords = (config.metadata.keywords || "").split(',').map(k => k.trim()).filter(Boolean) + const itemKeywords = (item_config.keywords || "").split(',').map(k => k.trim()).filter(Boolean) + const allKeywords = Array.from(new Set([ + ...defaultsKeywords, + ...configKeywords, + ...itemKeywords + ])).join(','); + system_keywords = await translate(allKeywords, I18N_SOURCE_LANGUAGE, locale) +} +const keywords = [ ...new Set([item_config.name, ...system_keywords.split(',')])].join(',') +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{ REDIRECT && +} + diff --git a/packages/polymech/src/components/Breadcrumb.astro b/packages/polymech/src/components/Breadcrumb.astro index 9f0ed70..8db634b 100644 --- a/packages/polymech/src/components/Breadcrumb.astro +++ b/packages/polymech/src/components/Breadcrumb.astro @@ -106,7 +106,7 @@ const breadcrumbs = generateBreadcrumbs(currentPath, collection, title); } .breadcrumb-link { - color: #6b7280; + text-decoration: none; font-size: 0.875rem; padding: 0.25rem 0.5rem; @@ -126,7 +126,7 @@ const breadcrumbs = generateBreadcrumbs(currentPath, collection, title); } .breadcrumb-current { - color: #111827; + font-weight: 500; font-size: 0.875rem; padding: 0.25rem 0.5rem; diff --git a/packages/polymech/src/components/GalleryK.astro b/packages/polymech/src/components/GalleryK.astro index 05020e9..03a0799 100644 --- a/packages/polymech/src/components/GalleryK.astro +++ b/packages/polymech/src/components/GalleryK.astro @@ -102,7 +102,7 @@ const locale = Astro.currentLocale || "en"; class="product-gallery">
@@ -136,14 +136,14 @@ const locale = Astro.currentLocale || "en";
)} -
-
+
+
{images.map((image, index) => ( + + diff --git a/packages/polymech/src/components/resources/ResourceCard.astro b/packages/polymech/src/components/resources/ResourceCard.astro new file mode 100644 index 0000000..20eeda5 --- /dev/null +++ b/packages/polymech/src/components/resources/ResourceCard.astro @@ -0,0 +1,269 @@ +--- +/** + * ResourceCard Component + * + * A flexible card component for displaying content from any collection. + * + * Usage examples: + * + * // For resources collection (default) + * + * + * // For store collection + * + * + * // For helpcenter collection + * + */ +import { Img } from "imagetools/components"; +import { translate } from "@polymech/astro-base/base/i18n.js"; +import { I18N_SOURCE_LANGUAGE } from "config/config.js"; +import { resolveImagePath } from '@/utils/path-resolution'; + +export interface Props { + title: string; + url: string; + author: string; + pubDate: string; + description: string; + image?: string; + alt?: string; + tags?: string[]; + path?: string; // File path for breadcrumb display + locale?: string; // Locale for i18n support + contentId?: string; // Content collection ID for resolving relative images + collectionName?: string; // Collection name for dynamic routing (e.g., 'resources', 'store', 'helpcenter'). Defaults to 'resources' for backward compatibility. +} + +const { title, url, author, pubDate, description, image, alt, tags = [], path, locale, contentId, collectionName = 'resources' } = Astro.props; + +// Translate title, description, and author +const currentLocale = locale || Astro.currentLocale; +const translatedTitle = await translate(title, I18N_SOURCE_LANGUAGE, currentLocale); +const translatedDescription = await translate(description, I18N_SOURCE_LANGUAGE, currentLocale); +const translatedAuthor = await translate(author, I18N_SOURCE_LANGUAGE, currentLocale); + +// Default image fallback +const defaultImage = "https://picsum.photos/640/360"; + +// Construct entryPath for the resolver, e.g., "resources/cassandra/home" or "store/product" +const entryPath = contentId ? `${collectionName}/${contentId}` : undefined; + +// Use the centralized, robust path resolver +const resolvedImage = image ? resolveImagePath(image, entryPath, Astro.url) : defaultImage; + +const displayImage = resolvedImage; +const displayAlt = alt || translatedTitle; +const isDefaultImage = !image; + +// Check if image is external URL (imagetools can't process external URLs at build time) +const isExternalImage = displayImage.startsWith('http://') || displayImage.startsWith('https://'); +const useImagetools = !isExternalImage; + +// Generate breadcrumb from path +function generateBreadcrumb(filePath?: string) { + if (!filePath) return null; + + const segments = filePath.split('/').filter(segment => segment !== ''); + if (segments.length <= 1) return null; // No breadcrumb for root level files + + // Remove the filename and file extension from the last segment + const pathSegments = segments.slice(0, -1); + + return pathSegments.map(segment => + segment + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + ).join(' / '); +} + +const breadcrumb = generateBreadcrumb(path); + +// Check if we're currently viewing the parent folder of this card +// If so, don't show the breadcrumb as it's redundant +const currentPath = Astro.url.pathname; +const isInParentFolder = path && currentPath.includes(path.split('/').slice(0, -1).join('/')); + +// The URL prop already points to the correct article page. +const cardUrl = url; + +// Generate breadcrumb URL for the parent folder +const breadcrumbUrl = locale + ? `/${locale}/${collectionName}/${path?.split('/').slice(0, -1).join('/') || ''}/` + : `/${collectionName}/${path?.split('/').slice(0, -1).join('/') || ''}/`; +--- + + + + diff --git a/packages/polymech/src/components/sidebar/SidebarGroup.astro b/packages/polymech/src/components/sidebar/SidebarGroup.astro index f2e899a..18a96d6 100644 --- a/packages/polymech/src/components/sidebar/SidebarGroup.astro +++ b/packages/polymech/src/components/sidebar/SidebarGroup.astro @@ -70,8 +70,6 @@ const { group, isNested = false } = Astro.props; padding: 0.5rem 0.75rem; font-size: 0.875rem; font-weight: 500; - color: #374151; - background-color: #f9fafb; border-radius: 0.375rem; cursor: pointer; user-select: none; @@ -79,8 +77,7 @@ const { group, isNested = false } = Astro.props; } .sidebar-subgroup-title:hover { - background-color: #f3f4f6; - color: #111827; + /* Hover styles handled by global.css */ } .sidebar-subgroup-title .caret { @@ -96,7 +93,6 @@ const { group, isNested = false } = Astro.props; margin-top: 0.5rem; margin-left: 0.75rem; padding-left: 0.75rem; - border-left: 2px solid #e5e7eb; } .sidebar-subgroup-content .sidebar-links { @@ -114,27 +110,17 @@ const { group, isNested = false } = Astro.props; /* Page-level navigation styling */ .page-level { - border-bottom: 2px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } .page-level .sidebar-group-title { - color: #3b82f6; /* blue-500 */ font-size: 0.8rem; - border-bottom: 1px solid #dbeafe; /* blue-100 */ } .page-level .sidebar-link { - color: #1e40af; /* blue-800 */ font-weight: 500; - background-color: #f0f9ff; /* blue-50 */ border-radius: 0.375rem; margin-bottom: 0.25rem; } - - .page-level .sidebar-link:hover { - background-color: #dbeafe; /* blue-100 */ - color: #1d4ed8; /* blue-700 */ - } diff --git a/packages/polymech/src/components/sidebar/TableOfContents.astro b/packages/polymech/src/components/sidebar/TableOfContents.astro index 91da0c2..3f74487 100644 --- a/packages/polymech/src/components/sidebar/TableOfContents.astro +++ b/packages/polymech/src/components/sidebar/TableOfContents.astro @@ -13,7 +13,7 @@ interface Props { const { headings, - title = "toc.on-this-page", + title = "", minHeadingLevel = 2, maxHeadingLevel = 4 } = Astro.props; diff --git a/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro b/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro index 604e279..0d9fa7e 100644 --- a/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro +++ b/packages/polymech/src/components/sidebar/TableOfContentsWithScroll.astro @@ -14,7 +14,7 @@ interface Props { const { headings, - title = "toc.on-this-page", + title = "", minHeadingLevel = 2, maxHeadingLevel = 4 } = Astro.props; diff --git a/packages/polymech/src/layouts/BaseLayout.astro b/packages/polymech/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..06c48ef --- /dev/null +++ b/packages/polymech/src/layouts/BaseLayout.astro @@ -0,0 +1,19 @@ +--- +import BaseHead from "../components/BaseHead.astro"; +import Navigation from "../components/global/Navigation.astro"; +import Footer from "../components/global/Footer.astro"; +import { isRTL } from "config/config.js" + +const { frontmatter: frontmatter, ...rest } = Astro.props; + +--- + + + + + + +
+