@adiholick/nvstudio-mcp 2.2.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +118 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +841 -3
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +152 -4
- package/dist/roblox-docs.js +264 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +6 -4
- package/public/index.html +1590 -180
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/insert_asset.lua +85 -12
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3824 -60
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +83 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
package/dist/luau-validator.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validateLuauSyntax = validateLuauSyntax;
|
|
4
|
+
/**
|
|
5
|
+
* Heuristic pre-commit syntax validator for Luau.
|
|
6
|
+
* Validates bracket parity, string literals, multiline comments,
|
|
7
|
+
* and block keywords (function, do, statement if ... then, repeat ... until, end).
|
|
8
|
+
*/
|
|
4
9
|
function validateLuauSyntax(source) {
|
|
5
10
|
const lines = source.split('\n');
|
|
6
|
-
// Stack for blocks: function, do, if, for, while, repeat
|
|
7
|
-
// We'll keep it simple: just count block openers vs block closers (end, until)
|
|
8
|
-
// Note: This is a heuristic pre-commit check. It doesn't replace a full AST parser,
|
|
9
|
-
// Bracket stacks
|
|
10
11
|
let parenDepth = 0; // ()
|
|
11
12
|
let braceDepth = 0; // {}
|
|
12
13
|
let bracketDepth = 0; // []
|
|
14
|
+
let blockDepth = 0; // function, do, if ... then -> end
|
|
15
|
+
let repeatDepth = 0; // repeat -> until
|
|
13
16
|
let inString = false;
|
|
14
17
|
let stringChar = '';
|
|
15
18
|
let inMultilineString = false;
|
|
@@ -18,10 +21,11 @@ function validateLuauSyntax(source) {
|
|
|
18
21
|
for (let i = 0; i < lines.length; i++) {
|
|
19
22
|
const line = lines[i];
|
|
20
23
|
let j = 0;
|
|
24
|
+
const lineTokens = [];
|
|
21
25
|
while (j < line.length) {
|
|
22
26
|
const char = line[j];
|
|
23
27
|
const nextChar = line[j + 1] || '';
|
|
24
|
-
//
|
|
28
|
+
// Multiline comment start: --[[ or --[===[
|
|
25
29
|
if (!inString && !inMultilineString && !inMultilineComment) {
|
|
26
30
|
if (char === '-' && nextChar === '-') {
|
|
27
31
|
const nextNextChar = line[j + 2] || '';
|
|
@@ -39,7 +43,7 @@ function validateLuauSyntax(source) {
|
|
|
39
43
|
continue;
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
// Single
|
|
46
|
+
// Single-line comment, ignore rest of line
|
|
43
47
|
break;
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -60,7 +64,7 @@ function validateLuauSyntax(source) {
|
|
|
60
64
|
j++;
|
|
61
65
|
continue;
|
|
62
66
|
}
|
|
63
|
-
//
|
|
67
|
+
// String literals
|
|
64
68
|
if (!inString && !inMultilineString) {
|
|
65
69
|
if (char === '"' || char === "'") {
|
|
66
70
|
inString = true;
|
|
@@ -81,10 +85,6 @@ function validateLuauSyntax(source) {
|
|
|
81
85
|
j = k + 1;
|
|
82
86
|
continue;
|
|
83
87
|
}
|
|
84
|
-
// If it ended at end of line, we can't reliably detect it. Let's just avoid bracket counting
|
|
85
|
-
if (line[k] === undefined) {
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
if (inString) {
|
|
@@ -115,36 +115,97 @@ function validateLuauSyntax(source) {
|
|
|
115
115
|
j++;
|
|
116
116
|
continue;
|
|
117
117
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
118
|
+
// Outside strings & comments: collect bracket depth & tokens
|
|
119
|
+
if (char === '(') {
|
|
120
|
+
parenDepth++;
|
|
121
|
+
lineTokens.push('(');
|
|
122
|
+
}
|
|
123
|
+
else if (char === ')') {
|
|
124
|
+
parenDepth--;
|
|
125
|
+
lineTokens.push(')');
|
|
126
|
+
if (parenDepth < 0)
|
|
127
|
+
return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
|
|
128
|
+
}
|
|
129
|
+
else if (char === '{') {
|
|
130
|
+
braceDepth++;
|
|
131
|
+
lineTokens.push('{');
|
|
132
|
+
}
|
|
133
|
+
else if (char === '}') {
|
|
134
|
+
braceDepth--;
|
|
135
|
+
lineTokens.push('}');
|
|
136
|
+
if (braceDepth < 0)
|
|
137
|
+
return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
|
|
138
|
+
}
|
|
139
|
+
else if (char === '[') {
|
|
140
|
+
bracketDepth++;
|
|
141
|
+
lineTokens.push('[');
|
|
142
|
+
}
|
|
143
|
+
else if (char === ']') {
|
|
144
|
+
bracketDepth--;
|
|
145
|
+
lineTokens.push(']');
|
|
146
|
+
if (bracketDepth < 0)
|
|
147
|
+
return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
|
|
148
|
+
}
|
|
149
|
+
else if (char === '=') {
|
|
150
|
+
lineTokens.push('=');
|
|
151
|
+
}
|
|
152
|
+
else if (char === ',') {
|
|
153
|
+
lineTokens.push(',');
|
|
154
|
+
}
|
|
155
|
+
else if (/[a-zA-Z_]/.test(char)) {
|
|
156
|
+
let start = j;
|
|
157
|
+
while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) {
|
|
158
|
+
j++;
|
|
141
159
|
}
|
|
160
|
+
lineTokens.push(line.substring(start, j));
|
|
161
|
+
continue;
|
|
142
162
|
}
|
|
143
163
|
j++;
|
|
144
164
|
}
|
|
145
165
|
if (inString) {
|
|
146
166
|
return { valid: false, error: 'String tidak ditutup (unclosed string literal)', line: i + 1 };
|
|
147
167
|
}
|
|
168
|
+
// Process line tokens for block balance
|
|
169
|
+
let statementIfCount = 0;
|
|
170
|
+
for (let t = 0; t < lineTokens.length; t++) {
|
|
171
|
+
const tok = lineTokens[t];
|
|
172
|
+
const prevTok = t > 0 ? lineTokens[t - 1] : null;
|
|
173
|
+
if (tok === 'function') {
|
|
174
|
+
blockDepth++;
|
|
175
|
+
}
|
|
176
|
+
else if (tok === 'do') {
|
|
177
|
+
blockDepth++;
|
|
178
|
+
}
|
|
179
|
+
else if (tok === 'if') {
|
|
180
|
+
// Luau expression if is preceded by '=', 'return', '(', ',', '{', '[', 'and', 'or', 'not'
|
|
181
|
+
const isExpr = prevTok && ['=', 'return', '(', ',', '{', '[', 'and', 'or', 'not'].includes(prevTok);
|
|
182
|
+
if (!isExpr) {
|
|
183
|
+
statementIfCount++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (tok === 'then') {
|
|
187
|
+
const isElseIf = lineTokens.includes('elseif');
|
|
188
|
+
if (!isElseIf) {
|
|
189
|
+
if (statementIfCount > 0) {
|
|
190
|
+
blockDepth++;
|
|
191
|
+
statementIfCount--;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (tok === 'repeat') {
|
|
196
|
+
repeatDepth++;
|
|
197
|
+
}
|
|
198
|
+
else if (tok === 'until') {
|
|
199
|
+
repeatDepth--;
|
|
200
|
+
if (repeatDepth < 0)
|
|
201
|
+
return { valid: false, error: 'Kelebihan kata kunci "until"', line: i + 1 };
|
|
202
|
+
}
|
|
203
|
+
else if (tok === 'end') {
|
|
204
|
+
blockDepth--;
|
|
205
|
+
if (blockDepth < 0)
|
|
206
|
+
return { valid: false, error: 'Kelebihan kata kunci "end"', line: i + 1 };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
148
209
|
}
|
|
149
210
|
if (inMultilineString)
|
|
150
211
|
return { valid: false, error: 'Multiline string tidak ditutup (unclosed [[ ]])' };
|
|
@@ -156,5 +217,9 @@ function validateLuauSyntax(source) {
|
|
|
156
217
|
return { valid: false, error: 'Kekurangan tutup kurawal "}"' };
|
|
157
218
|
if (bracketDepth > 0)
|
|
158
219
|
return { valid: false, error: 'Kekurangan tutup siku "]"' };
|
|
220
|
+
if (repeatDepth > 0)
|
|
221
|
+
return { valid: false, error: 'Kekurangan kata kunci "until"' };
|
|
222
|
+
if (blockDepth > 0)
|
|
223
|
+
return { valid: false, error: 'Kekurangan kata kunci "end" (blok tidak ditutup)' };
|
|
159
224
|
return { valid: true };
|
|
160
225
|
}
|
package/dist/roblox-bridge.js
CHANGED
|
@@ -18,6 +18,8 @@ const path_1 = __importDefault(require("path"));
|
|
|
18
18
|
const child_process_1 = require("child_process");
|
|
19
19
|
const task_queue_1 = require("./task-queue");
|
|
20
20
|
const ws_gateway_1 = require("./ws-gateway");
|
|
21
|
+
const roblox_docs_1 = require("./roblox-docs");
|
|
22
|
+
const vfx_database_1 = require("./vfx-database");
|
|
21
23
|
const { version: SERVER_VERSION } = require('../package.json');
|
|
22
24
|
exports.isBridgeHosting = false;
|
|
23
25
|
let resolveBridgeReady;
|
|
@@ -349,7 +351,17 @@ function addLog(io, type, message) {
|
|
|
349
351
|
activityLogs.pop();
|
|
350
352
|
if (io.engine && io.engine.clientsCount > 0) {
|
|
351
353
|
io.emit('log', log);
|
|
352
|
-
io.emit('stats', {
|
|
354
|
+
io.emit('stats', {
|
|
355
|
+
totalTasks,
|
|
356
|
+
successCount,
|
|
357
|
+
errorCount,
|
|
358
|
+
sessionStart,
|
|
359
|
+
studioConnected: isStudioConnected(),
|
|
360
|
+
studioCount: getConnectedStudioCount(),
|
|
361
|
+
wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
|
|
362
|
+
connectedStudios: getConnectedStudios(),
|
|
363
|
+
version: SERVER_VERSION
|
|
364
|
+
});
|
|
353
365
|
}
|
|
354
366
|
}
|
|
355
367
|
/**
|
|
@@ -422,7 +434,12 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
422
434
|
onStudioStatusChange: () => {
|
|
423
435
|
const connected = isStudioConnected();
|
|
424
436
|
const studioCount = getConnectedStudioCount();
|
|
425
|
-
io.emit('studio-status', {
|
|
437
|
+
io.emit('studio-status', {
|
|
438
|
+
connected,
|
|
439
|
+
studioCount,
|
|
440
|
+
connectedStudios: getConnectedStudios(),
|
|
441
|
+
version: SERVER_VERSION
|
|
442
|
+
});
|
|
426
443
|
broadcastStatus();
|
|
427
444
|
if (!connected && (0, task_queue_1.hasPendingTasks)()) {
|
|
428
445
|
checkPendingTasksOnStudioDisconnect();
|
|
@@ -464,10 +481,135 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
464
481
|
studioConnected: isStudioConnected(),
|
|
465
482
|
studioCount: getConnectedStudioCount(),
|
|
466
483
|
wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
|
|
467
|
-
port
|
|
484
|
+
port,
|
|
485
|
+
version: SERVER_VERSION,
|
|
486
|
+
connectedStudios: getConnectedStudios(),
|
|
468
487
|
},
|
|
469
488
|
});
|
|
470
489
|
});
|
|
490
|
+
// ── /api/docs (Endpoint pencarian dokumentasi engine untuk web dashboard) ──
|
|
491
|
+
app.get('/api/docs', async (req, res) => {
|
|
492
|
+
try {
|
|
493
|
+
const query = String(req.query.query || '').trim();
|
|
494
|
+
const member = req.query.member ? String(req.query.member).trim() : undefined;
|
|
495
|
+
const category = req.query.category ? String(req.query.category).trim() : 'auto';
|
|
496
|
+
if (!query) {
|
|
497
|
+
return res.status(400).json({ error: "Parameter 'query' wajib diisi." });
|
|
498
|
+
}
|
|
499
|
+
const result = await (0, roblox_docs_1.getRobloxDocs)(query, member, category);
|
|
500
|
+
res.json({ success: true, result });
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
res.status(500).json({ error: err.message });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
// ── /api/vfx Endpoints (NV-VFX Database Management) ───────────────────────
|
|
507
|
+
app.get('/api/vfx', (req, res) => {
|
|
508
|
+
try {
|
|
509
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
510
|
+
const category = req.query.category ? String(req.query.category) : undefined;
|
|
511
|
+
const query = req.query.query ? String(req.query.query) : undefined;
|
|
512
|
+
const items = db.listVfx({ category, query });
|
|
513
|
+
res.json({ success: true, count: items.length, items });
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
res.status(500).json({ error: err.message });
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
app.get('/api/vfx/categories', (req, res) => {
|
|
520
|
+
try {
|
|
521
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
522
|
+
res.json({ success: true, categories: db.getCategories() });
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
res.status(500).json({ error: err.message });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
app.get('/api/vfx/:id', (req, res) => {
|
|
529
|
+
try {
|
|
530
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
531
|
+
const item = db.getVfx(req.params.id);
|
|
532
|
+
if (!item)
|
|
533
|
+
return res.status(404).json({ error: 'VFX tidak ditemukan' });
|
|
534
|
+
res.json({ success: true, item });
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
res.status(500).json({ error: err.message });
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
app.post('/api/vfx', (req, res) => {
|
|
541
|
+
try {
|
|
542
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
543
|
+
const saved = db.saveVfx(req.body);
|
|
544
|
+
io.emit('vfx_updated', { action: 'saved', item: saved });
|
|
545
|
+
res.json({ success: true, item: saved });
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
res.status(500).json({ error: err.message });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
app.delete('/api/vfx/:id', (req, res) => {
|
|
552
|
+
try {
|
|
553
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
554
|
+
const ok = db.deleteVfx(req.params.id);
|
|
555
|
+
if (ok) {
|
|
556
|
+
io.emit('vfx_updated', { action: 'deleted', id: req.params.id });
|
|
557
|
+
res.json({ success: true });
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
res.status(404).json({ error: 'VFX tidak ditemukan' });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
res.status(500).json({ error: err.message });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
app.post('/api/vfx/apply', async (req, res) => {
|
|
568
|
+
try {
|
|
569
|
+
const { id, target, scale, enabled, placeId, studioId } = req.body;
|
|
570
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
571
|
+
const item = db.getVfx(id);
|
|
572
|
+
if (!item)
|
|
573
|
+
return res.status(404).json({ error: 'VFX tidak ditemukan di database' });
|
|
574
|
+
const targetPath = target || 'Workspace';
|
|
575
|
+
const payload = JSON.stringify({ data: item.data, scale: scale || 1, enabled });
|
|
576
|
+
const result = await (0, task_queue_1.addTaskToQueue)('apply_vfx', targetPath, payload, { placeId, studioId });
|
|
577
|
+
res.json({ success: true, result });
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
res.status(500).json({ error: err.message });
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
app.post('/api/vfx/scan', async (req, res) => {
|
|
584
|
+
try {
|
|
585
|
+
const { target, name, category, tags, placeId, studioId } = req.body;
|
|
586
|
+
const targetPath = target || 'Workspace';
|
|
587
|
+
const scanPayload = JSON.stringify({ name, category, tags });
|
|
588
|
+
const result = await (0, task_queue_1.addTaskToQueue)('scan_vfx', targetPath, scanPayload, { placeId, studioId });
|
|
589
|
+
if (result && (result.status === 'success' || !result.error)) {
|
|
590
|
+
const scanned = result.result || result;
|
|
591
|
+
const db = (0, vfx_database_1.getVfxDatabase)();
|
|
592
|
+
const saved = db.saveVfx({
|
|
593
|
+
name: scanned.name || name || 'Scanned_VFX',
|
|
594
|
+
category: scanned.category || category || 'Ambient',
|
|
595
|
+
tags: scanned.tags || tags || [],
|
|
596
|
+
description: scanned.description,
|
|
597
|
+
sourcePlaceId: placeId,
|
|
598
|
+
stats: scanned.stats,
|
|
599
|
+
nodeCount: scanned.nodeCount,
|
|
600
|
+
data: scanned.data,
|
|
601
|
+
});
|
|
602
|
+
io.emit('vfx_updated', { action: 'saved', item: saved });
|
|
603
|
+
res.json({ success: true, item: saved, rawResult: result });
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
res.status(400).json({ error: result?.error || 'Gagal memindai VFX dari Studio' });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
res.status(500).json({ error: err.message });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
471
613
|
// ── /api/stats ─────────────────────────────────────────────────────────────
|
|
472
614
|
app.get('/api/stats', (req, res) => {
|
|
473
615
|
const activeStudios = getConnectedStudioCount();
|
|
@@ -514,7 +656,13 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
514
656
|
// Daftarkan studio jika belum ada
|
|
515
657
|
if (!studioEventQueues.has(studioId)) {
|
|
516
658
|
studioEventQueues.set(studioId, []);
|
|
517
|
-
io.emit('studio-status', {
|
|
659
|
+
io.emit('studio-status', {
|
|
660
|
+
connected: true,
|
|
661
|
+
studioCount: Math.max(studioEventQueues.size, 1),
|
|
662
|
+
studioId,
|
|
663
|
+
connectedStudios: getConnectedStudios(),
|
|
664
|
+
version: SERVER_VERSION
|
|
665
|
+
});
|
|
518
666
|
addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Place: ${placeId ?? 'Local'}, Total: ${studioEventQueues.size})`);
|
|
519
667
|
if (!isDaemon && !dashboardOpened) {
|
|
520
668
|
dashboardOpened = true;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.cleanRobloxMarkdown = cleanRobloxMarkdown;
|
|
7
|
+
exports.getRobloxDocs = getRobloxDocs;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
11
|
+
const BASE_URL = 'https://raw.githubusercontent.com/Roblox/creator-docs/main/content/en-us/reference/engine';
|
|
12
|
+
const CACHE_DIR = path_1.default.join(__dirname, '..', '.cache', 'roblox-docs');
|
|
13
|
+
if (!fs_1.default.existsSync(CACHE_DIR)) {
|
|
14
|
+
fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
function cleanRobloxMarkdown(text) {
|
|
17
|
+
if (!text)
|
|
18
|
+
return '';
|
|
19
|
+
return text
|
|
20
|
+
// Replace `Type.Target|Label` with `Label`
|
|
21
|
+
.replace(/`(?:Class|Datatype|Enum|Library)\.[^|`]+\|([^`]+)`/g, '`$1`')
|
|
22
|
+
// Replace `Type.Target` with `Target`
|
|
23
|
+
.replace(/`(?:Class|Datatype|Enum|Library)\.([^`]+)`/g, '`$1`')
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
const CATEGORIES = ['classes', 'datatypes', 'enums', 'libraries'];
|
|
27
|
+
function getCategoryFolder(cat) {
|
|
28
|
+
const c = cat.toLowerCase();
|
|
29
|
+
if (c.startsWith('class'))
|
|
30
|
+
return 'classes';
|
|
31
|
+
if (c.startsWith('data') || c.startsWith('type'))
|
|
32
|
+
return 'datatypes';
|
|
33
|
+
if (c.startsWith('enum'))
|
|
34
|
+
return 'enums';
|
|
35
|
+
if (c.startsWith('lib'))
|
|
36
|
+
return 'libraries';
|
|
37
|
+
return 'classes';
|
|
38
|
+
}
|
|
39
|
+
function getCachePath(category, name) {
|
|
40
|
+
const catFolder = path_1.default.join(CACHE_DIR, category);
|
|
41
|
+
if (!fs_1.default.existsSync(catFolder)) {
|
|
42
|
+
fs_1.default.mkdirSync(catFolder, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
return path_1.default.join(catFolder, `${name}.yaml`);
|
|
45
|
+
}
|
|
46
|
+
async function fetchFromNetwork(category, query) {
|
|
47
|
+
const url = `${BASE_URL}/${category}/${query}.yaml`;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'nvstudio-mcp-roblox-docs' } });
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return null;
|
|
52
|
+
return await res.text();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function resolveDocYaml(query, categoryFilter = 'auto') {
|
|
59
|
+
// Normalize query casing (capitalized first letter standard)
|
|
60
|
+
const normalizedQuery = query.trim();
|
|
61
|
+
const searchCategories = categoryFilter !== 'auto'
|
|
62
|
+
? [getCategoryFolder(categoryFilter)]
|
|
63
|
+
: ['classes', 'datatypes', 'enums', 'libraries'];
|
|
64
|
+
// 1. Cek disk cache
|
|
65
|
+
for (const cat of searchCategories) {
|
|
66
|
+
const cacheFile = getCachePath(cat, normalizedQuery);
|
|
67
|
+
if (fs_1.default.existsSync(cacheFile)) {
|
|
68
|
+
try {
|
|
69
|
+
const cached = fs_1.default.readFileSync(cacheFile, 'utf-8');
|
|
70
|
+
if (cached && cached.length > 50) {
|
|
71
|
+
return { yamlText: cached, category: cat };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// ignore cache read error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 2. Fetch dari network
|
|
80
|
+
for (const cat of searchCategories) {
|
|
81
|
+
const rawYaml = await fetchFromNetwork(cat, normalizedQuery);
|
|
82
|
+
if (rawYaml && rawYaml.length > 50) {
|
|
83
|
+
// Simpan ke cache
|
|
84
|
+
try {
|
|
85
|
+
fs_1.default.writeFileSync(getCachePath(cat, normalizedQuery), rawYaml, 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// ignore cache write error
|
|
89
|
+
}
|
|
90
|
+
return { yamlText: rawYaml, category: cat };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function formatSingleMember(parentName, member, kind) {
|
|
96
|
+
const name = member.name || member.Name || 'Unknown';
|
|
97
|
+
const summary = cleanRobloxMarkdown(member.summary || member.description || '');
|
|
98
|
+
const desc = cleanRobloxMarkdown(member.description || '');
|
|
99
|
+
let out = `## \`${name}\` (${kind})\n\n`;
|
|
100
|
+
if (summary)
|
|
101
|
+
out += `> ${summary}\n\n`;
|
|
102
|
+
if (member.type)
|
|
103
|
+
out += `* **Type:** \`${member.type}\`\n`;
|
|
104
|
+
if (member.default !== undefined && member.default !== null)
|
|
105
|
+
out += `* **Default:** \`${member.default}\`\n`;
|
|
106
|
+
const tags = (member.tags || []).join(', ');
|
|
107
|
+
if (tags)
|
|
108
|
+
out += `* **Tags:** ${tags}\n`;
|
|
109
|
+
if (member.deprecation_message) {
|
|
110
|
+
out += `\n> ⚠️ **Deprecated:** ${cleanRobloxMarkdown(member.deprecation_message)}\n\n`;
|
|
111
|
+
}
|
|
112
|
+
// Parameters
|
|
113
|
+
if (Array.isArray(member.parameters) && member.parameters.length > 0) {
|
|
114
|
+
out += `\n### Parameters\n`;
|
|
115
|
+
for (const p of member.parameters) {
|
|
116
|
+
const def = p.default !== undefined && p.default !== null ? ` = \`${p.default}\`` : '';
|
|
117
|
+
out += `* \`${p.name}\`: \`${p.type || 'any'}\`${def} — ${cleanRobloxMarkdown(p.summary || '')}\n`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Returns
|
|
121
|
+
if (Array.isArray(member.returns) && member.returns.length > 0) {
|
|
122
|
+
out += `\n### Returns\n`;
|
|
123
|
+
for (const r of member.returns) {
|
|
124
|
+
out += `* \`${r.type || 'void'}\` — ${cleanRobloxMarkdown(r.summary || '')}\n`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (desc && desc !== summary) {
|
|
128
|
+
out += `\n### Details\n${desc}\n`;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
async function getRobloxDocs(query, member, category = 'auto') {
|
|
133
|
+
const resolved = await resolveDocYaml(query, category);
|
|
134
|
+
if (!resolved) {
|
|
135
|
+
return `❌ Dokumentasi Roblox untuk "${query}" tidak ditemukan di Classes, Data types, Enums, maupun Libraries.\nPastikan ejaan ClassName atau Enum sudah benar (case-sensitive, contoh: 'TweenService', 'CanvasGroup', 'Vector3', 'EasingStyle').`;
|
|
136
|
+
}
|
|
137
|
+
let doc;
|
|
138
|
+
try {
|
|
139
|
+
doc = yaml_1.default.parse(resolved.yamlText);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return `❌ Gagal mem-parse format YAML untuk "${query}": ${err.message}`;
|
|
143
|
+
}
|
|
144
|
+
const name = doc.name || query;
|
|
145
|
+
const docType = doc.type || resolved.category;
|
|
146
|
+
const summary = cleanRobloxMarkdown(doc.summary || '');
|
|
147
|
+
const description = cleanRobloxMarkdown(doc.description || '');
|
|
148
|
+
// JIKA MEMBER SPESIFIK DICARI (Contoh: 'Create', 'GroupTransparency')
|
|
149
|
+
if (member) {
|
|
150
|
+
const memberNorm = member.trim().toLowerCase();
|
|
151
|
+
const allMembers = [];
|
|
152
|
+
const collect = (arr, kind) => {
|
|
153
|
+
if (Array.isArray(arr)) {
|
|
154
|
+
for (const item of arr)
|
|
155
|
+
allMembers.push({ item, kind });
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
collect(doc.constructors, 'Constructor');
|
|
159
|
+
collect(doc.properties, 'Property');
|
|
160
|
+
collect(doc.methods, 'Method');
|
|
161
|
+
collect(doc.functions, 'Function');
|
|
162
|
+
collect(doc.events, 'Event');
|
|
163
|
+
collect(doc.callbacks, 'Callback');
|
|
164
|
+
collect(doc.items, 'Enum Item');
|
|
165
|
+
collect(doc.constants, 'Constant');
|
|
166
|
+
const matched = allMembers.find(({ item }) => {
|
|
167
|
+
const full = String(item.name || '').toLowerCase();
|
|
168
|
+
return full === memberNorm || full.endsWith(`.${memberNorm}`) || full.endsWith(`:${memberNorm}`);
|
|
169
|
+
});
|
|
170
|
+
if (matched) {
|
|
171
|
+
let out = `# ${name} : ${matched.item.name || member}\n\n`;
|
|
172
|
+
out += formatSingleMember(name, matched.item, matched.kind);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
return `⚠️ Member "${member}" tidak ditemukan di dalam ${name}.\nAnggota yang tersedia: ` +
|
|
176
|
+
allMembers.slice(0, 15).map(({ item }) => `\`${item.name}\``).join(', ') +
|
|
177
|
+
(allMembers.length > 15 ? ` ...dan ${allMembers.length - 15} lainnya.` : '');
|
|
178
|
+
}
|
|
179
|
+
// TAMPILAN PENUH OVERVIEW DOKUMENTASI
|
|
180
|
+
let md = `# Roblox Engine: ${name} (${docType})\n\n`;
|
|
181
|
+
if (doc.deprecation_message) {
|
|
182
|
+
md += `> ⚠️ **DEPRECATED:** ${cleanRobloxMarkdown(doc.deprecation_message)}\n\n`;
|
|
183
|
+
}
|
|
184
|
+
if (summary) {
|
|
185
|
+
md += `> ${summary}\n\n`;
|
|
186
|
+
}
|
|
187
|
+
if (doc.inherits) {
|
|
188
|
+
md += `* **Inherits from:** \`${doc.inherits}\`\n`;
|
|
189
|
+
}
|
|
190
|
+
if (doc.memory_category) {
|
|
191
|
+
md += `* **Memory Category:** \`${doc.memory_category}\`\n`;
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(doc.tags) && doc.tags.length > 0) {
|
|
194
|
+
md += `* **Tags:** ${doc.tags.join(', ')}\n`;
|
|
195
|
+
}
|
|
196
|
+
md += `\n---\n\n`;
|
|
197
|
+
if (description && description !== summary) {
|
|
198
|
+
md += `### Description\n${description}\n\n`;
|
|
199
|
+
}
|
|
200
|
+
// CONSTRUCTORS
|
|
201
|
+
if (Array.isArray(doc.constructors) && doc.constructors.length > 0) {
|
|
202
|
+
md += `### Constructors\n`;
|
|
203
|
+
for (const c of doc.constructors) {
|
|
204
|
+
const params = (c.parameters || []).map((p) => `${p.name}: ${p.type || 'any'}`).join(', ');
|
|
205
|
+
md += `* \`${c.name}(${params})\`\n ${cleanRobloxMarkdown(c.summary || '')}\n`;
|
|
206
|
+
}
|
|
207
|
+
md += `\n`;
|
|
208
|
+
}
|
|
209
|
+
// PROPERTIES TABLE
|
|
210
|
+
if (Array.isArray(doc.properties) && doc.properties.length > 0) {
|
|
211
|
+
md += `### Properties\n\n`;
|
|
212
|
+
md += `| Property | Type | Security | Summary |\n`;
|
|
213
|
+
md += `| :--- | :--- | :--- | :--- |\n`;
|
|
214
|
+
for (const p of doc.properties) {
|
|
215
|
+
const pName = p.name ? p.name.replace(/^[^.]+\./, '') : 'Unknown';
|
|
216
|
+
const pType = `\`${p.type || 'any'}\``;
|
|
217
|
+
const sec = p.security?.read ? `${p.security.read}` : 'None';
|
|
218
|
+
const pSummary = cleanRobloxMarkdown(p.summary || '').replace(/[\r\n]+/g, ' ').slice(0, 100);
|
|
219
|
+
md += `| **\`${pName}\`** | ${pType} | ${sec} | ${pSummary} |\n`;
|
|
220
|
+
}
|
|
221
|
+
md += `\n`;
|
|
222
|
+
}
|
|
223
|
+
// METHODS
|
|
224
|
+
if (Array.isArray(doc.methods) && doc.methods.length > 0) {
|
|
225
|
+
md += `### Methods\n`;
|
|
226
|
+
for (const m of doc.methods) {
|
|
227
|
+
const params = (m.parameters || []).map((p) => `${p.name}: ${p.type || 'any'}`).join(', ');
|
|
228
|
+
const returns = (m.returns || []).map((r) => r.type || 'void').join(', ') || 'void';
|
|
229
|
+
md += `* \`${m.name}(${params}) -> ${returns}\`\n ${cleanRobloxMarkdown(m.summary || '')}\n`;
|
|
230
|
+
}
|
|
231
|
+
md += `\n`;
|
|
232
|
+
}
|
|
233
|
+
// FUNCTIONS (Libraries / Static)
|
|
234
|
+
if (Array.isArray(doc.functions) && doc.functions.length > 0) {
|
|
235
|
+
md += `### Functions\n`;
|
|
236
|
+
for (const f of doc.functions) {
|
|
237
|
+
const params = (f.parameters || []).map((p) => `${p.name}: ${p.type || 'any'}`).join(', ');
|
|
238
|
+
const returns = (f.returns || []).map((r) => r.type || 'void').join(', ') || 'void';
|
|
239
|
+
md += `* \`${f.name}(${params}) -> ${returns}\`\n ${cleanRobloxMarkdown(f.summary || '')}\n`;
|
|
240
|
+
}
|
|
241
|
+
md += `\n`;
|
|
242
|
+
}
|
|
243
|
+
// EVENTS
|
|
244
|
+
if (Array.isArray(doc.events) && doc.events.length > 0) {
|
|
245
|
+
md += `### Events\n`;
|
|
246
|
+
for (const e of doc.events) {
|
|
247
|
+
const params = (e.parameters || []).map((p) => `${p.name}: ${p.type || 'any'}`).join(', ');
|
|
248
|
+
md += `* \`${e.name}(${params})\`\n ${cleanRobloxMarkdown(e.summary || '')}\n`;
|
|
249
|
+
}
|
|
250
|
+
md += `\n`;
|
|
251
|
+
}
|
|
252
|
+
// ENUM ITEMS
|
|
253
|
+
if (Array.isArray(doc.items) && doc.items.length > 0) {
|
|
254
|
+
md += `### Enum Items\n\n`;
|
|
255
|
+
md += `| Item | Value | Summary |\n`;
|
|
256
|
+
md += `| :--- | :--- | :--- |\n`;
|
|
257
|
+
for (const it of doc.items) {
|
|
258
|
+
const itSummary = cleanRobloxMarkdown(it.summary || '').replace(/[\r\n]+/g, ' ');
|
|
259
|
+
md += `| **\`${it.name}\`** | \`${it.value}\` | ${itSummary} |\n`;
|
|
260
|
+
}
|
|
261
|
+
md += `\n`;
|
|
262
|
+
}
|
|
263
|
+
return md.trim();
|
|
264
|
+
}
|