Skip to content

Commit ce978ff

Browse files
committed
chore: add mpk-analyzer utility, widget-patterns doc, update .gitignore
1 parent c687c2c commit ce978ff

File tree

3 files changed

+293
-1
lines changed

3 files changed

+293
-1
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dist/
22
generations/
3-
node_modules/
3+
node_modules/
4+
mcp-session-logs/

packages/pluggable-widgets-mcp/docs/widget-patterns.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,78 @@ if (props.value?.status === "available" && !props.value.readOnly) {
515515
}
516516
```
517517

518+
### Numeric Attribute Types (Integer, Long, Decimal) — CRITICAL
519+
520+
**Integer, Long, and Decimal attributes use `Big` from `big.js`, NOT JavaScript's native `number` or `BigInt`.**
521+
522+
```tsx
523+
import Big from "big.js"; // REQUIRED for numeric attributes
524+
525+
// Reading — .value is a Big object, call .toNumber() to get a JS number
526+
const count = props.counterValue?.value?.toNumber() ?? 0;
527+
528+
// Writing — pass new Big(value), NEVER BigInt(value) or a plain number
529+
if (props.counterValue?.status === "available" && !props.counterValue.readOnly) {
530+
props.counterValue.setValue(new Big(newCount));
531+
}
532+
```
533+
534+
**Common mistakes:**
535+
536+
| Wrong | Correct |
537+
| -------------------------------------------- | ---------------------------------- |
538+
| `BigInt(value)` | `new Big(value)` |
539+
| `Number(props.attr.value)` | `props.attr.value.toNumber()` |
540+
| `props.attr.setValue(42)` | `props.attr.setValue(new Big(42))` |
541+
| `props.attr.value` (used as number directly) | `props.attr.value.toNumber()` |
542+
543+
**Counter widget pattern (Integer/Long attribute):**
544+
545+
```tsx
546+
import { ReactElement, createElement, useState, useEffect, useCallback } from "react";
547+
import Big from "big.js";
548+
import { CounterContainerProps } from "../typings/CounterProps";
549+
import "./ui/Counter.scss";
550+
551+
export default function Counter(props: CounterContainerProps): ReactElement {
552+
const { counterValue, class: className, style, tabIndex } = props;
553+
const [count, setCount] = useState<number>(counterValue?.value?.toNumber() ?? 0);
554+
555+
useEffect(() => {
556+
if (counterValue?.status === "available" && counterValue.value !== undefined) {
557+
setCount(counterValue.value.toNumber());
558+
}
559+
}, [counterValue]);
560+
561+
const handleChange = useCallback(
562+
(delta: number) => {
563+
const newCount = count + delta;
564+
setCount(newCount);
565+
if (counterValue?.status === "available" && !counterValue.readOnly) {
566+
counterValue.setValue(new Big(newCount));
567+
}
568+
},
569+
[count, counterValue]
570+
);
571+
572+
const isReadOnly = counterValue?.readOnly ?? false;
573+
574+
return (
575+
<div className={`widget-counter ${className}`} style={style}>
576+
<button onClick={() => handleChange(-1)} disabled={isReadOnly} tabIndex={tabIndex} type="button">
577+
-
578+
</button>
579+
<span>{count}</span>
580+
<button onClick={() => handleChange(1)} disabled={isReadOnly} tabIndex={tabIndex} type="button">
581+
+
582+
</button>
583+
</div>
584+
);
585+
}
586+
```
587+
588+
**String attributes** use plain strings — `setValue("text")` is correct for those. Only Integer, Long, and Decimal require `Big`.
589+
518590
### Loading States
519591

520592
Handle datasource loading:
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { execSync } from "node:child_process";
2+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3+
import { basename, join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
6+
export interface MpkFileEntry {
7+
path: string;
8+
sizeBytes: number;
9+
}
10+
11+
export interface PackageXmlInfo {
12+
clientModuleName?: string;
13+
version?: string;
14+
widgetFilePath?: string;
15+
filesPath?: string;
16+
raw: string;
17+
}
18+
19+
export interface WidgetXmlInfo {
20+
id?: string;
21+
pluginWidget?: boolean;
22+
needsEntityContext?: boolean;
23+
propertyCount: number;
24+
raw: string;
25+
}
26+
27+
export interface BundleInfo {
28+
fileName: string;
29+
sizeBytes: number;
30+
format: "amd" | "esm" | "unknown";
31+
hasUseStrict: boolean;
32+
exportPattern: string[];
33+
containsDefine: boolean;
34+
containsExportDefault: boolean;
35+
containsExportNamed: boolean;
36+
}
37+
38+
export interface MpkAnalysis {
39+
mpkPath: string;
40+
mpkSizeBytes: number;
41+
files: MpkFileEntry[];
42+
packageXml?: PackageXmlInfo;
43+
widgetXml?: WidgetXmlInfo;
44+
bundle?: BundleInfo;
45+
errors: string[];
46+
}
47+
48+
/**
49+
* Analyzes an .mpk file (ZIP archive) and returns structural findings.
50+
* Unzips to a temp directory, reads package.xml, widget XML, and the JS bundle.
51+
* No new dependencies — uses the macOS/Linux `unzip` command.
52+
*/
53+
export function analyzeMpk(mpkPath: string): MpkAnalysis {
54+
const errors: string[] = [];
55+
56+
if (!existsSync(mpkPath)) {
57+
return {
58+
mpkPath,
59+
mpkSizeBytes: 0,
60+
files: [],
61+
errors: [`File not found: ${mpkPath}`]
62+
};
63+
}
64+
65+
const mpkSizeBytes = statSync(mpkPath).size;
66+
const tempDir = mkdtempSync(join(tmpdir(), "mpk-analyze-"));
67+
68+
try {
69+
// Unzip to temp directory
70+
try {
71+
execSync(`unzip -q "${mpkPath}" -d "${tempDir}"`, { timeout: 30000 });
72+
} catch (e) {
73+
const msg = e instanceof Error ? e.message : String(e);
74+
errors.push(`unzip failed: ${msg}`);
75+
return { mpkPath, mpkSizeBytes, files: [], errors };
76+
}
77+
78+
// Catalog all files
79+
const files = catalogFiles(tempDir, tempDir);
80+
81+
// Parse package.xml
82+
const packageXml = parsePackageXml(tempDir, errors);
83+
84+
// Find and parse widget XML
85+
const widgetXml = parseWidgetXml(tempDir, files, errors);
86+
87+
// Find and analyze JS bundle
88+
const bundle = analyzeBundle(tempDir, files, errors);
89+
90+
return { mpkPath, mpkSizeBytes, files, packageXml, widgetXml, bundle, errors };
91+
} finally {
92+
try {
93+
rmSync(tempDir, { recursive: true, force: true });
94+
} catch {
95+
// ignore cleanup errors
96+
}
97+
}
98+
}
99+
100+
function catalogFiles(dir: string, rootDir: string): MpkFileEntry[] {
101+
const entries: MpkFileEntry[] = [];
102+
try {
103+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
104+
const fullPath = join(dir, entry.name);
105+
if (entry.isDirectory()) {
106+
entries.push(...catalogFiles(fullPath, rootDir));
107+
} else {
108+
const relativePath = fullPath.slice(rootDir.length + 1);
109+
entries.push({ path: relativePath, sizeBytes: statSync(fullPath).size });
110+
}
111+
}
112+
} catch {
113+
// ignore
114+
}
115+
return entries;
116+
}
117+
118+
function parsePackageXml(tempDir: string, errors: string[]): PackageXmlInfo | undefined {
119+
const xmlPath = join(tempDir, "package.xml");
120+
if (!existsSync(xmlPath)) {
121+
errors.push("package.xml not found");
122+
return undefined;
123+
}
124+
125+
const raw = readFileSync(xmlPath, "utf-8");
126+
127+
const clientModuleName = extractXmlAttr(raw, "clientModule", "name");
128+
const version = extractXmlAttr(raw, "clientModule", "version");
129+
130+
// <widgetFiles><widgetFile path="..." />
131+
const widgetFileMatch = raw.match(/<widgetFile\s+path="([^"]+)"/);
132+
const widgetFilePath = widgetFileMatch?.[1];
133+
134+
// <files path="..." />
135+
const filesMatch = raw.match(/<files\s+path="([^"]+)"/);
136+
const filesPath = filesMatch?.[1];
137+
138+
return { clientModuleName, version, widgetFilePath, filesPath, raw };
139+
}
140+
141+
function parseWidgetXml(tempDir: string, files: MpkFileEntry[], errors: string[]): WidgetXmlInfo | undefined {
142+
const xmlFile = files.find(f => f.path.endsWith(".xml") && !f.path.includes("package.xml"));
143+
if (!xmlFile) {
144+
errors.push("No widget .xml found");
145+
return undefined;
146+
}
147+
148+
const raw = readFileSync(join(tempDir, xmlFile.path), "utf-8");
149+
150+
const id = extractXmlAttr(raw, "widget", "id");
151+
const pluginWidgetStr = extractXmlAttr(raw, "widget", "pluginWidget");
152+
const needsEntityContextStr = extractXmlAttr(raw, "widget", "needsEntityContext");
153+
154+
const propertyCount = (raw.match(/<property\s/g) ?? []).length;
155+
156+
return {
157+
id,
158+
pluginWidget: pluginWidgetStr !== undefined ? pluginWidgetStr === "true" : undefined,
159+
needsEntityContext: needsEntityContextStr !== undefined ? needsEntityContextStr === "true" : undefined,
160+
propertyCount,
161+
raw
162+
};
163+
}
164+
165+
function analyzeBundle(tempDir: string, files: MpkFileEntry[], errors: string[]): BundleInfo | undefined {
166+
// Main bundle is always in a subdirectory (e.g., com/mendix/.../Widget.js or mendix/widgetname/Widget.js).
167+
// Top-level editorConfig.js and editorPreview.js are not the runtime bundle.
168+
const jsFile =
169+
files.find(
170+
f =>
171+
f.path.endsWith(".js") &&
172+
f.path.includes("/") &&
173+
!f.path.includes("editorPreview") &&
174+
!f.path.includes("editorConfig")
175+
) ??
176+
files.find(
177+
f => f.path.endsWith(".js") && !f.path.includes("editorPreview") && !f.path.includes("editorConfig")
178+
);
179+
180+
if (!jsFile) {
181+
errors.push("No JS bundle found");
182+
return undefined;
183+
}
184+
185+
const fullPath = join(tempDir, jsFile.path);
186+
const content = readFileSync(fullPath, "utf-8");
187+
188+
const containsDefine = content.includes("define(");
189+
const containsExportDefault = /export\s+default\s/.test(content);
190+
const containsExportNamed = /export\s+\{/.test(content) || /export\s+function\s/.test(content);
191+
const hasUseStrict = content.includes('"use strict"') || content.includes("'use strict'");
192+
193+
let format: "amd" | "esm" | "unknown" = "unknown";
194+
if (containsDefine) format = "amd";
195+
else if (containsExportDefault || containsExportNamed) format = "esm";
196+
197+
// Collect first few export/define patterns for comparison
198+
const exportPattern: string[] = [];
199+
const patterns = content.matchAll(/(export\s+(?:default\s+)?(?:function|class|const|let|var)\s+\w+|define\s*\()/g);
200+
for (const match of patterns) {
201+
if (exportPattern.length < 5) exportPattern.push(match[0]);
202+
}
203+
204+
return {
205+
fileName: basename(jsFile.path),
206+
sizeBytes: jsFile.sizeBytes,
207+
format,
208+
hasUseStrict,
209+
exportPattern,
210+
containsDefine,
211+
containsExportDefault,
212+
containsExportNamed
213+
};
214+
}
215+
216+
function extractXmlAttr(xml: string, tagName: string, attrName: string): string | undefined {
217+
const pattern = new RegExp(`<${tagName}[^>]+${attrName}="([^"]+)"`, "i");
218+
return xml.match(pattern)?.[1];
219+
}

0 commit comments

Comments
 (0)