refactor pm base | config

This commit is contained in:
babayaga 2025-12-28 09:32:41 +01:00
parent 940be340fa
commit 1793db09a0
49 changed files with 1552 additions and 886 deletions

View File

@ -1,12 +1,12 @@
export default {
"environment": "dev",
"environment": "build",
"isSsrBuild": false,
"projectBase": "",
"publicDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\public\\",
"rootDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\",
"mode": "dev",
"outDir": "dist",
"assetsDir": "/_astro",
"mode": "production",
"outDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\dist\\",
"assetsDir": "_astro",
"sourcemap": false,
"assetFileNames": "/_astro/[name]@[width].[hash][extname]"
}

View File

@ -46,10 +46,8 @@ const configFile = findUpSync([
"astro-imagetools.config.js",
"astro-imagetools.config.mjs",
]);
const configFunction = configFile
? await import(configFile).catch(async () => await import("/" + configFile))
: null;
/* @vite-ignore */
const configFunction = configFile ? await import(configFile).catch(async () => await import("/" + configFile)) : null;
const rawGlobalConfigOptions = configFunction?.default ?? {};
@ -93,7 +91,7 @@ const cache_dir = () => {
if (!exists(dir)) {
mkdir(dir);
}
}else{
} else {
dir = GlobalConfigOptions.cacheRoot
}
return dir + "/";

View File

@ -7,6 +7,7 @@ import { hideBin } from 'yargs/helpers';
import { substitute } from "@polymech/commons/variables";
import { appConfigSchema } from "./config.schema.js";
import type { AppConfig } from "./config.schema.js";
import { z } from "astro/zod";
const I18N_SOURCE_LANGUAGE = 'en';
@ -40,14 +41,15 @@ function deepMerge(target: any, source: any): any {
export function loadConfig(
locale: string = I18N_SOURCE_LANGUAGE,
libraryPath: string = LIBRARY_CONFIG_PATH
config: string = LIBRARY_CONFIG_PATH,
schema: z.ZodType<any> = appConfigSchema,
): AppConfig {
// 1. Load Library Config (Defaults)
let rawLibraryContent: string;
try {
rawLibraryContent = fs.readFileSync(libraryPath, 'utf-8');
rawLibraryContent = fs.readFileSync(config, 'utf-8');
} catch (error) {
throw new Error(`Failed to read library config file at ${libraryPath}: ${error}`);
throw new Error(`Failed to read library config file at ${config}: ${error}`);
}
const variables = {
@ -61,7 +63,6 @@ export function loadConfig(
} catch (error) {
throw new Error(`Failed to parse library config JSON: ${error}`);
}
// 2. Parse CLI Arguments
// We assume the caller might want to pass args, or we just grab process.argv
// We cast to any because yargs returns a complex type
@ -69,7 +70,7 @@ export function loadConfig(
// 3. Determine User Config Path
// Check for --config <path>
const userConfigPath = argv.config ? path.resolve(argv.config) : USER_CONFIG_DEFAULT_PATH;
const userConfigPath = argv.config ? path.resolve(argv.config) : LIBRARY_CONFIG_PATH;
// 4. Load User Config (if exists)
let userConfig: any = {};
@ -81,8 +82,11 @@ export function loadConfig(
} catch (error) {
console.warn(`Failed to load or parse user config at ${userConfigPath}: ${error}`);
}
} else {
console.log('User config not found at', userConfigPath);
}
// 5. Merge: Library <- User <- CLI
// Note: yargs parses --config as part of argv, but also other flags like --core.logging_namespace
// We filter out specific known CLI-only flags if needed, but config schema validation will drop unknown keys anyway?
@ -92,17 +96,11 @@ export function loadConfig(
// CLI args often come with standard keys like '$0', '_' which we might want to exclude if we blindly merge.
// However, deepMerge will add them.
// Ideally we would only merge keys that exist in the schema, but dynamic is fine for now.
let mergedConfig = deepMerge(libraryConfig, userConfig);
mergedConfig = deepMerge(mergedConfig, argv);
// 6. Validate
const result = appConfigSchema.safeParse(mergedConfig);
// @todo 6. Validate
// const result = schema.parse(mergedConfig);
if (!result.success) {
// Pretty print error if possible or just message
throw new Error(`Config validation failed: ${result.error.message}`);
}
return result.data;
return mergedConfig;
}

View File

@ -62,6 +62,7 @@ export const devSchema = z.object({
export const i18NSchema = z.object({
store: z.string(),
cache: z.boolean(),
source_language: z.string(),
asset_path: z.string()
});

View File

@ -6,6 +6,7 @@ import { sanitizeUri } from 'micromark-util-sanitize-uri'
import { loadConfig } from './config-loader.js'
export const I18N_SOURCE_LANGUAGE = 'en'
// Load config
const config = loadConfig(I18N_SOURCE_LANGUAGE)

View File

@ -1,5 +1,5 @@
import { translate } from '../base/i18n.js'
import { I18N_SOURCE_LANGUAGE } from 'config/config.js'
import { I18N_SOURCE_LANGUAGE } from '../app/config.js'
import { loadConfig } from './config-loader.js'
import pMap from 'p-map'

View File

@ -2,7 +2,7 @@ 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";
import { I18N_SOURCE_LANGUAGE } from "../app/config.js";
// Filter function type
export type CollectionFilter<T = any> = (entry: CollectionEntry<T>, astroConfig?: any) => boolean;

View File

@ -3,15 +3,22 @@ import { resolve } from '@polymech/commons'
import { sync as exists } from '@polymech/fs/exists'
import type { IOptions } from '@polymech/i18n'
import { CONFIG_DEFAULT } from '@polymech/commons'
import { I18N_ASSET_PATH, I18N_CACHE, I18N_SOURCE_LANGUAGE, PRODUCT_SPECS, RETAIL_LOG_LEVEL_I18N_PRODUCT_ASSETS } from '@/app/config.js'
export type { IOptions } from '@polymech/i18n'
import { CONFIG_DEFAULT } from '@polymech/commons'
import { translateXLS } from '@polymech/i18n/translate_xls'
import { I18N_STORE, OSR_ROOT } from 'config/config.js'
import { translateText } from '@polymech/i18n/translate_text'
import { PolymechInstance } from '../registry.js';
import { AppConfig } from "../app/config.schema.js"
import { I18N_STORE, OSR_ROOT } from '../app/config.js'
import { I18N_ASSET_PATH, I18N_CACHE, PRODUCT_SPECS, RETAIL_LOG_LEVEL_I18N_PRODUCT_ASSETS } from '../app/config.js'
import { logger } from './index.js'
export type { IOptions } from '@polymech/i18n'
const loadConfig = (): AppConfig => PolymechInstance.getConfig();
export const translate = async (text: string, srcLanguage = 'en', targetLanguage, opts = {}) => {
if (!targetLanguage) {
@ -20,7 +27,7 @@ export const translate = async (text: string, srcLanguage = 'en', targetLanguage
try {
const store = I18N_STORE(OSR_ROOT(), targetLanguage)
let translation = text
translation = await translateText(text, srcLanguage, targetLanguage, {
translation = await translateText(text, srcLanguage, targetLanguage, {
store,
...opts
})
@ -31,19 +38,20 @@ export const translate = async (text: string, srcLanguage = 'en', targetLanguage
return text
}
export const translateSheets = async (product, language) => {
const config: any = CONFIG_DEFAULT()
if (language === I18N_SOURCE_LANGUAGE) {
const pm_config: any = CONFIG_DEFAULT()
const config = loadConfig()
if (language === config.i18n.source_language) {
return
}
const i18nOptions: IOptions = {
srcLang: I18N_SOURCE_LANGUAGE,
srcLang: config.i18n.source_language,
dstLang: language,
src: PRODUCT_SPECS(product),
store: I18N_STORE(OSR_ROOT(), language),
dst: I18N_ASSET_PATH,
query: "$[*][0,1,2,3]",
cache: I18N_CACHE,
api_key: config.deepl.auth_key,
api_key: pm_config.deepl.auth_key,
logLevel: RETAIL_LOG_LEVEL_I18N_PRODUCT_ASSETS
}
const src = `${PRODUCT_SPECS(product)}`

View File

@ -9,17 +9,11 @@ import { findUp } from 'find-up'
import { createLogger } from '@polymech/log'
import { parse, IProfile } from '@polymech/commons/profile'
import { translate } from "@/base/i18n.js"
// import { renderMarkup } from "../model/component.js"
import {
LOGGING_NAMESPACE,
OSRL_ENV,
OSRL_PRODUCT_PROFILE,
PRODUCT_ROOT,
I18N_SOURCE_LANGUAGE
} from 'config/config.js'
PRODUCT_ROOT
} from '../app/config.js'
export const logger = createLogger('polymech-astro')

View File

@ -14,9 +14,9 @@ import { files } from '@polymech/commons'
import { sync as exists } from '@polymech/fs/exists'
import { sync as read } from '@polymech/fs/read'
import { logger } from '@/base/index.js'
import { logger } from './index.js'
import { removeArrayValues, removeArrays, removeBufferValues, removeEmptyObjects } from '@/base/objects.js'
import { removeArrayValues, removeArrays, removeBufferValues, removeEmptyObjects } from './objects.js'
import { ITEM_ASSET_URL, PRODUCT_CONFIG, PRODUCT_ROOT, DEFAULT_IMAGE_URL, FILE_SERVER_DEV } from '../app/config.js'
import { GalleryImage, MetaJSON } from './images.js'

View File

@ -2,5 +2,6 @@
import { cli } from './cli.js';
import './commands/build-config.js';
import './commands/build.js';
import './commands/dev.js';
cli.parse();

View File

@ -18,7 +18,7 @@ export const builder = (yargs: CLI.Argv) => {
alias: 'd',
type: 'string',
describe: 'Output d.ts path',
default: './src/app/config.d.ts'
default: './src/app/config-types.ts'
})
.option('dest-schema', {
alias: 'z',

View File

@ -0,0 +1,35 @@
import { spawnSync } from 'child_process';
import * as CLI from 'yargs';
import { cli } from '../cli.js';
import { handler as configHandler, builder as configBuilder } from './build-config.js';
export const command = 'dev [src]';
export const desc = 'Generate config and start Astro dev server';
export const builder = (yargs: CLI.Argv) => {
return configBuilder(yargs)
.strict(false);
};
export async function handler(argv: CLI.Arguments) {
// 1. Config Generation
await configHandler(argv);
// 2. Astro Dev
const devIndex = process.argv.indexOf('dev');
const forwardedArgs = devIndex !== -1 ? process.argv.slice(devIndex + 1) : [];
console.log('[pm-astro] Running astro dev...');
// Execute astro dev
// Standard dev args like --host can be passed by user
const result = spawnSync('npx', ['astro', 'dev', ...forwardedArgs], {
stdio: 'inherit',
shell: true
});
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
cli.command(command, desc, builder, handler);

View File

@ -4,18 +4,17 @@ import "../styles/flowbite.css"
import "../styles/global.css"
import "../styles/custom.scss"
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { sync as read } from '@polymech/fs/read'
import { AstroSeo } from "@astrolib/seo"
import { I18N_SOURCE_LANGUAGE } from "../app/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 { LANGUAGES_PROD } from "../app/config.js"
import config from "../app/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'

View File

@ -1,9 +1,10 @@
---
import { Img } from "imagetools/components";
import Translate from "./i18n.astro"
import { translate } from "@/base/i18n";
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js"
import Translate from "./i18n.astro"
import { translate } from "../base/i18n";
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "../app/config.js"
interface Image {
alt: string

View File

@ -1,10 +1,11 @@
---
import { Img } from "imagetools/components";
import { globBase, pathInfo } from "@polymech/commons";
import Translate from "./i18n.astro";
import { IMAGE_SETTINGS } from "config/config.js";
import { IMAGE_SETTINGS } from "../app/config.js";
import path from "node:path";
import { glob } from 'glob';
import { globBase, pathInfo } from "@polymech/commons";
import { extractImageMetadata, groupByYear, groupByMonth, GroupInfo } from "../base/media";
import { resolveImagePath } from "../utils/path-resolution.js";

View File

@ -2,7 +2,7 @@
import Wrapper from "@/components/containers/Wrapper.astro";
import { footer_left, footer_right } from "../../app/navigation.js";
import { ISO_LANGUAGE_LABELS } from "@polymech/i18n";
import { LANGUAGES_PROD, I18N_SOURCE_LANGUAGE } from "config/config.js";
import { LANGUAGES_PROD, I18N_SOURCE_LANGUAGE } from "../../app/config.js";
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE;
const currentUrl = new URL(Astro.url);

View File

@ -1,13 +1,12 @@
---
import Wrapper from "@/components/containers/Wrapper.astro";
import { I18N_SOURCE_LANGUAGE } from "@/app/config";
import { items } from "../../app/navigation.js";
import ThemeToggle from "./ThemeToggle.astro";
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE;
import { PolymechInstance } from "../../registry.js";
import { AppConfig } from "@/app/config.schema.js";
const config = (): AppConfig => PolymechInstance.getConfig();
const locale = Astro.currentLocale || config().i18n.source_language;
const navItems = await items({ locale });
---

View File

@ -1,21 +1,23 @@
---
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { translate, IOptions } from '@/base/i18n.js'
import { I18N_SOURCE_LANGUAGE } from "../app/config.js";
import { translate, IOptions } from "../base/i18n.js";
export interface Props extends IOptions {
language?: string,
clazz?:string
export interface Props extends IOptions {
language?: string;
clazz?: string;
}
const {
language = Astro.currentLocale,
clazz = '',
...rest
} = Astro.props
const { language = Astro.currentLocale, clazz = "", ...rest } = Astro.props;
const content = await Astro.slots.render('default')
const translatedText = await translate(content, I18N_SOURCE_LANGUAGE, language, rest)
const content = await Astro.slots.render("default");
const translatedText = await translate(
content,
I18N_SOURCE_LANGUAGE,
language,
rest,
);
---
<p data-widget="polymech.i18n" class={clazz}>
{translatedText}
{translatedText}
</p>

View File

@ -1,43 +1,44 @@
---
import { getProcessedCoilsData } from '../../utils/modbus-data';
import { getModbusFunctionName, getFunctionCategory } from '../../utils/modbusUtils';
import { getProcessedCoilsData } from "../../utils/modbus-data";
const groupedCoils = getProcessedCoilsData();
const groupNames = Object.keys(groupedCoils).sort();
---
<div class="modbus-coils-tables">
{groupNames.map(groupName => (
<div class="group-section">
<h3>{groupName}</h3>
<div class="table-container">
<table class="modbus-table">
<thead>
<tr>
<th>FC</th>
<th>Address</th>
<th>Name</th>
<th>Component</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{groupedCoils[groupName].map(coil => {
return (
<tr>
<td class="fc-cell">1/5</td>
<td>{coil.address}</td>
<td>{coil.name}</td>
<td>{coil.component}</td>
<td>{coil.id}</td>
</tr>
);
})}
</tbody>
</table>
{
groupNames.map((groupName) => (
<div class="group-section">
<h3>{groupName}</h3>
<div class="table-container">
<table class="modbus-table">
<thead>
<tr>
<th>FC</th>
<th>Address</th>
<th>Name</th>
<th>Component</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{groupedCoils[groupName].map((coil) => {
return (
<tr>
<td class="fc-cell">1/5</td>
<td>{coil.address}</td>
<td>{coil.name}</td>
<td>{coil.component}</td>
<td>{coil.id}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
))}
))
}
</div>
<style>
@ -108,7 +109,7 @@ const groupNames = Object.keys(groupedCoils).sort();
.modbus-table {
font-size: 0.75rem;
}
.modbus-table th,
.modbus-table td {
padding: 0.5rem;

View File

@ -2,9 +2,9 @@
import {
getProcessedRegistersData,
getRegisterDescription,
} from "@polymech/astro-base/utils/modbus-data.js";
} from "../../utils/modbus-data";
import { parseRegisterName } from "@polymech/astro-base/utils/modbusUtils.js";
import { parseRegisterName } from "../../utils/modbusUtils";
const groupedRegisters = getProcessedRegistersData();
const groupNames = Object.keys(groupedRegisters).sort();

View File

@ -1,7 +1,7 @@
---
import { createMarkdownComponent } from "@/base/index.js";
import { translate } from "@/base/i18n.js";
import { I18N_SOURCE_LANGUAGE, ASSET_URL } from "config/config.js";
import { createMarkdownComponent } from "../../base/index.js";
import { translate } from "../../base/i18n.js";
import { I18N_SOURCE_LANGUAGE, ASSET_URL } from "../../app/config.js";
import { fromMarkdown } from "mdast-util-from-markdown";
import { toMarkdown } from "mdast-util-to-markdown";
import { visit } from "unist-util-visit";
@ -14,6 +14,7 @@ interface Props {
translate?: boolean;
data?: any;
}
const {
markdown,
className = "",

View File

@ -1,38 +1,9 @@
---
/**
* ResourceCard Component
*
* A flexible card component for displaying content from any collection.
*
* Usage examples:
*
* // For resources collection (default)
* <ResourceCard
* title="Article Title"
* url="/en/resources/article"
* // ... other props
* />
*
* // For store collection
* <ResourceCard
* title="Product Name"
* url="/en/store/product"
* collectionName="store"
* // ... other props
* />
*
* // For helpcenter collection
* <ResourceCard
* title="Help Article"
* url="/en/helpcenter/article"
* collectionName="helpcenter"
* // ... other props
* />
*/
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';
import { translate } from "../../base/i18n.js";
import { I18N_SOURCE_LANGUAGE } from "../../app/config.js";
import { resolveImagePath } from "../../utils/path-resolution.js";
export interface Props {
title: string;

View File

@ -1,11 +1,11 @@
---
import SidebarGroup from './SidebarGroup.astro';
import TableOfContentsWithScroll from './TableOfContentsWithScroll.astro';
import { processSidebarGroup, getCurrentPath } from './utils';
import type { SidebarGroup as SidebarGroupType } from './types';
import type { MarkdownHeading } from 'astro';
import SidebarPersister from './SidebarPersister.astro';
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import SidebarGroup from "./SidebarGroup.astro";
import TableOfContentsWithScroll from "./TableOfContentsWithScroll.astro";
import { processSidebarGroup, getCurrentPath } from "./utils";
import type { SidebarGroup as SidebarGroupType } from "./types";
import type { MarkdownHeading } from "astro";
import SidebarPersister from "./SidebarPersister.astro";
import { I18N_SOURCE_LANGUAGE } from "../../app/config.js";
interface Props {
config: SidebarGroupType[];
@ -15,12 +15,12 @@ interface Props {
}
const { config, currentUrl, headings = [], pageNavigation = [] } = Astro.props;
const currentPath = currentUrl ? getCurrentPath(currentUrl) : '';
const currentPath = currentUrl ? getCurrentPath(currentUrl) : "";
// Extract locale from URL path (e.g., /es/resources/ -> 'es')
let locale: string = I18N_SOURCE_LANGUAGE; // Default to source language
if (currentPath) {
const pathSegments = currentPath.split('/').filter(segment => segment);
const pathSegments = currentPath.split("/").filter((segment) => segment);
// Check if first segment is a language code (2 letters)
if (pathSegments.length > 0 && /^[a-z]{2}$/.test(pathSegments[0])) {
locale = pathSegments[0];
@ -29,32 +29,32 @@ if (currentPath) {
// Process all sidebar groups
const processedGroups = await Promise.all(
config.map(group => processSidebarGroup(group, currentPath, locale))
config.map((group) => processSidebarGroup(group, currentPath, locale)),
);
// Process page-level navigation
const processedPageNav = await Promise.all(
pageNavigation.map(group => processSidebarGroup({...group, isPageLevel: true}, currentPath, locale))
pageNavigation.map((group) =>
processSidebarGroup({ ...group, isPageLevel: true }, currentPath, locale),
),
);
---
<nav class="sidebar-nav" aria-label="Site navigation">
<div class="sidebar-content">
{/* Page-level navigation first */}
{processedPageNav.map((group) => (
<SidebarGroup group={group} />
))}
{processedPageNav.map((group) => <SidebarGroup group={group} />)}
{/* Global navigation */}
{processedGroups.map((group) => (
<SidebarGroup group={group} />
))}
{processedGroups.map((group) => <SidebarGroup group={group} />)}
{/* Table of contents */}
{headings && headings.length > 0 && (
<TableOfContentsWithScroll headings={headings} />
)}
{
headings && headings.length > 0 && (
<TableOfContentsWithScroll headings={headings} />
)
}
</div>
</nav>
<SidebarPersister />
<SidebarPersister />

View File

@ -2,7 +2,7 @@ import { getCollection } from 'astro:content';
import type { SidebarGroup, SidebarLink, SortFunction } from './types.js';
import { filterCollection, defaultFilters, type CollectionFilter } from '../../base/collections.js';
import { z } from 'zod';
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { I18N_SOURCE_LANGUAGE } from "../../app/config.js";
interface DirectoryStructure {
[key: string]: {
@ -42,7 +42,7 @@ export interface SidebarGenerationOptions {
*/
export function createSidebarOptions(options: Partial<SidebarGenerationOptions> = {}): SidebarGenerationOptions {
const parsed = SidebarGenerationOptionsSchema.parse(options);
// Runtime validation for functions
if (parsed.customSort && typeof parsed.customSort !== 'function') {
throw new Error('customSort must be a function');
@ -50,7 +50,7 @@ export function createSidebarOptions(options: Partial<SidebarGenerationOptions>
if (parsed.filters && (!Array.isArray(parsed.filters) || parsed.filters.some(f => typeof f !== 'function'))) {
throw new Error('filters must be an array of functions');
}
return parsed as SidebarGenerationOptions;
}
@ -59,8 +59,8 @@ export function createSidebarOptions(options: Partial<SidebarGenerationOptions>
* @deprecated Use generateLinksFromDirectoryWithConfig with options object instead
*/
export async function generateLinksFromDirectory(
directory: string,
currentPath?: string,
directory: string,
currentPath?: string,
maxDepth: number = 2,
currentDepth: number = 0
): Promise<(SidebarLink | SidebarGroup)[]> {
@ -107,19 +107,19 @@ export async function generateLinksFromDirectoryWithConfig(
try {
// Validate and apply defaults to options
const validatedOptions = SidebarGenerationOptionsSchema.parse(options);
const allEntries = await getCollection(directory as any);
const entries = filterCollection(allEntries, validatedOptions.filters || defaultFilters);
// Organize entries by directory structure
const structure = organizeByDirectory(entries);
return buildSidebarFromStructure(
structure,
directory,
validatedOptions.currentPath,
validatedOptions.maxDepth,
validatedOptions.currentDepth,
structure,
directory,
validatedOptions.currentPath,
validatedOptions.maxDepth,
validatedOptions.currentDepth,
validatedOptions.collapsedByDefault,
validatedOptions.sortBy,
validatedOptions.customSort,
@ -136,36 +136,36 @@ export async function generateLinksFromDirectoryWithConfig(
* Organize entries into a directory structure
*/
function organizeByDirectory(entries: any[]): DirectoryStructure {
const structure: DirectoryStructure = {
'': { files: [], subdirs: {} }
};
const structure: DirectoryStructure = {
'': { files: [], subdirs: {} }
};
entries.forEach(entry => {
const entryPath = entry.id;
const parts = entryPath.split('/');
parts.pop();
let parentSubdirs = structure[''].subdirs;
parts.forEach(part => {
if (!parentSubdirs[part]) {
parentSubdirs[part] = { files: [], subdirs: {} };
}
parentSubdirs = parentSubdirs[part].subdirs;
});
entries.forEach(entry => {
const entryPath = entry.id;
const parts = entryPath.split('/');
parts.pop();
const parentPath = parts.join('/');
let parentNode = structure[''];
if(parentPath){
const parentParts = parentPath.split('/');
for (const part of parentParts) {
parentNode = parentNode.subdirs[part];
}
}
parentNode.files.push(entry);
let parentSubdirs = structure[''].subdirs;
parts.forEach(part => {
if (!parentSubdirs[part]) {
parentSubdirs[part] = { files: [], subdirs: {} };
}
parentSubdirs = parentSubdirs[part].subdirs;
});
return structure;
const parentPath = parts.join('/');
let parentNode = structure[''];
if (parentPath) {
const parentParts = parentPath.split('/');
for (const part of parentParts) {
parentNode = parentNode.subdirs[part];
}
}
parentNode.files.push(entry);
});
return structure;
}
@ -190,19 +190,19 @@ function buildSidebarFromStructure(
const rootFiles = structure[''].files
.filter(entry => !isPageHidden(entry))
.map(entry => createSidebarLink(entry, baseDirectory, locale, currentPath));
// Add root files without sorting (will be sorted at the end)
items.push(...rootFiles);
}
// Process subdirectories if we haven't reached max depth
if (currentDepth < maxDepth) {
const subdirs = structure['']?.subdirs || {};
const subdirs = structure['']?.subdirs || {};
Object.entries(subdirs).forEach(([dirName, dirData]) => {
if (dirName === '') return; // Skip root files (already processed)
const subItems: (SidebarLink | SidebarGroup)[] = [];
// Add files in this subdirectory
if (dirData.files.length > 0) {
const subFiles = dirData.files
@ -210,15 +210,15 @@ function buildSidebarFromStructure(
.map(entry => createSidebarLink(entry, baseDirectory, locale, currentPath));
subItems.push(...subFiles);
}
// Recursively add nested subdirectories
if (Object.keys(dirData.subdirs).length > 0) {
const nestedStructure = {'': {files: [], subdirs: dirData.subdirs}}
const nestedStructure = { '': { files: [], subdirs: dirData.subdirs } }
const nestedItems = buildSidebarFromStructure(
nestedStructure,
baseDirectory,
currentPath,
maxDepth,
nestedStructure,
baseDirectory,
currentPath,
maxDepth,
currentDepth + 1,
collapsedByDefault,
sortBy,
@ -228,11 +228,11 @@ function buildSidebarFromStructure(
);
subItems.push(...nestedItems);
}
if (subItems.length > 0) {
// Sort the subItems before adding to the group
const sortedSubItems = applySorting(subItems, sortBy, customSort, entries);
items.push({
label: formatDirectoryName(dirName),
items: sortedSubItems,
@ -242,7 +242,7 @@ function buildSidebarFromStructure(
}
});
}
return applySorting(items, sortBy, customSort, entries);
}
@ -266,18 +266,18 @@ function createSidebarLink(entry: any, baseDirectory: string, locale: string, cu
} else if (entry.data.name) {
label = entry.data.name;
}
// Clean up label if it includes directory path
if (label.includes('/')) {
label = label.split('/').pop() || label;
}
// Remove file extension from entry.id for clean URLs
const cleanId = entry.id.replace(/\.(md|mdx)$/, '');
// Generate href with locale if provided
const href = locale ? `/${locale}/${baseDirectory}/${cleanId}/` : `/${baseDirectory}/${cleanId}/`;
return {
label,
href,
@ -308,7 +308,7 @@ function sortAlphabetically(a: SidebarLink | SidebarGroup, b: SidebarLink | Side
if (!isAFile && isBFile) {
return 1; // b (file) comes before a (folder)
}
// If both are files or both are folders, sort by label
return a.label.localeCompare(b.label);
}
@ -321,17 +321,17 @@ function sortByDate(a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup
if (!('href' in a) || !('href' in b)) {
return sortAlphabetically(a, b);
}
// Find corresponding entries for date comparison
const entryA = entries.find(entry => a.href?.includes(entry.id.replace(/\.(md|mdx)$/, '')));
const entryB = entries.find(entry => b.href?.includes(entry.id.replace(/\.(md|mdx)$/, '')));
if (entryA?.data?.pubDate && entryB?.data?.pubDate) {
const dateA = new Date(entryA.data.pubDate);
const dateB = new Date(entryB.data.pubDate);
return dateB.getTime() - dateA.getTime(); // Newest first
}
// Fallback to alphabetical if no dates
return sortAlphabetically(a, b);
}
@ -340,7 +340,7 @@ function sortByDate(a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup
* Apply sorting to sidebar items
*/
function applySorting(
items: (SidebarLink | SidebarGroup)[],
items: (SidebarLink | SidebarGroup)[],
sortBy: SortFunction = 'alphabetical',
customSort?: (a: SidebarLink | SidebarGroup, b: SidebarLink | SidebarGroup) => number,
entries?: any[]
@ -371,7 +371,7 @@ export async function processSidebarGroup(group: SidebarGroup, currentPath?: str
const maxDepth = group.autogenerate.maxDepth ?? 2;
const sortBy = group.autogenerate.sortBy ?? 'alphabetical';
const items = await generateLinksFromDirectoryWithConfig(
group.autogenerate.directory,
group.autogenerate.directory,
{
currentPath,
maxDepth,
@ -392,8 +392,8 @@ export async function processSidebarGroup(group: SidebarGroup, currentPath?: str
return {
...item,
href: item.slug ? `/${item.slug}/` : item.href,
isCurrent: currentPath ?
(item.slug ? currentPath.includes(`/${item.slug}`) : currentPath === item.href) :
isCurrent: currentPath ?
(item.slug ? currentPath.includes(`/${item.slug}`) : currentPath === item.href) :
false,
};
} else {

View File

@ -2,22 +2,21 @@
import * as path from "path";
import { sync as fileExists } from "@polymech/fs/exists";
import { specs } from "@/base/specs.js";
import { createComponent } from "astro/runtime/server/astro-component.js";
import { renderTemplate, unescapeHTML } from "astro/runtime/server/index.js";
import { logger } from "@/base/index.js";
import { translateSheets } from "@/base/i18n.js";
import {
I18N_SOURCE_LANGUAGE,
LANGUAGES,
PRODUCT_SPECS,
} from "config/config.js";
import { specs } from "../base/specs.js";
import { logger } from "../base/index.js";
import { translateSheets } from "../base/i18n.js";
import { PRODUCT_SPECS } from "../app/config.js";
import { loadConfig } from "../app/config-loader.js";
const config = loadConfig();
import DefaultComponent from "./Default.astro";
const { frontmatter: data } = Astro.props;
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE;
const locale = Astro.currentLocale || config.i18n.source_language;
const specs_table = async (relPath) => {
let specsPath = path.join(PRODUCT_SPECS(relPath));
@ -25,7 +24,10 @@ const specs_table = async (relPath) => {
logger.debug(`No specs found for ${specsPath}`);
return null;
}
if (locale !== I18N_SOURCE_LANGUAGE && LANGUAGES.includes(locale)) {
if (
locale !== config.i18n.source_language &&
config.core.languages.includes(locale)
) {
const i18n = await translateSheets(relPath, locale);
if (!i18n) {
logger.debug(`No i18n found for ${relPath} : ${locale}`);

View File

@ -0,0 +1,407 @@
---
const products = [
{
id: 1,
name: "OP-1 Portable Synthesizer",
price: "$1,299.00",
description: "All-in-one portable synthesizer, sampler, and controller.",
storage: "64MB",
imageSrc: "/products/1.jpeg",
},
{
id: 2,
name: "PO-33 Pocket Operator",
price: "$89.00",
description:
"Micro sampler and sequencer with built-in microphone and 40-second sample memory.",
storage: "4MB",
imageSrc: "/products/17.jpeg",
},
];
---
<section>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 just xl:grid-cols-3 gap-2">
<div class="flex flex-col gap-2 h-full justify-between">
<div>
<h1
class="text-lg text-neutral-600 tracking-tight text-balance">
IPHONE 14 PRO MAX
</h1>
<p class=" mt-2 text-sm">
Elevate your iPhone 14 Pro Max's aesthetic with our exclusive
collection. Gone are the days of conventional designs. Our innovative
process transforms standard patterns into exceptional skins for your
device.
</p>
<form class="bg-white p-4 mt-6 rounded-xl">
<div class="grid grid-cols-12 gap-1">
<div class="col-span-full">
<label
for="email-address"
class="sr-only"
>Email address</label
>
<div class="mt-1">
<input
type="email"
id="email-address"
name="email-address"
placeholder="Email address"
aria-label="Email address"
autocomplete="email"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full">
<label
for="name-on-card"
class="sr-only"
>Name on card</label
>
<div class="mt-1">
<input
type="text"
id="name-on-card"
name="name-on-card"
placeholder="Name on card"
autocomplete="cc-name"
aria-label="Name on card"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full">
<label
for="card-number"
class="sr-only"
>Card number</label
>
<div class="mt-1">
<input
type="text"
id="card-number"
name="card-number"
autocomplete="cc-number"
placeholder="Card number"
aria-label="Card number"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-8 sm:col-span-9">
<label
for="expiration-date"
class="sr-only"
>Expiration date (MM/YY)</label
>
<div class="mt-1">
<input
type="text"
name="expiration-date"
id="expiration-date"
placeholder="Expiration date (MM/YY)"
aria-label="Expiration date"
autocomplete="cc-exp"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-4 sm:col-span-3">
<label
for="cvc"
class="sr-only"
>CVC</label
>
<div class="mt-1">
<input
id="cvc"
type="text"
name="cvc"
placeholder="CVC"
autocomplete="cc-csc"
aria-label="CVC"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full">
<label
for="address"
class="sr-only"
>Address</label
>
<div class="mt-1">
<input
id="address"
type="text"
name="address"
placeholder="Address"
aria-label="Address"
autocomplete="street-address"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full sm:col-span-4">
<label
for="city"
class="sr-only"
>City</label
>
<div class="mt-1">
<input
id="city"
type="text"
name="city"
placeholder="City"
aria-label="City"
autocomplete="address-level2"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full sm:col-span-4">
<label
for="region"
class="sr-only"
>State / Province</label
>
<div class="mt-1">
<input
id="region"
type="text"
name="region"
placeholder="State / Province"
aria-label="State / Province"
autocomplete="address-level1"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
<div class="col-span-full sm:col-span-4">
<label
for="postal-code"
class="sr-only"
>Postal code</label
>
<div class="mt-1">
<input
id="postal-code"
type="text"
name="postal-code"
placeholder="Postal Code"
autocomplete="postal-code"
aria-label="Postal code"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
</div>
</div>
</div>
<div class="col-span-full mt-2">
<label
for="discount-code"
class="sr-only"
>Discount code</label
>
<div class="mt-1 flex w-full space-x-2">
<input
id="discount-code"
type="text"
name="discount-code"
placeholder="Discount Code"
aria-label="Discount code"
class="flex-auto rounded-xl border-0 h-14 text-xs uppercase duration-300 px-3.5 py-2 ring-1 ring-inset ring-white placeholder:text-neutral-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 bg-neutral-100 w-full"
/>
<button
type="submit"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden px-6 justify-center text-xs h-14 flex uppercase items-center bg-black hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-xl w-full">
Apply
</button>
</div>
</div>
<div class="mt-2 flex flex-col gap-2">
<button
type="submit"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 justify-between text-xs h-14 flex space-x-6 items-center bg-black hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-xl w-full">
<span class="relative uppercase text-xs">Pay with apple</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
</div>
</button>
<button
type="submit"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center hover:bg-black duration-300 rounded-xl w-full justify-between">
<span class="relative uppercase text-xs ">Pay</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
</div>
</button>
</div>
</form>
</div>
</div>
<div class="lg:col-span-2 space-y-2">
<div class="">
<ul
role="list"
class="grid grid-cols-1 lg:grid-cols-2 gap-2">
{
products.map((product) => (
<li
x-data="{ show: true }"
x-show="show">
<div class="flex flex-col gap-2 relative p-4 bg-white rounded-xl">
<img
src={product.imageSrc}
alt={product.name}
class="aspect-[4/2] flex-none rounded-lg bg-neutral-200 object-cover object-center"
/>
<div class="flex flex-col justify-between w-full">
<div class=" text-sm ">
<h3 class="text-lg text-neutral-600 uppercase tracking-tight">
{product.name}
</h3>
<p class=" ">
{product.price} — {product.storage}
</p>
<p class=" text-balance mt-8">
{product.description}
</p>
</div>
<div class="flex ml-auto mt-16">
<button
@click="show = false"
type="submit"
title="link to your page"
aria-label="your label"
class="relative group px-6 justify-center text-xs text-orange-600 uppercase h-8 flex items-center bg-neutral-100 hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-lg w-full">
<span class="sr-only">Remove</span>
Remove
</button>
</div>
</div>
</div>
</li>
))
}
</ul>
</div>
<div class="bg-white p-4 rounded-xl">
<div class="flex-none pt-2">
<dl class="space-y-6 text-sm ">
<div class="flex justify-between">
<dt
class="text-lg text-neutral-600 uppercase tracking-tight">
Subtotal
</dt>
<dd class=" ">$920.00</dd>
</div>
<div class="flex justify-between">
<dt class="flex">
<span
class="text-lg text-neutral-600 uppercase tracking-tight"
>Discount</span
>
<span
class="ml-2 rounded-md bg-neutral-100 px-2 py-0.5 inline-flex items-center text-xs tracking-wide text-orange-600 uppercase"
>LEXINGTON30</span
>
</dt>
<dd class=" ">-$30.00</dd>
</div>
<div class="flex justify-between">
<dt
class="text-lg text-neutral-600 uppercase tracking-tight">
Taxes
</dt>
<dd class=" ">$403.68</dd>
</div>
<div class="flex justify-between">
<dt
class="text-lg text-neutral-600 uppercase tracking-tight">
Shipping
</dt>
<dd class=" ">Free</dd>
</div>
<div
class="flex items-center justify-between border-t border-neutral-200 pt-6 ">
<dt class="text-base">Total</dt>
<dd class="text-base">$3.019</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,47 @@
---
import { default_image } from "@/app/config.js";
const { title, url, price, model } = Astro.props;
const thumbnail =
model?.assets?.main_image?.url ||
model?.assets?.gallery[0]?.url ||
default_image();
const hash = model?.assets?.main_image?.hash || model?.assets?.gallery[0]?.hash;
import Img from "@polymech/astro-base/components/polymech/image.astro";
---
<div
class="group relative overflow-hidden rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<div class="aspect-square overflow-hidden">
<a href={url} title={title} aria-label={title} class="block w-full h-full">
<div class="w-full h-full flex items-center justify-center p-0 md:p-1">
<Img
src={thumbnail}
s={hash}
alt={title}
format="avif"
objectFit="contain"
placeholder="blurred"
sizes="(min-width: 220px) 220px"
breakpoints={{ count: 2, minWidth: 120, maxWidth: 430 }}
class="max-w-full max-h-full object-contain group-hover:scale-105 transition-transform duration-300"
/>
</div>
</a>
</div>
<div class="p-4">
<h3 class="text-sm font-medium line-clamp-2">
<a
href={url}
title={title}
aria-label={title}
class="hover:text-neutral-900 transition-colors"
>
<span aria-hidden="true" class="absolute inset-0"></span>
{title}
</a>
</h3>
</div>
</div>

View File

@ -1,7 +1,6 @@
---
import { translate } from "@/base/i18n.js";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { slugify } from "@/base/strings.js"
import { translate } from "../base/i18n.js";
import { slugify } from "../base/strings.js";
export interface Props {
title: string;
@ -12,7 +11,7 @@ const slug = `${slugify(title)}-tab-button`;
const id = `#${slug}-tab`;
const view = `#${slugify(title)}-view`;
const locale = Astro.currentLocale;
const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale);
const title_i18n = await translate(title, "en", locale);
---
<li>

View File

@ -1,5 +1,6 @@
---
import { slugify } from "@/base/strings.js";
import { slugify } from "../base/strings.js";
export interface Props {
title: string;
}

View File

@ -1,15 +1,15 @@
---
import { translate } from '@/base/i18n.js';
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { translate } from "../base/i18n.js";
import { I18N_SOURCE_LANGUAGE } from "../app/config.js";
// A simple slugify helper (you could import this from a utility file instead)
function slugify(text: string) {
return text
.normalize('NFD') // Normalise accented characters
.replace(/[\u0300-\u036f]/g, '') // Strip accents
.normalize("NFD") // Normalise accented characters
.replace(/[\u0300-\u036f]/g, "") // Strip accents
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
}
export interface Props {
@ -18,11 +18,7 @@ export interface Props {
src?: string; // optional
}
const {
title = 'title',
clazz = 'unstyled',
src,
} = Astro.props;
const { title = "title", clazz = "unstyled", src } = Astro.props;
// Compute the slug from the original title
const id = slugify(title);
@ -36,16 +32,19 @@ const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale);
id={id}
slot="tab"
class="inline-flex whitespace-nowrap border-b-2 border-transparent py-2 px-3 text-sm font-medium text-gray-300 transition-all duration-200 ease-in-out hover:border-b-[#9249ed] hover:text-[#9249ed] aria-selected:border-b-[#9249ed] aria-selected:text-[#9249ed]"
>{title_i18n}</button>
>{title_i18n}</button
>
<div slot="panel" class={clazz}>
{src ? (
<div class="iframe-container">
<iframe src={src}></iframe>
</div>
) : (
<slot />
)}
{
src ? (
<div class="iframe-container">
<iframe src={src} />
</div>
) : (
<slot />
)
}
</div>
<style>

1
packages/polymech/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@ -2,16 +2,22 @@
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"
import { isRTL } from "../app/config.js";
const { frontmatter: frontmatter, ...rest } = Astro.props;
---
<html lang={Astro.currentLocale} class="scroll-smooth" dir={isRTL(Astro.currentLocale) ? "rtl" : "ltr"}>
<html
lang={Astro.currentLocale}
class="scroll-smooth"
dir={isRTL(Astro.currentLocale) ? "rtl" : "ltr"}
>
<head>
<BaseHead frontmatter={frontmatter} {...rest} />
</head>
<body class="bg-white dark:bg-gray-900 mx-auto 2xl:max-w-7xl flex flex-col min-h-svh p-4 transition-colors duration-200">
<body
class="bg-white dark:bg-gray-900 mx-auto 2xl:max-w-7xl flex flex-col min-h-svh p-4 transition-colors duration-200"
>
<Navigation />
<main class="grow"><slot /></main>
<Footer />

View File

@ -1,7 +1,7 @@
---
/**
* Resources Layout
*
*
* Features:
* - Automatic top-level Table of Contents for articles with 20+ headings
* - Sidebar navigation with page-level navigation support
@ -9,18 +9,17 @@
* - Image lightbox functionality
* - Responsive design with mobile sidebar toggle
*/
import Sidebar from "@polymech/astro-base/components/sidebar/Sidebar.astro"
import MobileToggle from "@polymech/astro-base/components/sidebar/MobileToggle.astro"
import Breadcrumb from "@polymech/astro-base/components/Breadcrumb.astro";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { getSidebarConfig } from '@polymech/astro-base/config/sidebar';
import { generateToC } from '@polymech/astro-base/components/sidebar/utils/generateToC.js';
import Sidebar from "../components/sidebar/Sidebar.astro";
import MobileToggle from "../components/sidebar/MobileToggle.astro";
import Breadcrumb from "../components/Breadcrumb.astro";
import Translate from "../components/i18n.astro";
import { getSidebarConfig } from "../config/sidebar";
import { generateToC } from "../components/sidebar/utils/generateToC.js";
import RelativeImage from '@polymech/astro-base/components/RelativeImage.astro';
import type { MarkdownHeading } from 'astro';
import { getCollection } from 'astro:content';
import type { MarkdownHeading } from "astro";
import { getCollection } from "astro:content";
import BaseLayout from "./BaseLayout.astro"
import BaseLayout from "./BaseLayout.astro";
interface Props {
frontmatter: {
@ -35,7 +34,7 @@ interface Props {
};
sidebar?: any; // Page-level sidebar configuration
breadcrumb?: boolean; // Enable/disable breadcrumb (default: true)
bottomNav?: 'PREV/NEXT' | 'TOP' | false; // Bottom navigation type
bottomNav?: "PREV/NEXT" | "TOP" | false; // Bottom navigation type
minutesRead?: string; // Reading time from remark plugin
};
headings?: MarkdownHeading[];
@ -43,34 +42,42 @@ interface Props {
collectionName?: string; // Collection name for dynamic functionality
}
const { frontmatter, headings = [], entryPath, collectionName = 'resources' } = Astro.props; // Updated to get entryPath and collectionName
const {
frontmatter,
headings = [],
entryPath,
collectionName = "resources",
} = Astro.props; // Updated to get entryPath and collectionName
const locale = Astro.currentLocale;
const sidebarConfig = getSidebarConfig();
// Extract page-level sidebar configuration from frontmatter
const pageNavigation = frontmatter.sidebar ? [frontmatter.sidebar].flat() : [];
// Format the date
const formattedDate = frontmatter.pubDate ? new Date(frontmatter.pubDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : '';
const formattedDate = frontmatter.pubDate
? new Date(frontmatter.pubDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "";
// Generate top-level TOC for articles with many headings (10+)
const shouldShowTopToc = headings.length > 10;
const topToc = shouldShowTopToc ? generateToC(headings, {
minHeadingLevel: 2,
maxHeadingLevel: 3,
title: 'Table of Contents'
}) : null;
const topToc = shouldShowTopToc
? generateToC(headings, {
minHeadingLevel: 2,
maxHeadingLevel: 3,
title: "Table of Contents",
})
: null;
// Bottom navigation configuration
const BOTTOM_NAV = frontmatter.bottomNav || 'PREV/NEXT';
const BOTTOM_NAV = frontmatter.bottomNav || "PREV/NEXT";
const showBottomNav = BOTTOM_NAV !== false;
const isPrevNext = BOTTOM_NAV === 'PREV/NEXT';
const isTop = BOTTOM_NAV === 'TOP';
const isPrevNext = BOTTOM_NAV === "PREV/NEXT";
const isTop = BOTTOM_NAV === "TOP";
// Get previous and next articles for navigation
let prevArticle: any = null;
@ -80,29 +87,31 @@ if (isPrevNext && entryPath) {
try {
// Get all articles from the collection
const allArticles = await getCollection(collectionName);
// Extract the current article path without the collection prefix
const currentArticlePath = entryPath.replace(`${collectionName}/`, '');
const currentArticlePath = entryPath.replace(`${collectionName}/`, "");
// Sort articles by their full path to maintain hierarchical order
const sortedArticles = allArticles.sort((a, b) => a.id.localeCompare(b.id));
// Find current article index
const currentIndex = sortedArticles.findIndex(article => article.id === currentArticlePath);
const currentIndex = sortedArticles.findIndex(
(article) => article.id === currentArticlePath,
);
if (currentIndex !== -1) {
// Get previous article
if (currentIndex > 0) {
prevArticle = sortedArticles[currentIndex - 1];
}
// Get next article
if (currentIndex < sortedArticles.length - 1) {
nextArticle = sortedArticles[currentIndex + 1];
}
}
} catch (error) {
console.warn('Could not fetch collection for navigation:', error);
console.warn("Could not fetch collection for navigation:", error);
}
}
---
@ -111,208 +120,313 @@ if (isPrevNext && entryPath) {
<div class="layout-with-sidebar">
<!-- Mobile Toggle -->
<MobileToggle />
<!-- Sidebar -->
<div class="sidebar-wrapper">
<Sidebar
config={sidebarConfig}
currentUrl={Astro.url}
headings={headings}
<Sidebar
config={sidebarConfig}
currentUrl={Astro.url}
headings={headings}
pageNavigation={pageNavigation}
/>
</div>
<!-- Main Content -->
<main class="main-content-with-sidebar">
<div class="px-4 py-4 md:px-6 md:py-6">
{/* Breadcrumb - enabled by default, can be disabled with breadcrumb: false */}
{frontmatter.breadcrumb !== false && (
<Breadcrumb
currentPath={Astro.url.pathname}
collection={collectionName}
title={frontmatter.title}
/>
)}
<article class="prose prose-lg max-w-full md:max-w-4xl mx-auto overflow-x-hidden" id="top">
{
/* Breadcrumb - enabled by default, can be disabled with breadcrumb: false */
}
{
frontmatter.breadcrumb !== false && (
<Breadcrumb
currentPath={Astro.url.pathname}
collection={collectionName}
title={frontmatter.title}
/>
)
}
<article
class="prose prose-lg max-w-full md:max-w-4xl mx-auto overflow-x-hidden"
id="top"
>
<header class="mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
{frontmatter.title}
</h1>
{/* Reading time and metadata */}
{(frontmatter.author && frontmatter.author.toLowerCase() !== 'unknown') || formattedDate || frontmatter.minutesRead ? (
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
{frontmatter.minutesRead && (
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<Translate>{frontmatter.minutesRead}</Translate>
</span>
)}
{frontmatter.author && frontmatter.author.toLowerCase() !== 'unknown' && <span>By {frontmatter.author}</span>}
{formattedDate && <span>{formattedDate}</span>}
</div>
) : null}
<p class="text-xl text-gray-700 mb-6">{frontmatter.description}</p>
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-6">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full hover:bg-blue-200 hover:text-blue-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
</div>
)}
{/* Top-level Table of Contents for long articles */}
{shouldShowTopToc && topToc && (
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
<Translate>Table of Contents</Translate>
</h2>
<nav class="top-toc-nav">
{topToc.length >= 10 ? (
/* 2-column layout for 10+ items with even distribution */
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
{/* Debug info */}
{/* Left column: first half of items */}
<div class="space-y-2">
{topToc.slice(0, Math.floor(topToc.length / 2)).map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{index + 1}.</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
{/* Right column: second half of items */}
<div class="space-y-2">
{topToc.slice(Math.floor(topToc.length / 2)).map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{Math.floor(topToc.length / 2) + index + 1}.</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
</div>
) : (
/* Single column for fewer items */
<ul class="space-y-2">
{topToc.map((item, index) => (
<li>
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{index + 1}.</span>
<span class="inline-block">{item.text}</span>
</a>
</li>
))}
</ul>
{
(frontmatter.author &&
frontmatter.author.toLowerCase() !== "unknown") ||
formattedDate ||
frontmatter.minutesRead ? (
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
{frontmatter.minutesRead && (
<span class="flex items-center">
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Translate>{frontmatter.minutesRead}</Translate>
</span>
)}
</nav>
</div>
)}
{frontmatter.author &&
frontmatter.author.toLowerCase() !== "unknown" && (
<span>By {frontmatter.author}</span>
)}
{formattedDate && <span>{formattedDate}</span>}
</div>
) : null
}
<p class="text-xl text-gray-700 mb-6">{frontmatter.description}</p>
{
frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-6">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full hover:bg-blue-200 hover:text-blue-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
</div>
)
}
{/* Top-level Table of Contents for long articles */}
{
shouldShowTopToc && topToc && (
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
<Translate>Table of Contents</Translate>
</h2>
<nav class="top-toc-nav">
{topToc.length >= 10 ? (
/* 2-column layout for 10+ items with even distribution */
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
{/* Debug info */}
{/* Left column: first half of items */}
<div class="space-y-2">
{topToc
.slice(0, Math.floor(topToc.length / 2))
.map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">
{index + 1}.
</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
{/* Right column: second half of items */}
<div class="space-y-2">
{topToc
.slice(Math.floor(topToc.length / 2))
.map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">
{Math.floor(topToc.length / 2) + index + 1}.
</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
</div>
) : (
/* Single column for fewer items */
<ul class="space-y-2">
{topToc.map((item, index) => (
<li>
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">
{index + 1}.
</span>
<span class="inline-block">{item.text}</span>
</a>
</li>
))}
</ul>
)}
</nav>
</div>
)
}
</header>
<div class="markdown-content">
<slot />
</div>
{/* Tags at bottom of article */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="mt-8 pt-6 border-t border-gray-200">
<h3 class="text-sm font-medium text-gray-700 mb-3">Tags:</h3>
<div class="flex flex-wrap gap-2">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 hover:text-gray-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
{
frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="mt-8 pt-6 border-t border-gray-200">
<h3 class="text-sm font-medium text-gray-700 mb-3">Tags:</h3>
<div class="flex flex-wrap gap-2">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 hover:text-gray-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
</div>
</div>
</div>
)}
)
}
{/* Bottom Navigation */}
{showBottomNav && (
<div class="mt-12 pt-8 border-t border-gray-200">
{isPrevNext ? (
<div class="flex justify-between items-center">
<div class="flex-1">
{prevArticle ? (
<a href={`/${locale}/${collectionName}/${prevArticle.id}`} class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<svg class="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<span class="text-sm font-medium">← {prevArticle.data.title}</span>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>First Article</Translate> ←
</div>
)}
{
showBottomNav && (
<div class="mt-12 pt-8 border-t border-gray-200">
{isPrevNext ? (
<div class="flex justify-between items-center">
<div class="flex-1">
{prevArticle ? (
<a
href={`/${locale}/${collectionName}/${prevArticle.id}`}
class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
<svg
class="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
<span class="text-sm font-medium">
← {prevArticle.data.title}
</span>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>First Article</Translate> ←
</div>
)}
</div>
<div class="flex-1 text-right">
{nextArticle ? (
<a
href={`/${locale}/${collectionName}/${nextArticle.id}`}
class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
<span class="text-sm font-medium">
{nextArticle.data.title} →
</span>
<svg
class="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>Last Article</Translate> →
</div>
)}
</div>
</div>
<div class="flex-1 text-right">
{nextArticle ? (
<a href={`/${locale}/${collectionName}/${nextArticle.id}`} class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<span class="text-sm font-medium">{nextArticle.data.title} →</span>
<svg class="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>Last Article</Translate> →
</div>
)}
) : isTop ? (
<div class="text-center">
<a
href="#top"
class="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span class="text-sm font-medium">
<Translate>Back to Top</Translate>
</span>
</a>
</div>
</div>
) : isTop ? (
<div class="text-center">
<a href="#top" class="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
<span class="text-sm font-medium">
<Translate>Back to Top</Translate>
</span>
</a>
</div>
) : null}
</div>
)}
) : null}
</div>
)
}
</article>
</div>
</main>
</div>
<!-- Global Lightbox -->
<div id="global-lightbox" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" style="display: none;">
<div
id="global-lightbox"
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
style="display: none;"
>
<div class="relative max-w-full max-h-full">
<img id="lightbox-image" src="" alt="" class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg" />
<img
id="lightbox-image"
src=""
alt=""
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
/>
<!-- Close Button -->
<button id="lightbox-close" class="absolute -top-4 -right-4 text-2xl p-2 bg-gray-800/75 rounded-full">&times;</button>
<button
id="lightbox-close"
class="absolute -top-4 -right-4 text-2xl p-2 bg-gray-800/75 rounded-full"
>&times;</button
>
<!-- Navigation Buttons -->
<button id="lightbox-prev" class="absolute left-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg">&#10094;</button>
<button id="lightbox-next" class="absolute right-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg">&#10095;</button>
<button
id="lightbox-prev"
class="absolute left-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg"
>&#10094;</button
>
<button
id="lightbox-next"
class="absolute right-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg"
>&#10095;</button
>
</div>
</div>
</BaseLayout>
@ -324,11 +438,11 @@ if (isPrevNext && entryPath) {
padding: 0;
margin: 0;
}
.top-toc-nav li {
margin: 0;
}
.top-toc-nav a {
display: block;
text-decoration: none;
@ -336,29 +450,29 @@ if (isPrevNext && entryPath) {
transition: all 0.2s ease;
color: #3b82f6; /* text-blue-600 */
}
.top-toc-nav a:hover {
background-color: #f3f4f6;
color: #1d4ed8; /* text-blue-800 */
}
.top-toc-nav a:visited {
color: #8b5cf6; /* text-violet-600 */
}
.top-toc-nav a:visited:hover {
color: #7c3aed; /* text-violet-700 */
}
/* Visited links styling */
.top-toc-nav a.visited {
color: #8b5cf6; /* text-violet-600 */
}
.top-toc-nav a.visited:hover {
color: #7c3aed; /* text-violet-700 */
}
/* Active/current section styling */
.top-toc-nav a.active {
background-color: #eff6ff; /* bg-blue-50 */
@ -367,25 +481,25 @@ if (isPrevNext && entryPath) {
border-left: 3px solid #3b82f6; /* border-blue-500 */
padding-left: 0.75rem;
}
/* 2-column grid styling */
.top-toc-nav .grid {
align-items: start;
}
/* CSS Columns for natural left-to-right flow */
.toc-columns {
column-count: 2;
column-gap: 2rem;
column-fill: auto;
}
.toc-columns a {
break-inside: avoid;
margin-bottom: 0.5rem;
display: block;
}
/* Better TOC text wrapping */
.toc-text {
display: inline;
@ -393,38 +507,38 @@ if (isPrevNext && entryPath) {
hyphens: auto;
line-height: 1.4;
}
/* Prevent awkward line breaks */
.top-toc-nav a {
min-height: 2.5rem;
display: flex;
align-items: flex-start;
}
.top-toc-nav a .font-mono {
margin-top: 0;
line-height: 1.4;
align-self: flex-start;
}
.top-toc-nav a .toc-text {
flex: 1;
line-height: 1.4;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.top-toc-nav .grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.toc-columns {
column-count: 1;
column-gap: 0;
}
}
/* Reading time styling */
.reading-time {
display: inline-flex;
@ -433,160 +547,181 @@ if (isPrevNext && entryPath) {
font-size: 0.875rem;
font-weight: 500;
}
.reading-time svg {
color: #9ca3af; /* text-gray-400 */
margin-right: 0.25rem;
}
/* Bottom navigation styling */
.bottom-nav {
border-top: 1px solid #e5e7eb;
padding-top: 2rem;
margin-top: 3rem;
}
.bottom-nav a {
transition: all 0.2s ease;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
.bottom-nav a:hover {
background-color: #f3f4f6;
}
.bottom-nav svg {
transition: transform 0.2s ease;
}
.bottom-nav .group:hover svg {
transform: translateX(-4px);
}
.bottom-nav .group:hover svg:last-child {
transform: translateX(4px);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener("DOMContentLoaded", () => {
// TOC functionality
const tocLinks = document.querySelectorAll('.top-toc-nav a[href^="#"]');
// Track visited sections
tocLinks.forEach(link => {
link.addEventListener('click', () => {
const targetId = link.getAttribute('href')?.substring(1);
tocLinks.forEach((link) => {
link.addEventListener("click", () => {
const targetId = link.getAttribute("href")?.substring(1);
if (targetId) {
// Mark as visited in localStorage
const visited = JSON.parse(localStorage.getItem('toc-visited') || '[]');
const visited = JSON.parse(
localStorage.getItem("toc-visited") || "[]",
);
if (!visited.includes(targetId)) {
visited.push(targetId);
localStorage.setItem('toc-visited', JSON.stringify(visited));
localStorage.setItem("toc-visited", JSON.stringify(visited));
}
// Update visited state
updateTocStates();
}
});
});
// Update TOC states based on visited sections and current position
function updateTocStates() {
const visited = JSON.parse(localStorage.getItem('toc-visited') || '[]');
tocLinks.forEach(link => {
const targetId = link.getAttribute('href')?.substring(1);
const visited = JSON.parse(localStorage.getItem("toc-visited") || "[]");
tocLinks.forEach((link) => {
const targetId = link.getAttribute("href")?.substring(1);
if (targetId) {
// Remove existing classes
link.classList.remove('active', 'visited');
link.classList.remove("active", "visited");
// Add visited class if section has been visited
if (visited.includes(targetId)) {
link.classList.add('visited');
link.classList.add("visited");
}
}
});
}
// Intersection Observer for active section highlighting
const headings = document.querySelectorAll('h1[id], h2[id], h3[id]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
// Update active state
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${id}`) {
link.classList.add('active');
}
});
}
});
}, {
rootMargin: '-20% 0% -35% 0%',
threshold: 0.1
});
headings.forEach(heading => observer.observe(heading));
const headings = document.querySelectorAll("h1[id], h2[id], h3[id]");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
// Update active state
tocLinks.forEach((link) => {
link.classList.remove("active");
if (link.getAttribute("href") === `#${id}`) {
link.classList.add("active");
}
});
}
});
},
{
rootMargin: "-20% 0% -35% 0%",
threshold: 0.1,
},
);
headings.forEach((heading) => observer.observe(heading));
// Initialize TOC states
updateTocStates();
// Lightbox functionality
const lightbox = document.getElementById('global-lightbox') as HTMLElement;
const lightboxImage = document.getElementById('lightbox-image') as HTMLImageElement;
const closeButton = document.getElementById('lightbox-close') as HTMLButtonElement;
const prevButton = document.getElementById('lightbox-prev') as HTMLButtonElement;
const nextButton = document.getElementById('lightbox-next') as HTMLButtonElement;
const lightbox = document.getElementById("global-lightbox") as HTMLElement;
const lightboxImage = document.getElementById(
"lightbox-image",
) as HTMLImageElement;
const closeButton = document.getElementById(
"lightbox-close",
) as HTMLButtonElement;
const prevButton = document.getElementById(
"lightbox-prev",
) as HTMLButtonElement;
const nextButton = document.getElementById(
"lightbox-next",
) as HTMLButtonElement;
if (!lightbox || !lightboxImage || !closeButton || !prevButton || !nextButton) return;
if (
!lightbox ||
!lightboxImage ||
!closeButton ||
!prevButton ||
!nextButton
)
return;
let images: { src: string, alt: string }[] = [];
let images: { src: string; alt: string }[] = [];
let currentIndex = 0;
function updateLightbox() {
if (images.length > 0 && images[currentIndex]) {
lightboxImage.src = images[currentIndex].src;
lightboxImage.alt = images[currentIndex].alt;
prevButton.style.display = currentIndex > 0 ? 'block' : 'none';
nextButton.style.display = currentIndex < images.length - 1 ? 'block' : 'none';
prevButton.style.display = currentIndex > 0 ? "block" : "none";
nextButton.style.display =
currentIndex < images.length - 1 ? "block" : "none";
}
}
window.addEventListener('open-lightbox', (event: CustomEvent) => {
window.addEventListener("open-lightbox", (event: CustomEvent) => {
images = event.detail.images;
currentIndex = event.detail.currentIndex;
updateLightbox();
lightbox.style.display = 'flex';
lightbox.style.display = "flex";
});
closeButton.addEventListener('click', () => {
lightbox.style.display = 'none';
closeButton.addEventListener("click", () => {
lightbox.style.display = "none";
});
prevButton.addEventListener('click', () => {
prevButton.addEventListener("click", () => {
if (currentIndex > 0) {
currentIndex--;
updateLightbox();
}
});
nextButton.addEventListener('click', () => {
nextButton.addEventListener("click", () => {
if (currentIndex < images.length - 1) {
currentIndex++;
updateLightbox();
}
});
window.addEventListener('keydown', (event) => {
if (lightbox.style.display === 'none') return;
if (event.key === 'Escape') closeButton.click();
if (event.key === 'ArrowLeft') prevButton.click();
if (event.key === 'ArrowRight') nextButton.click();
window.addEventListener("keydown", (event) => {
if (lightbox.style.display === "none") return;
if (event.key === "Escape") closeButton.click();
if (event.key === "ArrowLeft") prevButton.click();
if (event.key === "ArrowRight") nextButton.click();
});
});
</script>

View File

@ -1,21 +1,18 @@
---
import "flowbite";
import { createMarkdownComponent } from "@/base/index.js";
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import LGallery from "@polymech/astro-base/components/GalleryK.astro";
import { createMarkdownComponent } from "../base/index.js";
import { translate } from "../base/i18n.js";
import Translate from "../components/i18n.astro";
import LGallery from "../components/GalleryK.astro";
import BaseLayout from "./BaseLayout.astro";
import Wrapper from "@/components/containers/Wrapper.astro";
import Readme from "@/components/polymech/readme.astro";
import Breadcrumb from "@/components/Breadcrumb.astro";
import Resources from "@/components/polymech/resources.astro";
import Specs from "@polymech/astro-base/components/specs.astro";
import TabButton from "@polymech/astro-base/components/tab-button.astro";
import TabContent from "@polymech/astro-base/components/tab-content.astro";
import Wrapper from "../components/containers/Wrapper.astro";
import Readme from "../components/polymech/readme.astro";
import Breadcrumb from "../components/Breadcrumb.astro";
import Resources from "../components/polymech/resources.astro";
import Specs from "../components/specs.astro";
import TabButton from "../components/tab-button.astro";
import TabContent from "../components/tab-content.astro";
import {
I18N_SOURCE_LANGUAGE,
@ -60,7 +57,7 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<BaseLayout frontmatter={data} description={data.description} {...rest}>
<Wrapper>
<Breadcrumb
<Breadcrumb
currentPath={Astro.url.pathname}
collection="store"
title={data.title}
@ -71,19 +68,19 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<!-- Left Column: Description -->
<div class="flex flex-col gap-4">
<div>
<h1 class=" font-semibold mb-2 text-2xl">
<h1 class="font-semibold mb-2 text-2xl">
<Translate>{`${data.title}`}</Translate>
</h1>
{
isRTL(Astro.currentLocale) && (
<div class=" font-semibold mb-2">
"{data.title}"
</div>
<div class=" font-semibold mb-2">"{data.title}"</div>
)
}
</div>
<article class="markdown-content bg-white dark:bg-gray-800 rounded-xl p-4">
<article
class="markdown-content bg-white dark:bg-gray-800 rounded-xl p-4"
>
<Body />
</article>
@ -146,12 +143,12 @@ const Content_Debug = await createMarkdownComponent(str_debug);
{
SHOW_CHECKOUT && data.checkout && (
<a
href={data.checkout}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white dark:bg-gray-800 hover:bg-black dark:hover:bg-gray-700 duration-300 rounded-xl w-full justify-between"
>
<a
href={data.checkout}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white dark:bg-gray-800 hover:bg-black dark:hover:bg-gray-700 duration-300 rounded-xl w-full justify-between"
>
<span class="relative uppercase text-xs ">
<Translate>Add to cart</Translate>
</span>
@ -202,7 +199,9 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<h3 class="text-lg text-neutral-600 dark:text-gray-300 uppercase tracking-tight">
License
</h3>
<p class=" mt-4 text-sm text-gray-700 dark:text-gray-300">{data.license}</p>
<p class=" mt-4 text-sm text-gray-700 dark:text-gray-300">
{data.license}
</p>
</div>
)
}
@ -220,149 +219,147 @@ const Content_Debug = await createMarkdownComponent(str_debug);
</div>
)
}
</div>
</div>
</section>
{data.assets.showcase && data.assets.showcase.length > 0 && (
<section>
<div
class="bg-white dark:bg-gray-800 rounded-xl p-4 md:mb-16 mt-0 p-2 md:p-4 border-b border-gray-200 dark:border-gray-700"
>
<LGallery
images={data.assets.showcase}
lightboxSettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
SIZES_THUMB: "w-32 h-32",
}}
gallerySettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
//SIZES_THUMB: "w-32 h-32",
}}
/>{" "}
</div>
</section>
)}
{
data.assets.showcase && data.assets.showcase.length > 0 && (
<section>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 md:mb-16 mt-0 p-2 md:p-4 border-b border-gray-200 dark:border-gray-700">
<LGallery
images={data.assets.showcase}
lightboxSettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
SIZES_THUMB: "w-32 h-32",
}}
gallerySettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
//SIZES_THUMB: "w-32 h-32",
}}
/>{" "}
</div>
</section>
)
}
<section id="tabs-view">
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
id="default-styled-tab"
data-tabs-toggle="#default-styled-tab-content"
data-tabs-active-classes="text-orange-600 hover:text-orange-600 dark:text-purple-500 dark:hover:text-purple-500 border-orange-600 dark:border-purple-500"
data-tabs-inactive-classes="dark:border-transparent text-gray-500 hover:text-gray-600 dark:text-gray-400 border-gray-100 hover:border-gray-300 dark:border-gray-700 dark:hover:text-gray-300"
role="tablist"
>
{SHOW_README && <TabButton title="Overview" />}
<TabButton title="Specs" />
<TabButton title="Gallery" />
<TabButton title="Resources" />
{SHOW_SAMPLES && <TabButton title="Samples" />}
{SHOW_DEBUG && <TabButton title="Debug" />}
</ul>
</div>
<div id="default-styled-tab-content">
<TabContent title="Overview">
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
id="default-styled-tab"
data-tabs-toggle="#default-styled-tab-content"
data-tabs-active-classes="text-orange-600 hover:text-orange-600 dark:text-purple-500 dark:hover:text-purple-500 border-orange-600 dark:border-purple-500"
data-tabs-inactive-classes="dark:border-transparent text-gray-500 hover:text-gray-600 dark:text-gray-400 border-gray-100 hover:border-gray-300 dark:border-gray-700 dark:hover:text-gray-300"
role="tablist"
>
{SHOW_README && <TabButton title="Overview" />}
<TabButton title="Specs" />
<TabButton title="Gallery" />
<TabButton title="Resources" />
{SHOW_SAMPLES && <TabButton title="Samples" />}
{SHOW_DEBUG && <TabButton title="Debug" />}
</ul>
</div>
<div id="default-styled-tab-content">
<TabContent title="Overview">
{
SHOW_README && data.readme && (
<Readme markdown={data.readme} data={data} />
)
}
</TabContent>
<div
class="hidden bg-white rounded-xl dark:bg-gray-800"
id="specs-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Specs frontmatter={data} />
</div>
<div
class="hidden p-0 md:p-4 rounded-lg bg-white dark:bg-gray-800"
id="gallery-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.gallery} />
</div>
{
SHOW_README && data.readme && (
<Readme markdown={data.readme} data={data} />
SHOW_SAMPLES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="samples-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.samples} />
</div>
)
}
</TabContent>
<div
class="hidden bg-white rounded-xl dark:bg-gray-800 "
id="specs-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Specs frontmatter={data} />
</div>
<div
class="hidden p-0 md:p-4 rounded-lg bg-white dark:bg-gray-800"
id="gallery-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.gallery} />
</div>
{
SHOW_SAMPLES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="samples-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.samples} />
</div>
)
}
{
SHOW_RESOURCES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="resources-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Resources frontmatter={data} />
</div>
)
}
{
SHOW_DEBUG && (
<div
class="hidden rounded-lg bg-white p-4 dark:bg-gray-800"
id="debug-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Content_Debug />
</div>
)
}
</div>
<script>
window.addEventListener("hashchange", () => {
const hash = window.location.hash.substring(1);
if (hash) {
const tabTrigger = document.querySelector(
`a[href="#${hash}"], a[data-tabs-target="#${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
{
SHOW_RESOURCES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="resources-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Resources frontmatter={data} />
</div>
)
}
});
document.addEventListener("DOMContentLoaded", () => {
const hash = window.location.hash;
if (hash) {
const tabTrigger = document.querySelector(
`a[href="${hash}"], a[data-tabs-target="${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
{
SHOW_DEBUG && (
<div
class="hidden rounded-lg bg-white p-4 dark:bg-gray-800"
id="debug-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Content_Debug />
</div>
)
}
document.querySelectorAll("a[data-tabs-target]").forEach((tab) => {
tab.addEventListener("click", () => {
const href = tab.getAttribute("href");
if (href) {
window.location.hash = href;
</div>
<script>
window.addEventListener("hashchange", () => {
const hash = window.location.hash.substring(1);
if (hash) {
const tabTrigger = document.querySelector(
`a[href="#${hash}"], a[data-tabs-target="#${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
}
});
document.addEventListener("DOMContentLoaded", () => {
const hash = window.location.hash;
if (hash) {
const tabTrigger = document.querySelector(
`a[href="${hash}"], a[data-tabs-target="${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
}
document.querySelectorAll("a[data-tabs-target]").forEach((tab) => {
tab.addEventListener("click", () => {
const href = tab.getAttribute("href");
if (href) {
window.location.hash = href;
}
});
});
});
});
</script>
</script>
</section>
</Wrapper>
</BaseLayout>

View File

@ -1,8 +1,8 @@
---
import BaseLayout from './BaseLayout.astro';
import Sidebar from '@/components/sidebar/Sidebar.astro';
import MobileToggle from '@/components/sidebar/MobileToggle.astro';
import { getSidebarConfig } from '@/components/sidebar/config';
import BaseLayout from "./BaseLayout.astro";
import Sidebar from "../components/sidebar/Sidebar.astro";
import MobileToggle from "../components/sidebar/MobileToggle.astro";
import { getSidebarConfig } from "../components/sidebar/config";
interface Props {
frontmatter: {
@ -20,12 +20,12 @@ const sidebarConfig = getSidebarConfig();
<div class="layout-with-sidebar">
<!-- Mobile Toggle -->
<MobileToggle />
<!-- Sidebar -->
<div class="sidebar-wrapper">
<Sidebar config={sidebarConfig} currentUrl={Astro.url} />
</div>
<!-- Main Content -->
<main class="main-content-with-sidebar">
<div class="container mx-auto px-4 py-8 max-w-4xl">

View File

@ -1,49 +1,49 @@
import type { DataEntry } from "astro:content"
import * as path from 'path'
import { findUp } from 'find-up'
import { sync as read } from '@polymech/fs/read'
import { sync as exists } from '@polymech/fs/exists'
import { env } from '@/base/index.js'
import { env } from '../base/index.js'
import { gallery } from '@polymech/astro-base/base/media.js';
import { get } from '@polymech/commons/component'
import { PFilterValid } from '@polymech/commons/filter'
import { IAssemblyData } from '@polymech/cad'
import { logger as log } from '@/base/index.js'
import { logger as log } from '../base/index.js'
import { translate } from "../base/i18n.js"
import { slugify } from '../base/strings.js'
import { loadConfig } from '../app/config-loader.js'
import { filesEx, forward_slash, resolveConfig, template } from '@polymech/commons'
import { ICADNodeSchema, IComponentConfig } from '@polymech/commons/component'
import { DataEntry } from "astro:content"
import type { Loader, LoaderContext } from 'astro/loaders'
import { PolymechInstance } from '../registry.js';
// Access config safely
const c = () => {
const cfg = PolymechInstance.getConfig();
if (!cfg) throw new Error("Polymech configuration missing. Ensure setConfig is called.");
return cfg;
}
import { AppConfig } from "../app/config.schema.js"
const config = (): AppConfig => PolymechInstance.getConfig();
// Config Accessors
const PRODUCT_ROOT = () => c().products?.root || '';
const PRODUCT_GLOB = () => c().products?.glob || '';
const PRODUCT_ROOT = () => config().products?.root || '';
const PRODUCT_GLOB = () => config().products?.glob || '';
const PRODUCT_DIR = (rel: string) => path.join(PRODUCT_ROOT(), rel);
const CAD_MAIN_MATCH = (product: string) => template(c().cad?.main_match || '', { product });
const CAD_MAIN_MATCH = (product: string) => template(config().cad?.main_match || '', { product });
const CAD_URL = (file: string, variables: Record<string, string>) =>
template(c().assets?.cad_url || '', { file, ...variables });
template(config().assets?.cad_url || '', { file, ...variables });
const CAD_EXTENSIONS = () => c().cad?.extensions || [];
const CAD_EXTENSIONS = () => config().cad?.extensions || [];
const CAD_MODEL_EXT = ".tree.json";
const CAD_EXPORT_CONFIGURATIONS = () => c().cad?.export_configurations;
const CAD_DEFAULT_CONFIGURATION = () => c().cad?.default_configuration || '';
const CAD_EXPORT_CONFIGURATIONS = () => config().cad?.export_configurations;
const CAD_DEFAULT_CONFIGURATION = () => config().cad?.default_configuration || '';
// Product Branches
const PRODUCT_BRANCHES = () => {
const enabled = c().products?.enabled;
const enabled = config().products?.enabled;
const resolvedPath = enabled ? path.resolve(enabled) : null;
return (resolvedPath && exists(resolvedPath)) ? read(resolvedPath, 'json') : null;
}
@ -272,3 +272,32 @@ export function loader(branch: string): Loader {
load
};
}
export const group_path = (item) => item.id.split("/")[1]
const group_label = async (text: string, locale) => await translate(slugify(text), config().i18n.source_language, locale)
const group = async (items, locale) => {
return items.reduce(async (accPromise, item) => {
const acc = await accPromise
const id = group_path(item)
let key: string = await group_label(id, locale)
key = key.charAt(0).toUpperCase() + key.slice(1)
if (!acc[key]) {
acc[key] = []
}
acc[key].push(item)
return acc
}, {})
}
export const group_by_path = async (items, locale): Promise<IComponentConfigEx[]> => await group(items, locale)
export const mailto = (to: string, subject: string, body: string): string => {
const encode = (str: string) => encodeURIComponent(str).replace(/%20/g, '+');
return `mailto:${encode(to)}?subject=${encode(subject)}&body=${encode(body)}`;
}
export const item_checkout = async (item: IComponentConfig) => {
// return `mailto:${DEFAULT_CONTACT}?subject=${item.name}&body=${""}`
}

View File

@ -1,5 +1,6 @@
import type { IComponentNode, IComponentConfig } from '@polymech/commons'
import config from "../app/config.json"
import { PolymechInstance } from '../registry.js';
const config = () => PolymechInstance.getConfig();
interface ProductJsonLD {
'@context': 'https://schema.org'
@ -21,18 +22,18 @@ interface ProductJsonLD {
}
}
export const get = async (node: IComponentNode, component: IComponentConfig, opts:{
url?:string
export const get = async (node: IComponentNode, component: IComponentConfig, opts: {
url?: string
}): Promise<ProductJsonLD> => {
const jsonLD: ProductJsonLD = {
'@context': 'https://schema.org',
'@type': 'Product',
'@type': 'Product',
name: component.name,
description: component.keywords,
sku: component.code,
brand: {
'@type': 'Brand',
name: config.ecommerce?.brand || config.site.title
name: config().ecommerce?.brand || config().site.title
}
}
if (component.image?.url) {
@ -43,9 +44,9 @@ export const get = async (node: IComponentNode, component: IComponentConfig, opt
jsonLD.offers = {
'@type': 'Offer',
price: component.price,
priceCurrency: config.ecommerce?.currencyCode || 'EU',
priceCurrency: config().ecommerce?.currencyCode || 'EU',
availability: 'https://schema.org/InStock',
url: opts.url || config.site.base_url
url: opts.url || config().site.base_url
}
}
return jsonLD

View File

@ -1,7 +1,5 @@
import type { IComponentNode, IComponentConfig } from '@polymech/commons'
import config from "../app/config.json"
interface GoogleMerchantProduct {
id: string
title: string
@ -16,7 +14,7 @@ interface GoogleMerchantProduct {
}
export const get = async (node: IComponentNode, config: IComponentConfig, opts: {
url?:string
url?: string
}): Promise<GoogleMerchantProduct> => {
const product: GoogleMerchantProduct = {
id: config.code,

View File

@ -1,18 +1,18 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import BaseLayout from "../layouts/BaseLayout.astro";
Astro.redirect("/en/home");
---
<BaseLayout>
<section>
<div
class="flex flex-col gap-12 h-full justify-between p-4 text-center py-20">
class="flex flex-col gap-12 h-full justify-between p-4 text-center py-20"
>
<div class="max-w-xl mx-auto">
<h1
class="text-lg text-neutral-600 tracking-tight text-balance">
<h1 class="text-lg text-neutral-600 tracking-tight text-balance">
404 Page not found
</h1>
<p class="text-sm text-balance ">
<p class="text-sm text-balance">
The page you are looking for does not exist. Please try again. If the
problem persists, please contact us.
</p>
@ -21,13 +21,15 @@ Astro.redirect("/en/home");
href="/"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 duration-300 rounded-xl w-full justify-between">
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 duration-300 rounded-xl w-full justify-between"
>
<span class="relative uppercase text-xs text-orange-600"
>Go home</span
>
<div
aria-hidden="true"
class="w-12 text-orange-600 transition duration-300 -translate-y-7 group-hover:translate-y-7">
class="w-12 text-orange-600 transition duration-300 -translate-y-7 group-hover:translate-y-7"
>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -35,12 +37,12 @@ Astro.redirect("/en/home");
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
<div class="h-14 flex">
@ -50,12 +52,12 @@ Astro.redirect("/en/home");
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
</div>
@ -64,13 +66,13 @@ Astro.redirect("/en/home");
href="/forms/contact"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center hover:bg-black duration-300 rounded-xl w-full justify-between">
<span class="relative uppercase text-xs "
>Contact us</span
>
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center hover:bg-black duration-300 rounded-xl w-full justify-between"
>
<span class="relative uppercase text-xs">Contact us</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7"
>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -78,12 +80,12 @@ Astro.redirect("/en/home");
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
<div class="h-14 flex">
@ -93,12 +95,12 @@ Astro.redirect("/en/home");
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
</div>

View File

@ -1,28 +1,29 @@
---
import { getCollection, render } from "astro:content"
import Resources from "@/layouts/Resources.astro"
import BaseLayout from "@/layouts/BaseLayout.astro";
import Sidebar from "@polymech/astro-base/components/sidebar/Sidebar.astro"
import MobileToggle from "@polymech/astro-base/components/sidebar/MobileToggle.astro"
import { getSidebarConfig } from '@polymech/astro-base/config/sidebar';
import ResourceCard from "@polymech/astro-base/components/resources/ResourceCard.astro";
import Resources from "../../../layouts/Resources.astro"
import BaseLayout from "../../../layouts/BaseLayout.astro";
import Sidebar from "../../../components/sidebar/Sidebar.astro"
import MobileToggle from "../../../components/sidebar/MobileToggle.astro"
import { getSidebarConfig } from '../../../components/sidebar/config';
import ResourceCard from "../../../components/resources/ResourceCard.astro";
import { generateBreadcrumbs, calculateReadingTime, getStaticPaths_fs } from '@polymech/astro-base/base/collections';
import { generateBreadcrumbs, calculateReadingTime, getStaticPaths_fs } from '../../../base/collections';
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import {translate } from "../../../base/i18n.js";
import Translate from "../../../components/i18n.astro";
import { I18N_SOURCE_LANGUAGE } from "../../../app/config.js";
const collectionName = 'resources';
const collectionDescription = 'Discover insights, tutorials, and best practices from our collection of technical articles and resources.';
import { PolymechInstance } from '@polymech/astro-base/registry';
import { PolymechInstance } from '../../../registry';
export async function getStaticPaths() {
const config = PolymechInstance.getConfig();
const collectionName = 'resources';
const paths = await getStaticPaths_fs(getCollection, collectionName, config.LANGUAGES_PROD, config.COLLECTION_FILTERS);
const paths = await getStaticPaths_fs(getCollection, collectionName, config.core.languages_prod, { });
// Add root path for each language
config.LANGUAGES_PROD?.forEach((lang) => {
config.core.languages_prod?.forEach((lang) => {
paths.push({
params: {
locale: lang

View File

@ -1,30 +1,31 @@
---
import { getCollection, render } from "astro:content"
import Resources from "@/layouts/Resources.astro"
import BaseLayout from "@/layouts/BaseLayout.astro";
import Sidebar from "@polymech/astro-base/components/sidebar/Sidebar.astro"
import MobileToggle from "@polymech/astro-base/components/sidebar/MobileToggle.astro"
import { getSidebarConfig } from '@polymech/astro-base/config/sidebar';
import ResourceCard from "@/components/resources/ResourceCard.astro";
import { LANGUAGES_PROD, COLLECTION_FILTERS } from "config/config.js"
import Resources from "../../../layouts/Resources.astro"
import BaseLayout from "../../../layouts/BaseLayout.astro";
import Sidebar from "../../../components/sidebar/Sidebar.astro"
import MobileToggle from "../../../components/sidebar/MobileToggle.astro"
import { generateBreadcrumbs, calculateReadingTime, getStaticPaths_fs } from '@polymech/astro-base/base/collections';
import { getSidebarConfig } from '../../../config/sidebar';
import ResourceCard from "../../../components/resources/ResourceCard.astro";
import { generateBreadcrumbs, calculateReadingTime, getStaticPaths_fs } from '../../../base/collections';
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { I18N_SOURCE_LANGUAGE } from "../../../app/config.js";
const collectionName = 'resources';
const collectionDescription = 'Discover insights, tutorials, and best practices from our collection of technical articles and resources.';
import { PolymechInstance } from '@polymech/astro-base/registry';
export async function getStaticPaths() {
debugger;
const config = PolymechInstance.getConfig();
const collectionName = 'resources';
const paths = await getStaticPaths_fs(getCollection, collectionName, LANGUAGES_PROD, COLLECTION_FILTERS);
const paths = await getStaticPaths_fs(getCollection, collectionName, config.core.languages_prod, {});
// Add root path for each language
LANGUAGES_PROD.forEach((lang) => {
config.core.languages_prod.forEach((lang) => {
paths.push({
params: {
locale: lang,

View File

@ -1,84 +1,95 @@
---
import Resources from "@/layouts/Resources.astro";
import ResourceCard from "@/components/resources/ResourceCard.astro";
import Resources from "../../../../layouts/Resources.astro";
import ResourceCard from "../../../../components/resources/ResourceCard.astro";
import { getCollection } from "astro:content";
import { LANGUAGES_PROD } from "config/config.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { LANGUAGES_PROD } from "../../../../app/config.js";
import Translate from "../../../../components/i18n.astro";
export async function getStaticPaths() {
const allPosts = await getCollection("resources");
const uniqueTags = [
...new Set(allPosts.map((post) => post.data.tags).flat()),
];
const paths: any[] = [];
// Generate paths for each locale and tag combination
LANGUAGES_PROD.forEach((locale) => {
uniqueTags.forEach((tag) => {
const filteredPosts = allPosts.filter((post) =>
post.data.tags.includes(tag)
post.data.tags.includes(tag),
);
paths.push({
params: {
params: {
locale,
tag
tag,
},
props: {
props: {
posts: filteredPosts,
locale,
tag
tag,
},
});
});
});
return paths;
}
const { tag, locale } = Astro.params;
const { posts } = Astro.props;
const collectionName = posts.length > 0 ? posts[0].collection : 'resources';
const collectionName = posts.length > 0 ? posts[0].collection : "resources";
---
<Resources
<Resources
frontmatter={{
title: `${collectionName.charAt(0).toUpperCase() + collectionName.slice(1)} tagged with ${tag}`,
description: `${posts.length} ${collectionName}${posts.length !== 1 ? 's' : ''} found`,
description: `${posts.length} ${collectionName}${posts.length !== 1 ? "s" : ""} found`,
breadcrumb: true,
bottomNav: 'TOP'
bottomNav: "TOP",
}}
collectionName={collectionName}
>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-6 py-4">
{posts.map((post) => (
<ResourceCard
url={`/${locale}/${collectionName}/${post.id}`}
title={post.data.title}
description={post.data.description}
alt={post.data.image?.alt || post.data.title}
pubDate={post.data.pubDate.toLocaleDateString()}
author={post.data.author}
image={post.data.image?.url}
tags={post.data.tags}
path={post.id}
locale={locale}
contentId={post.id}
collectionName={collectionName}
/>
))}
{
posts.map((post) => (
<ResourceCard
url={`/${locale}/${collectionName}/${post.id}`}
title={post.data.title}
description={post.data.description}
alt={post.data.image?.alt || post.data.title}
pubDate={post.data.pubDate.toLocaleDateString()}
author={post.data.author}
image={post.data.image?.url}
tags={post.data.tags}
path={post.id}
locale={locale}
contentId={post.id}
collectionName={collectionName}
/>
))
}
</div>
<!-- Back to Tags -->
<div class="mt-12 text-center">
<a
<a
href={`/${locale}/${collectionName}/tags/`}
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
<Translate>Back to All Tags</Translate>
</a>

View File

@ -1,12 +1,13 @@
---
import StoreLayout from "@/layouts/StoreLayout.astro";
import { getCollection } from "astro:content";
import { LANGUAGES_PROD } from "config/config.js";
import StoreEntries from "@/components/store/StoreEntries.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import Wrapper from "@/components/containers/Wrapper.astro";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { slugify } from "@/base/strings.js";
import { LANGUAGES_PROD } from "../../../app/config.js";
import StoreEntries from "../../../components/store/StoreEntries.astro";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import Wrapper from "../../../components/containers/Wrapper.astro";
import Translate from "../../../components/i18n.astro";
import { slugify } from "../../../base/strings.js";
export async function getStaticPaths() {
const view = "store";

View File

@ -1,25 +1,28 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro"
import { getCollection } from "astro:content"
import StoreEntries from "@/components/store/StoreEntries.astro"
const allProducts = await getCollection("store")
const locale = Astro.currentLocale || "en"
import BaseLayout from "../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
import StoreEntries from "../components/store/StoreEntries.astro";
const allProducts = await getCollection("store");
const locale = Astro.currentLocale || "en";
---
<BaseLayout>
<BaseLayout>
<section>
<div class="py-2 space-y-2">
<div class="grid md:grid-cols-2 lg:grid-cols-2 gap-2 ">
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-2 gap-2"></div>
<a
href="/blog/home"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 justify-between text-xs text-orange-600 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-xl">
class="relative group overflow-hidden pl-4 justify-between text-xs text-orange-600 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-xl"
>
<span class="relative uppercase text-xs">Read all articles</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7"
>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -27,12 +30,12 @@ const locale = Astro.currentLocale || "en"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
<div class="h-14 flex">
@ -42,12 +45,12 @@ const locale = Astro.currentLocale || "en"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"></path>
</svg>
</div>
</div>
@ -56,11 +59,10 @@ const locale = Astro.currentLocale || "en"
</section>
<section>
<div class="grid sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-2">
{
allProducts.map((post) => (
<StoreEntries
url={ locale + "/store/" + post.id}
url={locale + "/store/" + post.id}
title={post.data.title}
price={post.data.price}
type={post.data.type}
@ -71,6 +73,4 @@ const locale = Astro.currentLocale || "en"
}
</div>
</section>
</BaseLayout>

View File

@ -1,3 +1,4 @@
import { AppConfig } from './app/config.schema.js'
interface PolymechConfig {
// Config groups matching app-config.json
site?: any;
@ -87,7 +88,7 @@ if (!G.__MYPAGES_CONFIG__) {
class PolymechRegistry {
private static instance: PolymechRegistry;
private config: PolymechConfig;
private config: AppConfig;
private callbacks: Record<string, Function[]> = {};
constructor() {
@ -103,19 +104,19 @@ class PolymechRegistry {
}
// Set configuration from host app
setConfig(config: PolymechConfig) {
setConfig(config: AppConfig) {
// Merge the new config into the existing one and update the global store.
const newConfig = { ...this.config, ...config };
this.config = newConfig;
G.__MYPAGES_CONFIG__ = newConfig;
// Trigger callbacks
this.config.callbacks?.onConfigUpdate?.(this.config);
(this.config as any).callbacks?.onConfigUpdate?.(this.config);
this.emit('configUpdate', { newConfig: this.config });
}
// Get configuration in components
getConfig(): PolymechConfig {
getConfig(): AppConfig {
// The config property is now always synced with the global store.
return this.config;
}
@ -136,9 +137,10 @@ class PolymechRegistry {
// Convenience getters
// Convenience getters (Mapping new config structure to old accessors if needed, or just helpers)
get productCategory() { return this.config.productCategory || 'Unknown'; }
get languages() { return this.config.core?.languages || ['en']; }
get LANGUAGES_PROD() { return this.config.core?.languages_prod || ['en']; }
get COLLECTION_FILTERS() { return {}; }
// products was string[]? in old config it was string[], now it's an object config.products
// Old: get products() { return this.config.products || []; }
// We can leave 'products' as the category list if it exists in 'site' or somewhere?
@ -146,8 +148,6 @@ class PolymechRegistry {
// If the old getter meant 'list of product categories', that's not in the new config yet?
// Let's assume accessing raw config is preferred now.
get apiEndpoints() { return this.config.apiEndpoints || {}; }
get COLLECTION_FILTERS() { return this.config.COLLECTION_FILTERS || {}; }
}
export const PolymechInstance = PolymechRegistry.getInstance();

View File

@ -1,9 +1,9 @@
import fs from 'fs';
import path from 'path';
import { resolveVariables, resolve } from '@polymech/commons'
// --- Debug Configuration ---
const enableDebugSuccess = false;
const enableDebugErrors = false;
const enableDebugErrors = false;
/**
* Checks if a given file path exists and is a file.
@ -72,7 +72,7 @@ export function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL
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);
@ -88,7 +88,7 @@ export function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL
}
// 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);
@ -101,7 +101,7 @@ export function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL
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.`);
}

View File

@ -12,6 +12,7 @@
"isolatedModules": true,
// Astro will directly run your TypeScript code, no transpilation needed.
"outDir": "./dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
@ -43,9 +44,14 @@
},
"include": [
".astro/types.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.astro"
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.astro"
],
"exclude": [
"src/components/sidebar/**/*",
"src/config/astro-config.ts",
"src/components/polymech/renderer.ts"
],
"files": [
"src/index.ts"

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias:
{
"src/*": "/src/*",
"app/*": "/src/app/*",
},
},
});