refactor polymech-astro

This commit is contained in:
babayaga 2025-12-26 14:05:55 +01:00
parent 5df7b5915f
commit 2123ae4361
15 changed files with 964 additions and 6 deletions

View File

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

View File

@ -22,8 +22,7 @@ export function loadConfig(
}
const variables = {
LANG: locale,
...process.env
LANG: locale
};
const substitutedContent = substitute(false, rawContent, variables);

View File

@ -0,0 +1,117 @@
---
import { getProcessedCoilsData } from '../../utils/modbus-data';
import { getModbusFunctionName, getFunctionCategory } from '../../utils/modbusUtils';
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>
</div>
</div>
))}
</div>
<style>
.modbus-coils-tables {
margin: 2rem 0;
}
.group-section {
margin-bottom: 3rem;
}
.group-section h3 {
color: var(--theme-text-accent);
border-bottom: 2px solid var(--theme-accent);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
font-size: 1.25rem;
font-weight: 600;
}
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--theme-bg-accent);
}
.modbus-table {
width: 100%;
border-collapse: collapse;
background: var(--theme-bg);
font-size: 0.875rem;
}
.modbus-table th {
background: var(--theme-bg-accent);
color: var(--theme-text-accent);
font-weight: 600;
padding: 0.75rem;
text-align: left;
border-bottom: 2px solid var(--theme-accent);
}
.modbus-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--theme-bg-accent);
color: var(--theme-text);
}
.modbus-table tbody tr:hover {
background: var(--theme-bg-accent);
}
.modbus-table tbody tr:last-child td {
border-bottom: none;
}
/* FC cell styling */
.fc-cell {
font-family: monospace;
font-weight: 600;
text-align: center;
color: var(--theme-text-accent);
font-size: 0.875rem;
}
/* Responsive design */
@media (max-width: 768px) {
.modbus-table {
font-size: 0.75rem;
}
.modbus-table th,
.modbus-table td {
padding: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,258 @@
---
import {
getProcessedRegistersData,
getRegisterDescription,
} from "@polymech/astro-base/utils/modbus-data.js";
import { parseRegisterName } from "@polymech/astro-base/utils/modbusUtils.js";
const groupedRegisters = getProcessedRegistersData();
const groupNames = Object.keys(groupedRegisters).sort();
---
<div class="modbus-registers-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>Description</th>
</tr>
</thead>
<tbody>
{groupedRegisters[groupName].map((register) => {
const parsed = parseRegisterName(register.name);
const baseName = register.name.split("(")[0];
const description = getRegisterDescription(register.address);
return (
<tr>
<td class="fc-cell">{register.type}</td>
<td>{register.address}</td>
<td>
<div class="name-cell">
<div class="base-name">{baseName}</div>
{parsed && (
<div
class={`enum-values ${parsed.isFlags ? "flags" : "enum"}`}
>
{parsed.enumValues.map(({ val, label }, index) => (
<span
class="enum-item"
data-key={`${register.address}-${val}-${index}`}
>
<span class="enum-value">{val}</span>
<span class="enum-label">{label}</span>
</span>
))}
</div>
)}
</div>
</td>
<td>{register.component}</td>
<td class="description-cell">{description || ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))
}
</div>
<style>
.modbus-registers-tables {
margin: 2rem 0;
}
.group-section {
margin-bottom: 3rem;
}
.group-section h3 {
color: var(--theme-text-accent);
border-bottom: 2px solid var(--theme-accent);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
font-size: 1.25rem;
font-weight: 600;
}
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--theme-bg-accent);
}
.modbus-table {
width: 100%;
border-collapse: collapse;
background: var(--theme-bg);
font-size: 0.875rem;
}
.modbus-table th {
background: var(--theme-bg-accent);
color: var(--theme-text-accent);
font-weight: 600;
padding: 0.75rem;
text-align: left;
border-bottom: 2px solid var(--theme-accent);
white-space: nowrap;
}
.modbus-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--theme-bg-accent);
color: var(--theme-text);
}
.modbus-table tbody tr:hover {
background: var(--theme-bg-accent);
}
.modbus-table tbody tr:last-child td {
border-bottom: none;
}
/* Name cell styling */
.name-cell {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
}
.base-name {
font-weight: 600;
color: var(--theme-text);
}
/* Enum and flag styling */
.enum-values {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
margin-top: 0.25rem;
}
.enum-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: var(--theme-bg-accent);
border: 1px solid var(--theme-accent-light);
width: fit-content;
max-width: 100%;
}
.enum-values.enum .enum-item {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.3);
color: var(--theme-text);
}
.enum-values.flags .enum-item {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: var(--theme-text);
}
.enum-value {
font-family: monospace;
font-weight: 600;
color: var(--theme-text-accent);
}
.enum-label {
font-family: monospace;
font-size: 0.7rem;
color: var(--theme-text-light);
text-transform: uppercase;
}
/* FC cell styling */
.fc-cell {
font-family: monospace;
font-weight: 600;
text-align: center;
color: var(--theme-text-accent);
font-size: 0.875rem;
}
/* Description cell styling */
.description-cell {
font-style: italic;
color: var(--theme-text-light);
max-width: 200px;
}
/* Responsive design */
@media (max-width: 768px) {
.modbus-table {
font-size: 0.75rem;
}
.modbus-table th,
.modbus-table td {
padding: 0.5rem;
}
.name-cell {
min-width: 150px;
gap: 0.375rem;
}
.enum-values {
font-size: 0.7rem;
}
.enum-item {
padding: 0.1rem 0.25rem;
}
.enum-label {
font-size: 0.65rem;
}
}
@media (max-width: 640px) {
.table-container {
border-radius: 4px;
}
.modbus-table th,
.modbus-table td {
padding: 0.375rem;
}
.name-cell {
min-width: 120px;
}
.enum-values {
font-size: 0.65rem;
}
.enum-item {
padding: 0.08rem 0.2rem;
gap: 0.15rem;
}
.enum-label {
font-size: 0.6rem;
}
}
</style>

View File

@ -0,0 +1,8 @@
---
import DefaultFallback from "@/components/Default.astro"
const { expression = false, fallback = DefaultFallback } = Astro.props
const currentLocal = Astro.currentLocale || "en"
---
<div data-track={currentLocal}>
{expression ? <slot /> : <div/>}
</div>

View File

@ -0,0 +1,22 @@
---
import { DEFAULT_IMAGE_URL } from '@/app/config.js'
import { Picture } from "imagetools/components"
import { image_url } from "@/base/media.js"
interface SafeImageProps {
src: string
alt: string
fallback?: string
[propName: string]: any
}
const {
src,
alt,
fallback = DEFAULT_IMAGE_URL,
...rest
} = Astro.props as SafeImageProps
const srcSafe = await image_url(src, fallback)
---
<Picture class="" src={srcSafe} alt={alt} {...rest} />

View File

@ -0,0 +1,7 @@
---
import JsxParser from 'react-jsx-parser'
const {...rest} = Astro.props
---
<JsxParser
jsx={rest.markup}
/>

View File

@ -0,0 +1,11 @@
---
const { href, className } = Astro.props;
---
<li>
<a
href={href}
class={className}
>
<slot />
</a>
</li>

View File

@ -0,0 +1,48 @@
---
import { createMarkdownComponent } from "@/base/index.js";
import { translate } from "@/base/i18n.js";
import { I18N_SOURCE_LANGUAGE, ASSET_URL } from "config/config.js";
import { fromMarkdown } from "mdast-util-from-markdown";
import { toMarkdown } from "mdast-util-to-markdown";
import { visit } from "unist-util-visit";
import { Root, Image } from "mdast";
interface Props {
markdown: string;
className?: string;
baseImageUrl?: string;
translate?: boolean;
data?: any;
}
const {
markdown,
className = "",
baseImageUrl = "",
translate: _translate = false,
data = {},
...rest
} = Astro.props as Props;
const processImageUrls = (content: string, data: Record<string, string>) => {
const tree = fromMarkdown(content) as Root;
visit(tree, "image", (node: Image) => {
if (!node.url.startsWith("http") && !node.url.startsWith("/")) {
node.url = ASSET_URL(node.url, data);
}
});
const markup = toMarkdown(tree);
return markup;
};
const content = await translate(
processImageUrls(markdown, data),
I18N_SOURCE_LANGUAGE,
Astro.currentLocale,
);
const ReadmeContent = await createMarkdownComponent(content);
---
<div class={className}>
<ReadmeContent />
</div>

View File

@ -0,0 +1,11 @@
---
// Example: Fetch Markdown from a remote API
// and render it to HTML, at runtime.
// Using "marked" (https://github.com/markedjs/marked)
import { marked } from 'marked'
const {...rest} = Astro.props
const response = await fetch(rest.src || 'https://raw.githubusercontent.com/wiki/adam-p/markdown-here/Markdown-Cheatsheet.md')
const markdown = await response.text();
const content = marked.parse(markdown);
---
<article set:html={content} />

View File

@ -0,0 +1,53 @@
import { unified } from "unified"
import remarkParse from "remark-parse"
import remarkGfm from "remark-gfm"
import remarkRehype from "remark-rehype"
import rehypeRaw from "rehype-raw"
import emoji from "remark-emoji"
import rehypeStringify from "rehype-stringify"
import { createComponent } from "astro/runtime/server/astro-component.js"
import { renderTemplate, unescapeHTML } from "astro/runtime/server/index.js"
// Define the type for the component map
type ComponentMap = Record<string, (props: Record<string, string>) => unknown>;
// Function to convert Markdown to HTML and replace elements with Astro components
export async function markdown(input: string, componentsMap: ComponentMap = {}): Promise<unknown> {
const slots: unknown[] = [];
let slotIndex = 0;
// Ensure input is treated as UTF-8
const markdownText = new TextDecoder("utf-8").decode(new TextEncoder().encode(input));
const processedHtml = await unified()
.use(remarkParse) // Parse Markdown to AST
.use(emoji, {
accessible: true, // Defaults to false
emoticon: true, // Defaults to false
})
.use(remarkGfm) // Enable tables, strikethrough, autolinks, etc.
.use(remarkRehype, { allowDangerousHtml: true }) // Convert Markdown to HTML AST
.use(rehypeRaw) // Process raw HTML inside Markdown
.use(() => (tree) => {
function transformNode(node: { type: string; tagName?: string; properties?: Record<string, string>; value?: string; children?: any[] }) {
if (node.type === "element" && node.tagName && componentsMap[node.tagName]) {
const props = node.properties || {};
// Register the component as a slot instead of calling it directly
const slotName = `COMPONENT_SLOT_${slotIndex++}`;
slots[slotName] = { component: componentsMap[node.tagName], props };
node.type = "text";
node.value = `<!--${slotName}-->`; // Slot placeholder
}
if (node.children) {
node.children.forEach(transformNode);
}
}
transformNode(tree);
})
.use(rehypeStringify) // Convert AST back to HTML
.process(markdownText);
return createComponent(() => renderTemplate(unescapeHTML(processedHtml.toString()), slots));
}

View File

@ -0,0 +1,132 @@
---
import Link from "./link.astro";
import { createMarkdownComponent } from "@/base/index.js";
import Translation from "@polymech/astro-base/components/i18n.astro";
const { frontmatter: data } = Astro.props;
const LINK_CLASSES = "text-blue-400 hover:text-blue-800 hover:underline";
const getHref = (key, data) => {
switch (key) {
case "cad":
return data.cad?.[0]?.[".html"];
default:
return data[key];
}
};
const checkCondition = (key, data) => {
switch (key) {
case "cad":
return !!data.Preview3d && !!data.cad?.[0]?.[".html"];
default:
return true;
}
};
const links = (data) =>
["forum", "firmware", "download", "tests", "cad"].map((key) => {
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
getHref: () => getHref(key, data),
condition: () => checkCondition(key, data),
};
});
const componentsLinks = (data) =>
data.components?.map((comp) => ({
key: comp.name,
label: comp.name,
getHref: () => comp.store,
condition: () => !!comp.store,
})) || [];
const authorsLinks = (data) =>
data.authors?.map((author) => ({
key: author.name,
label: author.name,
getHref: () => author.url,
condition: () => !!author.url,
})) || [];
const groups = {
Resources: links(data),
...(data.components && { Components: componentsLinks(data) }),
...(data.authors && { Authors: authorsLinks(data) }),
};
const filteredGroups: Record<
string,
{
key: string;
label: string;
getHref: () => string;
condition: () => boolean;
}[]
> = Object.entries(groups).reduce((acc, [name, links]) => {
const validLinks = (links as any[]).filter(
(link) => link.getHref() && link.condition(),
);
if (validLinks.length > 0) {
acc[name] = validLinks;
}
return acc;
}, {});
const extraContent = [data.extra_resources, data.tests]
.filter((s) => s && s.length)
.map(createMarkdownComponent)
const sharedContent = [data.shared_resources]
.filter((s) => s && s.length)
.map(createMarkdownComponent)
---
<section class="p-4">
<div class="grid grid-cols-1 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{
Object.entries(filteredGroups).map(([name, links]) => (
<div class="p-2">
<h3 class="text-xl font-bold mb-2">
<Translation>{name}</Translation>
</h3>
<ul class="list-disc pl-6 space-y-1">
{links.map((link) => (
<Link href={link.getHref()} className={LINK_CLASSES}>
<Translation>{link.label}</Translation>
</Link>
))}
</ul>
</div>
))
}
</div>
{
extraContent.length > 0 && (
<div class="p-2">
<h3 class="text-xl font-bold mb-2">
<Translation>Extras</Translation>
</h3>
<div class="grid grid-cols-1 gap-4 extra-resources">
{extraContent.map(async (Content) => (
<Content class="extra-resource" />
))}
</div>
</div>
)
}
{
sharedContent.length > 0 && (
<div class="p-2">
<div class="grid grid-cols-1 gap-4 extra-resources">
{sharedContent.map(async (Content) => (
<Content class="extra-resource" />
))}
</div>
</div>
)
}
</div>
</section>

View File

@ -0,0 +1,36 @@
import { visit } from 'unist-util-visit';
import path from 'path';
export default function rehypeCustomImg() {
return (tree, file) => {
if(!file.path.endsWith('.mdx')) {
return;
}
const contentDir = path.join(process.cwd(), 'src', 'content');
const entryPath = path.relative(contentDir, file.history[0]).replace(/\\/g, '/');
// 1. Add the import statement for RelativeImage.
tree.children.unshift({
type: 'mdxjsEsm',
value: "import RelativeImage from '~/components/imagetools/RelativeImage.astro';"
});
// 2. Visit all JSX nodes and inject entryPath into <img> tags.
visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name === 'img') {
node.attributes.push({
type: 'mdxJsxAttribute',
name: 'entryPath',
value: entryPath
});
}
});
// 3. Export the components mapping.
tree.children.push({
type: 'mdxjsEsm',
value: 'export const components = { img: RelativeImage };'
});
};
}

View File

@ -0,0 +1,128 @@
import fs from 'fs';
import path from 'path';
export interface CoilData {
address: number;
value: number;
name: string;
component: string;
id: number;
group: string;
}
export interface RegisterData {
error: number;
address: number;
value: number;
name: string;
component: string;
id: number;
type: number;
slaveId: number;
flags: number;
group: string;
}
export interface GroupedData<T> {
[groupName: string]: T[];
}
export interface RegDescription {
address: number;
description: string;
}
/**
* Read and parse coils.json file
*/
export function readCoilsData(): CoilData[] {
try {
const filePath = path.join(process.cwd(), 'src/content/resources/cassandra/coils.json');
const fileContent = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(fileContent) as CoilData[];
} catch (error) {
console.error('Error reading coils.json:', error);
return [];
}
}
/**
* Read and parse registers.json file
*/
export function readRegistersData(): RegisterData[] {
try {
const filePath = path.join(process.cwd(), 'src/content/resources/cassandra/registers.json');
const fileContent = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(fileContent) as RegisterData[];
} catch (error) {
console.error('Error reading registers.json:', error);
return [];
}
}
/**
* Group data by the 'group' field
*/
export function groupData<T extends { group: string }>(data: T[]): GroupedData<T> {
return data.reduce((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
return acc;
}, {} as GroupedData<T>);
}
/**
* Sort grouped data by address within each group
*/
export function sortGroupedDataByAddress<T extends { address: number }>(groupedData: GroupedData<T>): GroupedData<T> {
const sorted: GroupedData<T> = {};
Object.keys(groupedData).forEach(group => {
sorted[group] = groupedData[group].sort((a, b) => a.address - b.address);
});
return sorted;
}
/**
* Get processed coils data grouped by component/group
*/
export function getProcessedCoilsData(): GroupedData<CoilData> {
const coilsData = readCoilsData();
const groupedData = groupData(coilsData);
return sortGroupedDataByAddress(groupedData);
}
/**
* Read and parse regs.json file for descriptions
*/
export function readRegDescriptions(): RegDescription[] {
try {
const filePath = path.join(process.cwd(), 'src/content/resources/cassandra/regs.json');
const fileContent = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(fileContent) as RegDescription[];
} catch (error) {
console.error('Error reading regs.json:', error);
return [];
}
}
/**
* Get processed registers data grouped by component/group
*/
export function getProcessedRegistersData(): GroupedData<RegisterData> {
const registersData = readRegistersData();
const groupedData = groupData(registersData);
return sortGroupedDataByAddress(groupedData);
}
/**
* Get description for a specific register address
*/
export function getRegisterDescription(address: number): string | null {
const descriptions = readRegDescriptions();
const found = descriptions.find(desc => desc.address === address);
return found ? found.description : null;
}

View File

@ -0,0 +1,128 @@
export interface EnumValue {
val: number;
label: string;
}
export interface ParsedRegister {
enumValues: EnumValue[];
isFlags?: boolean;
}
/**
* Parse register name to extract enum values and flag information
* Examples:
* - "Status(0:IDLE, 1:INITIALIZING, 2:RUNNING, 3:PAUSED, 4:STOPPED, 5:FINISHED)"
* - "CFlags(1:MINLOAD,2:MAX_TIME,4:STALLED,8:BALANCE,16:LOADCELL,32:MULTI_TIMEOUT)"
* - "Mode(0:NONE,1:MANUAL,2:AUTO,3:MANUAL_MULTI,4:AUTO_MULTI,5:AUTO_MULTI_BALANCED,6:REMOTE)"
*/
export function parseRegisterName(name: string): ParsedRegister | null {
if (!name) return null;
// Look for pattern like "Name(0:VALUE, 1:VALUE2, ...)"
const enumMatch = name.match(/\(([^)]+)\)/);
if (!enumMatch) return null;
const enumString = enumMatch[1];
const enumValues: EnumValue[] = [];
// Split by comma and parse each enum value
const parts = enumString.split(',');
for (const part of parts) {
const trimmed = part.trim();
// Match pattern like "0:IDLE" or "1:INITIALIZING"
const valueMatch = trimmed.match(/^(\d+):(.+)$/);
if (valueMatch) {
const val = parseInt(valueMatch[1], 10);
const label = valueMatch[2].trim();
if (!isNaN(val)) {
enumValues.push({ val, label });
}
}
}
if (enumValues.length === 0) return null;
// Determine if this is flags based on the name or values
const isFlags = name.toLowerCase().includes('flags') ||
name.toLowerCase().includes('cflags') ||
// Check if values are powers of 2 (typical for flags)
enumValues.every(ev => ev.val > 0 && (ev.val & (ev.val - 1)) === 0);
return {
enumValues,
isFlags
};
}
/**
* Format enum values for display
*/
export function formatEnumValues(enumValues: EnumValue[]): string {
return enumValues.map(ev => `${ev.val}:${ev.label}`).join(', ');
}
/**
* Get the label for a specific enum value
*/
export function getEnumLabel(enumValues: EnumValue[], value: number): string | null {
const found = enumValues.find(ev => ev.val === value);
return found ? found.label : null;
}
/**
* Get active flag labels for a flag value
*/
export function getActiveFlagLabels(enumValues: EnumValue[], value: number): string[] {
return enumValues
.filter(ev => (value & ev.val) === ev.val && ev.val > 0)
.map(ev => ev.label);
}
/**
* Modbus function type mappings
*/
export const MODBUS_FUNCTION_TYPES = {
// Coil functions
1: 'Read Coils',
5: 'Write Single Coil',
15: 'Write Multiple Coils',
// Discrete Input functions
2: 'Read Discrete Inputs',
// Holding Register functions
3: 'Read Holding Registers',
6: 'Write Single Register',
16: 'Write Multiple Registers',
// Input Register functions
4: 'Read Input Registers'
} as const;
/**
* Get the Modbus function name from type number
*/
export function getModbusFunctionName(type: number): string {
return MODBUS_FUNCTION_TYPES[type as keyof typeof MODBUS_FUNCTION_TYPES] || `Function ${type}`;
}
/**
* Determine if a register/coil is writable based on its type
*/
export function isWritableFunction(type: number): boolean {
return [5, 6, 15, 16].includes(type);
}
/**
* Get function type category
*/
export function getFunctionCategory(type: number): 'coil' | 'discrete' | 'holding' | 'input' | 'unknown' {
if ([1, 5, 15].includes(type)) return 'coil';
if ([2].includes(type)) return 'discrete';
if ([3, 6, 16].includes(type)) return 'holding';
if ([4].includes(type)) return 'input';
return 'unknown';
}