config | pm-astro cli

This commit is contained in:
babayaga 2025-12-27 19:36:03 +01:00
parent a59625c4b1
commit 3da1040d72
18 changed files with 26 additions and 527 deletions

View File

@ -23,7 +23,7 @@
"format": "unix-time"
}
],
"default": "2025-12-27T17:43:28.651Z"
"default": "2025-12-27T18:35:25.856Z"
},
"description": {
"type": "string",

File diff suppressed because one or more lines are too long

View File

@ -143,6 +143,7 @@
"i18n": {
"store": "${OSR_ROOT}/i18n-store/store-${LANG}.json",
"cache": true,
"source_language": "en",
"asset_path": "${SRC_DIR}/${SRC_NAME}-${DST_LANG}${SRC_EXT}"
},
"products": {

View File

@ -10,15 +10,14 @@ import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
import { PolymechInstance } from "@polymech/astro-base/registry";
import { loadConfig } from './src/app/config-loader.js';
import { I18N_SOURCE_LANGUAGE } from "./src/app/constants.js"
import { loadConfig } from '@polymech/astro-base/app/config-loader';
// import domainExpansion from '@domain-expansion/astro';
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
const argv = yargs(hideBin(process.argv)).argv;
const config = loadConfig(I18N_SOURCE_LANGUAGE);
const config = loadConfig();
PolymechInstance.setConfig({
...config,

5
package-lock.json generated
View File

@ -224,6 +224,7 @@
"@polymech/i18n": "file:../../../polymech-mono/packages/i18n",
"@polymech/kbot-d": "file:../../../polymech-mono/packages/kbot",
"@polymech/log": "file:../../../polymech-mono/packages/log",
"@types/yargs": "^17.0.35",
"astro": "^5.13.2",
"exifreader": "^4.31.1",
"find-up": "^7.0.0",
@ -237,6 +238,7 @@
"mdast-util-to-string": "^4.0.0",
"node-xlsx": "^0.24.0",
"p-map": "^7.0.3",
"quicktype-core": "^23.2.6",
"react-jsx-parser": "^2.4.0",
"reading-time": "^1.5.0",
"rehype-stringify": "^10.0.1",
@ -247,6 +249,9 @@
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"yargs": "^18.0.0"
},
"bin": {
"pm-astro": "dist/bin.js"
}
},
"../polymech-mono/packages/cache": {

View File

@ -4,11 +4,11 @@
"private": true,
"type": "module",
"scripts": {
"generate:config": "npx vite-node scripts/generate-app-config.ts",
"generate:config": "pm-astro build:config",
"dev": "npm run generate:config && astro dev --mode dev --host=0.0.0.0",
"dev:all": "concurrently \"npm run dev\" \"npm run serve:products\"",
"start": "astro dev",
"build": "npm run generate:config && astro build -- --logLevel=info --branch=test",
"build": "pm-astro build -- --logLevel=info --branch=site-dev",
"test:build": "astro build ; cd dist ; serve",
"preview": "astro preview",
"astro": "astro",

View File

@ -1,64 +0,0 @@
import fs from 'fs';
import path from 'path';
import { quicktype, InputData, jsonInputForTargetLanguage } from 'quicktype-core';
const CONFIG_PATH = path.resolve('./app-config.json');
const OUTPUT_SCHEMA_PATH = path.resolve('./src/app/config.schema.ts');
const OUTPUT_DTS_PATH = path.resolve('./src/app/config.d.ts');
async function main() {
console.log(`Reading config from ${CONFIG_PATH}...`);
const configContent = fs.readFileSync(CONFIG_PATH, 'utf8');
// 1. Generate TypeScript Definitions (d.ts) FIRST
console.log('Generating TypeScript definitions...');
const tsInput = jsonInputForTargetLanguage("ts");
await tsInput.addSource({
name: "AppConfig",
samples: [configContent]
});
const tsInputData = new InputData();
tsInputData.addInput(tsInput);
const tsResult = await quicktype({
inputData: tsInputData,
lang: "ts",
rendererOptions: {
"just-types": "true",
"acronym-style": "original"
}
});
const tsCode = tsResult.lines.join('\n');
fs.writeFileSync(OUTPUT_DTS_PATH, tsCode);
console.log(`Wrote TypeScript definitions to ${OUTPUT_DTS_PATH}`);
// 2. Generate Zod Schema from Types using ts-to-zod
console.log('Generating Zod schema from types...');
try {
const { execSync } = await import('child_process');
// ts-to-zod <input> <output>
// Use relative paths to avoid Windows path concatenation issues with ts-to-zod
const relDts = path.relative(process.cwd(), OUTPUT_DTS_PATH);
const relSchema = path.relative(process.cwd(), OUTPUT_SCHEMA_PATH);
execSync(`npx ts-to-zod "${relDts}" "${relSchema}"`, { stdio: 'inherit', cwd: process.cwd() });
// Append export type AppConfig
fs.appendFileSync(OUTPUT_SCHEMA_PATH, `\nexport type AppConfig = z.infer<typeof appConfigSchema>;\n`);
console.log(`Wrote Zod schema to ${OUTPUT_SCHEMA_PATH}`);
} catch (error) {
console.error('Failed to generate Zod schema:', error);
throw error;
}
}
main().catch(err => {
console.error('Error fetching/generating config:', err);
process.exit(1);
});

View File

@ -1,9 +1,8 @@
import { loadConfig } from '../src/app/config-loader.js';
import { I18N_SOURCE_LANGUAGE } from "../src/app/constants.js";
try {
const config = loadConfig(I18N_SOURCE_LANGUAGE);
const config = loadConfig();
console.log('LOGGING_NAMESPACE:', config.core.logging_namespace);
console.log('SHOW_GALLERY:', config.features.show_gallery);
} catch (e) {

View File

@ -1,108 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { substitute } from "@polymech/commons/variables";
import { appConfigSchema } from "./config.schema.js";
import type { AppConfig } from "./config.schema.js";
const I18N_SOURCE_LANGUAGE = 'en';
const LIBRARY_CONFIG_PATH = path.resolve("./app-config.json");
const USER_CONFIG_DEFAULT_PATH = path.resolve("./app-config.local.json");
function deepMerge(target: any, source: any): any {
if (typeof source !== 'object' || source === null) {
return source; // Primitives or null overwrite
}
if (Array.isArray(source)) {
return source; // Arrays overwrite
}
if (typeof target !== 'object' || target === null || Array.isArray(target)) {
return source; // Target is not mergeable object, overwrite
}
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const val = source[key];
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
result[key] = deepMerge(result[key], val);
} else {
result[key] = val;
}
}
}
return result;
}
export function loadConfig(
locale: string = I18N_SOURCE_LANGUAGE,
libraryPath: string = LIBRARY_CONFIG_PATH
): AppConfig {
// 1. Load Library Config (Defaults)
let rawLibraryContent: string;
try {
rawLibraryContent = fs.readFileSync(libraryPath, 'utf-8');
} catch (error) {
throw new Error(`Failed to read library config file at ${libraryPath}: ${error}`);
}
const variables = {
LANG: locale
};
const substitutedLibraryContent = substitute(false, rawLibraryContent, variables);
let libraryConfig: any;
try {
libraryConfig = JSON.parse(substitutedLibraryContent);
} 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
const argv: any = yargs(hideBin(process.argv)).parseSync ? yargs(hideBin(process.argv)).parseSync() : (yargs(hideBin(process.argv)) as any).argv;
// 3. Determine User Config Path
// Check for --config <path>
const userConfigPath = argv.config ? path.resolve(argv.config) : USER_CONFIG_DEFAULT_PATH;
// 4. Load User Config (if exists)
let userConfig: any = {};
if (fs.existsSync(userConfigPath)) {
try {
const rawUserContent = fs.readFileSync(userConfigPath, 'utf-8');
const substitutedUserContent = substitute(false, rawUserContent, variables);
userConfig = JSON.parse(substitutedUserContent);
} catch (error) {
console.warn(`Failed to load or parse user config at ${userConfigPath}: ${error}`);
}
}
// 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?
// Actually zod 'strip' is default in safeParse? No, usually it passes through unless strict().
// We should probably rely on valid keys overwriting.
// 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);
if (!result.success) {
// Pretty print error if possible or just message
throw new Error(`Config validation failed: ${result.error.message}`);
}
return result.data;
}

7
src/app/config.d.ts vendored
View File

@ -76,9 +76,10 @@ export interface FooterLeft {
}
export interface I18N {
store: string;
cache: boolean;
asset_path: string;
store: string;
cache: boolean;
source_language: string;
asset_path: string;
}
export interface Metadata {

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

@ -3,11 +3,10 @@ import { IMAGE_PRESET, E_BROADBAND_SPEED } from "./network.js"
import { resolve, template } from '@polymech/commons'
import { sync as read } from '@polymech/fs/read'
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)
import { loadConfig } from '@polymech/astro-base/app/config-loader'
const config = loadConfig()
export const OSR_ROOT = () => path.resolve(resolve(config.core.osr_root))
export const FILE_SERVER_DEV = config.dev.file_server

View File

View File

@ -1,41 +0,0 @@
import { translate } from '../base/i18n.js'
import { I18N_SOURCE_LANGUAGE } from 'config/config.js'
import config from "./config.json"
import pMap from 'p-map'
export const items = async (opts: { locale: string }) => {
const _T = async (text: string) => await translate(text, I18N_SOURCE_LANGUAGE, opts.locale)
return [
{
"href": `/${opts.locale}`,
"title": _T("Home"),
"ariaLabel": "Home",
"class": "hover:text-orange-600"
},
{
"href": `/resources/home`,
"title": _T("Resources"),
"ariaLabel": "Resources",
"class": "hover:text-orange-600"
}
]
}
const isAbsoluteUrl = (url: string): boolean => /^[a-zA-Z]+:/.test(url);
const createFooterLinks = async (items: any[], locale: string) => {
const _T = async (text: string) => await translate(text, I18N_SOURCE_LANGUAGE, locale);
return await pMap(items, async (item) => {
const translatedText = await _T(item.text); // Single translation call
return {
"href": isAbsoluteUrl(item.href) ? item.href : `/${locale}${item.href}`,
"title": translatedText, // Use cached translation
"ariaLabel": translatedText, // Use cached translation
"class": "hover:text-orange-600"
};
});
};
export const footer_left = (locale: string) => createFooterLinks(config.footer_left, locale);
export const footer_right = (locale: string) => createFooterLinks(config.footer_right, locale);

View File

@ -24,12 +24,9 @@ const imagesSchema = z.object({
type Images = z.infer<typeof imagesSchema>;
import { loadConfig } from './config-loader.js'
import { I18N_SOURCE_LANGUAGE } from "./config.js"
// Load config
const config = loadConfig(I18N_SOURCE_LANGUAGE)
import { loadConfig } from '@polymech/astro-base/app/config-loader.js'
const config = loadConfig()
export const IMAGE_PRESET: Images =
{
[E_BROADBAND_SPEED.SLOW]: config.optimization.presets.slow,

View File

@ -4,11 +4,10 @@ import { ComponentConfigSchema } from '@polymech/commons/component'
import { loader } from '@polymech/astro-base/model/component.js'
import { loader as howtoLoader } from './model/howto/howto.js'
import { loadConfig } from './app/config-loader.js'
import { I18N_SOURCE_LANGUAGE } from "./app/constants.js"
import { loadConfig } from '@polymech/astro-base/app/config-loader.js'
const config = loadConfig()
// Load config
const config = loadConfig(I18N_SOURCE_LANGUAGE)
const LIBARY_BRANCH = config.retail.library_branch
const library = defineCollection({

View File

@ -1,7 +1,5 @@
---
import "flowbite";
import Navigation from "@polymech/astro-base/components/global/Navigation.astro";
import { createMarkdownComponent } from "@/base/index.js";
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";

View File

@ -1,287 +0,0 @@
import * as path from 'path'
import { findUp } from 'find-up'
import { } from 'astro:content'
import { sync as read } from '@polymech/fs/read'
import { sync as exists } from '@polymech/fs/exists'
import { filesEx, forward_slash, resolveConfig } from '@polymech/commons'
import { ICADNodeSchema, IComponentConfig } from '@polymech/commons/component'
import { RenderedContent, DataEntry } from "astro:content"
import type { Loader, LoaderContext } from 'astro/loaders'
import {
CAD_MAIN_MATCH, PRODUCT_BRANCHES,
CAD_EXTENSIONS, CAD_MODEL_EXT, PRODUCT_DIR, PRODUCT_GLOB,
PRODUCT_ROOT, CAD_EXPORT_CONFIGURATIONS,
CAD_DEFAULT_CONFIGURATION,
CAD_URL,
parseBoolean,
DEFAULT_CONTACT,
default_image
} from 'config/config.js'
import { env } from '@/base/index.js'
import { gallery } from '@/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 { translate } from "@/base/i18n.js"
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { slugify } from "@/base/strings.js"
export const ITEM_TYPE = 'product'
export interface IComponentConfigEx extends IComponentConfig {
content: string
extra_resources?: string
shared_resources?: string
readme?: string
rel: string
}
export interface IStoreItem extends DataEntry {
data: IComponentConfigEx
rendered?: RenderedContent
}
const filterBranch = (items: { rel: string, config, path }[], branch: string) => {
if (!PRODUCT_BRANCHES) {
return items
}
const branchItems = PRODUCT_BRANCHES[branch]
if (!branchItems) {
return items
}
return items.filter((item) => branchItems.includes(item.rel))
}
export const items = (branch: string) =>{
return []
filterBranch(get(`${PRODUCT_ROOT()}/${PRODUCT_GLOB}`,
PRODUCT_ROOT(), PFilterValid.marketplace_component), branch)
}
const onComponent = async (item: IStoreItem, ctx: LoaderContext) => {
/*
const onNode = async (data: INodeCallback, configuration: string) => {
if (!CAD_EXPORT_SUB_COMPONENTS || !data.target.endsWith('.json')) {
return
}
const modelPath = `${CAD_MODEL_FILE_PATH(data.target,configuration)}`
const model: IAssemblyData = read(modelPath, 'json') as IAssemblyData
if (!model) {
return
}
const configurations = Object.keys(model.Configurations).filter((c) => {
return c !== CAD_DEFAULT_CONFIGURATION &&
c !== 'Global' &&
model.Configurations[c].Hide !== '1'
})
if (!configurations.length ||
model.Configurations?.Global?.['Configurations'] !== '1') {
return
}
}
*/
}
export const defaults = async (data: IComponentConfigEx, cwd: string, root:string): Promise<IComponentConfigEx> => {
let defaultsJSON = await findUp('defaults.json', {
stopAt: root,
cwd: cwd
});
try {
if (defaultsJSON) {
data = {
...read(defaultsJSON, 'json') as any,
...data,
};
}
} catch (error) {
}
return data;
};
const cad = async (item: IStoreItem): Promise<ICADNodeSchema[]> => {
const root = PRODUCT_ROOT()
const data: IComponentConfigEx = item.data
const itemRel = data.rel
const default_profile = env(itemRel)
const mainAssemblies = filesEx(root, CAD_MAIN_MATCH(itemRel), { absolute: false })
//log.debug(`Loading CAD/CAM for ${itemRel} in ${root} with ${CAD_MAIN_MATCH(itemRel)}`)
const cadMeta = mainAssemblies.map((file: string): ICADNodeSchema => {
const result: ICADNodeSchema = {
file,
name: path.basename(file)
}
CAD_EXTENSIONS.forEach(ext => result[ext] = decodeURIComponent(forward_slash(CAD_URL(file.replace('.SLDASM', ext), data as Record<string, string>))))
result.model = path.join(root, file.replace('.SLDASM', CAD_MODEL_EXT))
return result
})
if (!cadMeta.length) {
return []
}
item.data.cad = cadMeta
item.data.preview3d = cadMeta[0].html
if (CAD_EXPORT_CONFIGURATIONS) {
const assemblies = cadMeta.filter((assembly) => assembly.model && exists(assembly.model))
const components = cadMeta.map((assembly) => {
const modelPath = assembly.model as string
const model: IAssemblyData = read(modelPath, 'json') as IAssemblyData
if (!model) {
return
}
const configurations = Object.keys(model.Configurations).filter((c) => {
return c !== CAD_DEFAULT_CONFIGURATION &&
parseBoolean(model.Configurations[c].Hide || '')
})
if (!configurations.length ||
parseBoolean(model.Configurations?.Global?.Configurations || '')) {
return
}
log.debug(`Loading CAD/CAM for ${itemRel}`, configurations)
})
}
return cadMeta
}
const onItem = async (item: IStoreItem, ctx: LoaderContext) => {
if (!item || !item.data) {
ctx.logger.error(`Error completing ${''}: no data`);
return
}
const { logger } = ctx
let data: IComponentConfigEx = item.data
const itemRel = data.rel
const itemRelMin = itemRel.replace('products/', '')
const itemDir = PRODUCT_DIR(itemRel)
const default_profile = env(itemRel)
data.product_rel = itemRelMin
data.assets = {
renderings: [],
gallery: []
}
data.body = (read(path.join(itemDir, 'templates/shared', 'body.md')) as string) || ""
data.resources = (read(path.join(itemDir, 'templates/shared', 'resources.md')) as string) || ""
//////////////////////////////////////////
//
// Item Shared Resources
//
let resourcesDefaultPath = await findUp('resources.md', {
stopAt: PRODUCT_ROOT(),
cwd: itemDir
}) || ""
data.shared = (read(resourcesDefaultPath) as string) || ""
//////////////////////////////////////////
//
// Readme
//
let readmePath = path.join(itemDir, 'Readme.md')
data.readme = (read(readmePath) as string) || ""
//////////////////////////////////////////
//
// Variables
//
data = await defaults(data, itemDir, PRODUCT_ROOT());
data = {
...data,
...default_profile.variables,
product_rel_min: itemRelMin,
}
data = resolveConfig(data as Record<string, string>) as IComponentConfigEx
item.data = data
//////////////////////////////////////////
//
// Extensions, CAD, Media, etcetera etcetera :)
//
data.cad = await cad(item)
data.assets.renderings = await gallery('renderings', data.rel)
data.assets.gallery = await gallery('media/gallery', data.rel)
data.image = data.assets.renderings[0] || default_image()
data.assets.showcase = await gallery('media/showcase', data.rel)
data.assets.samples = await gallery('media/samples', data.rel)
data.checkout = await item_checkout(data)
}
export function loader(branch: string): Loader {
const load = async ({
config,
logger,
watcher,
parseData,
store,
generateDigest }: LoaderContext) => {
store.clear();
let products = items(branch)
for (const item of products) {
const product: any = item.config
const id = product.slug;
const data = {
rel: item.rel,
slug: id,
id,
title: product.name,
type: ITEM_TYPE,
highlights: [],
components: [],
...product,
}
//const parsedData = await parseData({ id, data: data });
const storeItem = {
digest: await generateDigest(data),
filePath: id,
assetImports: [],
id: `${item.rel}`,
data: data
}
await onItem(storeItem, {
logger,
watcher,
parseData,
store,
generateDigest
} as any)
storeItem.data['config'] = JSON.stringify(storeItem.data, null, 2)
store.set(storeItem)
}
}
return {
name: `astro:store:${ITEM_TYPE}`,
load
};
}
export const group_path = (item) => item.id.split("/")[1]
const group_label = async (text: string, locale) => await translate(slugify(text), 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=${""}`
}