@isentinel/rojo-utils 0.1.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/CLAUDE.md +40 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +388 -0
- package/coverage/coverage-final.json +7 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/loader.ts.html +220 -0
- package/coverage/mount-collector.ts.html +301 -0
- package/coverage/path-mapper.ts.html +220 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/rojo-resolver.ts.html +1900 -0
- package/coverage/rojo-tree.ts.html +559 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/tree-mapper.ts.html +247 -0
- package/dist/index.d.mts +175 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +551 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.ts +23 -0
- package/out-tsc/index.d.ts +10 -0
- package/out-tsc/index.d.ts.map +1 -0
- package/out-tsc/index.js +6 -0
- package/out-tsc/loader.d.ts +3 -0
- package/out-tsc/loader.d.ts.map +1 -0
- package/out-tsc/loader.js +37 -0
- package/out-tsc/mount-collector.d.ts +10 -0
- package/out-tsc/mount-collector.d.ts.map +1 -0
- package/out-tsc/mount-collector.js +37 -0
- package/out-tsc/path-mapper.d.ts +4 -0
- package/out-tsc/path-mapper.d.ts.map +1 -0
- package/out-tsc/path-mapper.js +34 -0
- package/out-tsc/rojo-resolver.d.ts +110 -0
- package/out-tsc/rojo-resolver.d.ts.map +1 -0
- package/out-tsc/rojo-resolver.js +448 -0
- package/out-tsc/rojo-tree.d.ts +15 -0
- package/out-tsc/rojo-tree.d.ts.map +1 -0
- package/out-tsc/rojo-tree.js +106 -0
- package/out-tsc/tree-mapper.d.ts +4 -0
- package/out-tsc/tree-mapper.d.ts.map +1 -0
- package/out-tsc/tree-mapper.js +34 -0
- package/out-tsc/tsconfig.tsbuildinfo +1 -0
- package/out-tsc/types.d.ts +26 -0
- package/out-tsc/types.d.ts.map +1 -0
- package/out-tsc/types.js +1 -0
- package/out-tsc/vitest/eslint.config.d.ts +3 -0
- package/out-tsc/vitest/eslint.config.d.ts.map +1 -0
- package/out-tsc/vitest/eslint.shared.d.ts +13 -0
- package/out-tsc/vitest/eslint.shared.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/tsconfig.spec.tsbuildinfo +1 -0
- package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts.map +1 -0
- package/out-tsc/vitest/src/loader.spec.d.ts +2 -0
- package/out-tsc/vitest/src/loader.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/mount-collector.spec.d.ts +2 -0
- package/out-tsc/vitest/src/mount-collector.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/path-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/src/path-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/rojo-resolver.spec.d.ts +2 -0
- package/out-tsc/vitest/src/rojo-resolver.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/rojo-tree.spec.d.ts +2 -0
- package/out-tsc/vitest/src/rojo-tree.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/tree-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/src/tree-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/test/setup/jest-extended.d.ts +2 -0
- package/out-tsc/vitest/test/setup/jest-extended.d.ts.map +1 -0
- package/out-tsc/vitest/tsconfig.spec.tsbuildinfo +1 -0
- package/out-tsc/vitest/tsdown.config.d.ts +3 -0
- package/out-tsc/vitest/tsdown.config.d.ts.map +1 -0
- package/out-tsc/vitest/vitest.config.d.ts +3 -0
- package/out-tsc/vitest/vitest.config.d.ts.map +1 -0
- package/package.json +48 -0
- package/project.json +25 -0
- package/src/index.ts +25 -0
- package/src/loader.spec.ts +240 -0
- package/src/loader.ts +45 -0
- package/src/mount-collector.spec.ts +272 -0
- package/src/mount-collector.ts +72 -0
- package/src/path-mapper.spec.ts +201 -0
- package/src/path-mapper.ts +45 -0
- package/src/rojo-resolver.spec.ts +956 -0
- package/src/rojo-resolver.ts +605 -0
- package/src/rojo-tree.spec.ts +622 -0
- package/src/rojo-tree.ts +162 -0
- package/src/tree-mapper.spec.ts +147 -0
- package/src/tree-mapper.ts +54 -0
- package/src/types.ts +29 -0
- package/test/setup/jest-extended.ts +4 -0
- package/test/setup/vitest-jest-extended.d.ts +1 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +17 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +26 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tsdown.config.d.ts","sourceRoot":"","sources":["../../tsdown.config.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["../../vitest.config.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@isentinel/rojo-utils",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rojo tree walking, nested project resolution, and DataModel path mapping",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"roblox",
|
|
7
|
+
"rojo",
|
|
8
|
+
"datamodel",
|
|
9
|
+
"tree"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Christopher Buss <christopher.buss@pm.me> (https://github.com/christopher-buss)",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"source": "./src/index.ts",
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"default": "./dist/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"arktype": "2.2.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@isentinel/eslint-config": "6.0.0-beta.12",
|
|
29
|
+
"@types/node": "24.12.3",
|
|
30
|
+
"@typescript/native-preview": "7.0.0-dev.20260607.1",
|
|
31
|
+
"@vitest/coverage-v8": "4.1.8",
|
|
32
|
+
"@vitest/eslint-plugin": "1.6.19",
|
|
33
|
+
"eslint": "10.7.0",
|
|
34
|
+
"eslint-plugin-jest-extended": "3.0.1",
|
|
35
|
+
"eslint-plugin-n": "18.2.2",
|
|
36
|
+
"eslint-plugin-pnpm": "1.6.1",
|
|
37
|
+
"jest-extended": "7.0.0",
|
|
38
|
+
"tsdown": "0.22.0",
|
|
39
|
+
"typescript": "6.0.3",
|
|
40
|
+
"vitest": "4.1.8"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=24.12.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rojo-utils",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"projectType": "library",
|
|
5
|
+
"tags": ["scope:tooling", "type:lib"],
|
|
6
|
+
"targets": {
|
|
7
|
+
"build": {
|
|
8
|
+
"command": "tsdown",
|
|
9
|
+
"outputs": ["{projectRoot}/dist"],
|
|
10
|
+
"options": {
|
|
11
|
+
"cwd": "libs/rojo-utils"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"lint": {
|
|
15
|
+
"command": "eslint",
|
|
16
|
+
"hasTypeAwareRules": true
|
|
17
|
+
},
|
|
18
|
+
"test": {
|
|
19
|
+
"command": "vitest run --coverage",
|
|
20
|
+
"options": {
|
|
21
|
+
"cwd": "libs/rojo-utils"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { loadRojoProject } from "./loader.ts";
|
|
2
|
+
export { collectMounts, pruneAncestors } from "./mount-collector.ts";
|
|
3
|
+
export type { Mount, PathClassifier, PathKind } from "./mount-collector.ts";
|
|
4
|
+
export { mapFsPathToDataModel, mapFsRootToDataModel } from "./path-mapper.ts";
|
|
5
|
+
export {
|
|
6
|
+
FileRelation,
|
|
7
|
+
NetworkType,
|
|
8
|
+
RbxPathParent,
|
|
9
|
+
RbxType,
|
|
10
|
+
RojoResolver,
|
|
11
|
+
} from "./rojo-resolver.ts";
|
|
12
|
+
export type {
|
|
13
|
+
PartitionInfo,
|
|
14
|
+
RbxPath,
|
|
15
|
+
RelativeRbxPath,
|
|
16
|
+
RojoResolverState,
|
|
17
|
+
} from "./rojo-resolver.ts";
|
|
18
|
+
export {
|
|
19
|
+
collectPaths,
|
|
20
|
+
rebaseTreePaths,
|
|
21
|
+
resolveNestedProjects,
|
|
22
|
+
resolveNestedProjectSources,
|
|
23
|
+
} from "./rojo-tree.ts";
|
|
24
|
+
export { findInTree, matchNodePath } from "./tree-mapper.ts";
|
|
25
|
+
export type { LoadedRojoProject, RojoProject, RojoTreeNode } from "./types.ts";
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { loadRojoProject } from "./loader.ts";
|
|
7
|
+
|
|
8
|
+
describe(loadRojoProject, () => {
|
|
9
|
+
it("should load and parse a valid Rojo project", () => {
|
|
10
|
+
expect.assertions(2);
|
|
11
|
+
|
|
12
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
13
|
+
const projectPath = path.join(temporaryDirectory, "default.project.json");
|
|
14
|
+
fs.writeFileSync(
|
|
15
|
+
projectPath,
|
|
16
|
+
JSON.stringify({
|
|
17
|
+
name: "TestProject",
|
|
18
|
+
tree: {
|
|
19
|
+
$className: "DataModel",
|
|
20
|
+
ReplicatedStorage: {
|
|
21
|
+
shared: { $path: "src/shared" },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const project = loadRojoProject(projectPath);
|
|
29
|
+
|
|
30
|
+
expect(project.name).toBe("TestProject");
|
|
31
|
+
expect(project.tree).toStrictEqual({
|
|
32
|
+
$className: "DataModel",
|
|
33
|
+
ReplicatedStorage: {
|
|
34
|
+
shared: { $path: "src/shared" },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should resolve nested project references", () => {
|
|
43
|
+
expect.assertions(1);
|
|
44
|
+
|
|
45
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(temporaryDirectory, "inner.project.json"),
|
|
48
|
+
JSON.stringify({ name: "Inner", tree: { $path: "src/inner" } }),
|
|
49
|
+
);
|
|
50
|
+
const projectPath = path.join(temporaryDirectory, "default.project.json");
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
projectPath,
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
name: "Outer",
|
|
55
|
+
tree: {
|
|
56
|
+
$className: "DataModel",
|
|
57
|
+
ReplicatedStorage: {
|
|
58
|
+
inner: { $path: "inner.project.json" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const project = loadRojoProject(projectPath);
|
|
66
|
+
|
|
67
|
+
expect(project.tree["ReplicatedStorage"]).toStrictEqual({
|
|
68
|
+
inner: { $path: "src/inner" },
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should preserve servePort when present", () => {
|
|
76
|
+
expect.assertions(1);
|
|
77
|
+
|
|
78
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
79
|
+
const projectPath = path.join(temporaryDirectory, "default.project.json");
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
projectPath,
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
name: "TestProject",
|
|
84
|
+
servePort: 34872,
|
|
85
|
+
tree: { $className: "DataModel" },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const project = loadRojoProject(projectPath);
|
|
91
|
+
|
|
92
|
+
expect(project.servePort).toBe(34872);
|
|
93
|
+
} finally {
|
|
94
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should not include servePort when absent", () => {
|
|
99
|
+
expect.assertions(1);
|
|
100
|
+
|
|
101
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
102
|
+
const projectPath = path.join(temporaryDirectory, "default.project.json");
|
|
103
|
+
fs.writeFileSync(
|
|
104
|
+
projectPath,
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
name: "TestProject",
|
|
107
|
+
tree: { $className: "DataModel" },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const project = loadRojoProject(projectPath);
|
|
113
|
+
|
|
114
|
+
expect(project.servePort).toBeUndefined();
|
|
115
|
+
} finally {
|
|
116
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should throw with file path when project has malformed JSON", () => {
|
|
121
|
+
expect.assertions(1);
|
|
122
|
+
|
|
123
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
124
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
125
|
+
fs.writeFileSync(projectPath, "not valid json {{{");
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
expect(() => loadRojoProject(projectPath)).toThrow("Failed to parse Rojo project");
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should throw when project JSON is not an object", () => {
|
|
135
|
+
expect.assertions(1);
|
|
136
|
+
|
|
137
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
138
|
+
const projectPath = path.join(temporaryDirectory, "array.project.json");
|
|
139
|
+
fs.writeFileSync(projectPath, JSON.stringify([1, 2, 3]));
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
143
|
+
"Rojo project must be a JSON object",
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should throw when file does not exist", () => {
|
|
151
|
+
expect.assertions(1);
|
|
152
|
+
|
|
153
|
+
expect(() => loadRojoProject("/nonexistent/project.json")).toThrow(
|
|
154
|
+
"Could not read Rojo project",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should throw when name is missing", () => {
|
|
159
|
+
expect.assertions(1);
|
|
160
|
+
|
|
161
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
162
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
163
|
+
fs.writeFileSync(projectPath, JSON.stringify({ tree: { $className: "DataModel" } }));
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
167
|
+
'Rojo project must have a non-empty "name" field',
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should throw when name is empty string", () => {
|
|
175
|
+
expect.assertions(1);
|
|
176
|
+
|
|
177
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
178
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
projectPath,
|
|
181
|
+
JSON.stringify({ name: "", tree: { $className: "DataModel" } }),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
186
|
+
'Rojo project must have a non-empty "name" field',
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should throw when tree is missing", () => {
|
|
194
|
+
expect.assertions(1);
|
|
195
|
+
|
|
196
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
197
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
198
|
+
fs.writeFileSync(projectPath, JSON.stringify({ name: "Bad" })!);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
202
|
+
'Rojo project must have a "tree" object',
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should throw when tree is an array", () => {
|
|
210
|
+
expect.assertions(1);
|
|
211
|
+
|
|
212
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
213
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
214
|
+
fs.writeFileSync(projectPath, JSON.stringify({ name: "Bad", tree: [] }));
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
218
|
+
'Rojo project must have a "tree" object',
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should throw when tree is null", () => {
|
|
226
|
+
expect.assertions(1);
|
|
227
|
+
|
|
228
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rojo-loader-test-"));
|
|
229
|
+
const projectPath = path.join(temporaryDirectory, "bad.project.json");
|
|
230
|
+
fs.writeFileSync(projectPath, JSON.stringify({ name: "Bad", tree: null }));
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
expect(() => loadRojoProject(projectPath)).toThrow(
|
|
234
|
+
'Rojo project must have a "tree" object',
|
|
235
|
+
);
|
|
236
|
+
} finally {
|
|
237
|
+
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { resolveNestedProjects } from "./rojo-tree.ts";
|
|
5
|
+
import type { LoadedRojoProject, RojoTreeNode } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function loadRojoProject(projectPath: string): LoadedRojoProject {
|
|
8
|
+
let content: string;
|
|
9
|
+
try {
|
|
10
|
+
content = readFileSync(projectPath, "utf-8");
|
|
11
|
+
} catch (err) {
|
|
12
|
+
throw new Error(`Could not read Rojo project: ${projectPath}`, { cause: err });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let parsed: unknown;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(content);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(`Failed to parse Rojo project: ${projectPath}`, { cause: err });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
23
|
+
throw new Error(`Rojo project must be a JSON object: ${projectPath}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const raw = parsed as Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
if (typeof raw["name"] !== "string" || raw["name"] === "") {
|
|
29
|
+
throw new Error(`Rojo project must have a non-empty "name" field: ${projectPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof raw["tree"] !== "object" || raw["tree"] === null || Array.isArray(raw["tree"])) {
|
|
33
|
+
throw new Error(`Rojo project must have a "tree" object: ${projectPath}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rootDirectory = dirname(projectPath);
|
|
37
|
+
const tree = resolveNestedProjects(raw["tree"] as RojoTreeNode, rootDirectory);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
name: raw["name"],
|
|
41
|
+
raw,
|
|
42
|
+
servePort: typeof raw["servePort"] === "number" ? raw["servePort"] : undefined,
|
|
43
|
+
tree,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PathClassifier, PathKind } from "./mount-collector.ts";
|
|
4
|
+
import { collectMounts, pruneAncestors } from "./mount-collector.ts";
|
|
5
|
+
import type { RojoTreeNode } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
function allDirectories(): PathKind {
|
|
8
|
+
return "directory";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeClassifier(kinds: Record<string, PathKind>): PathClassifier {
|
|
12
|
+
function classify(fsPath: string): PathKind {
|
|
13
|
+
return kinds[fsPath] ?? "missing";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return classify;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe(collectMounts, () => {
|
|
20
|
+
it("should return empty array for tree with no $path entries", () => {
|
|
21
|
+
expect.assertions(1);
|
|
22
|
+
|
|
23
|
+
const tree = { $className: "DataModel" } satisfies RojoTreeNode;
|
|
24
|
+
|
|
25
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should collect a single directory $path entry", () => {
|
|
29
|
+
expect.assertions(1);
|
|
30
|
+
|
|
31
|
+
const tree = {
|
|
32
|
+
$className: "DataModel",
|
|
33
|
+
ReplicatedStorage: {
|
|
34
|
+
client: { $path: "out/client" },
|
|
35
|
+
},
|
|
36
|
+
} satisfies RojoTreeNode;
|
|
37
|
+
|
|
38
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([
|
|
39
|
+
{ dataModelPath: "ReplicatedStorage/client", fsPath: "out/client" },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should collect multiple $path entries in nested tree", () => {
|
|
44
|
+
expect.assertions(1);
|
|
45
|
+
|
|
46
|
+
const tree = {
|
|
47
|
+
$className: "DataModel",
|
|
48
|
+
ReplicatedStorage: {
|
|
49
|
+
client: { $path: "out/client" },
|
|
50
|
+
shared: { $path: "out/shared" },
|
|
51
|
+
},
|
|
52
|
+
ServerScriptService: {
|
|
53
|
+
server: { $path: "out/server" },
|
|
54
|
+
},
|
|
55
|
+
} satisfies RojoTreeNode;
|
|
56
|
+
|
|
57
|
+
const result = collectMounts(tree, "", allDirectories);
|
|
58
|
+
|
|
59
|
+
expect(result).toStrictEqual([
|
|
60
|
+
{ dataModelPath: "ReplicatedStorage/client", fsPath: "out/client" },
|
|
61
|
+
{ dataModelPath: "ReplicatedStorage/shared", fsPath: "out/shared" },
|
|
62
|
+
{ dataModelPath: "ServerScriptService/server", fsPath: "out/server" },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should normalize trailing slash in $path", () => {
|
|
67
|
+
expect.assertions(1);
|
|
68
|
+
|
|
69
|
+
const tree = {
|
|
70
|
+
$className: "DataModel",
|
|
71
|
+
ReplicatedStorage: {
|
|
72
|
+
client: { $path: "out/client/" },
|
|
73
|
+
},
|
|
74
|
+
} satisfies RojoTreeNode;
|
|
75
|
+
|
|
76
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([
|
|
77
|
+
{ dataModelPath: "ReplicatedStorage/client", fsPath: "out/client" },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should skip file-valued $path entries", () => {
|
|
82
|
+
expect.assertions(1);
|
|
83
|
+
|
|
84
|
+
const tree = {
|
|
85
|
+
$className: "DataModel",
|
|
86
|
+
ReplicatedStorage: {
|
|
87
|
+
Constants: { $path: "out/shared/Constants.luau" },
|
|
88
|
+
shared: { $path: "out/shared" },
|
|
89
|
+
},
|
|
90
|
+
} satisfies RojoTreeNode;
|
|
91
|
+
|
|
92
|
+
const classify = makeClassifier({
|
|
93
|
+
"out/shared": "directory",
|
|
94
|
+
"out/shared/Constants.luau": "file",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(collectMounts(tree, "", classify)).toStrictEqual([
|
|
98
|
+
{ dataModelPath: "ReplicatedStorage/shared", fsPath: "out/shared" },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should skip missing paths silently", () => {
|
|
103
|
+
expect.assertions(1);
|
|
104
|
+
|
|
105
|
+
const tree = {
|
|
106
|
+
$className: "DataModel",
|
|
107
|
+
ReplicatedStorage: {
|
|
108
|
+
client: { $path: "out/client" },
|
|
109
|
+
missing: { $path: "out/missing" },
|
|
110
|
+
},
|
|
111
|
+
} satisfies RojoTreeNode;
|
|
112
|
+
|
|
113
|
+
const classify = makeClassifier({
|
|
114
|
+
"out/client": "directory",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = collectMounts(tree, "", classify);
|
|
118
|
+
|
|
119
|
+
expect(result).toStrictEqual([
|
|
120
|
+
{ dataModelPath: "ReplicatedStorage/client", fsPath: "out/client" },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should skip $path entries pointing at .project.json files", () => {
|
|
125
|
+
expect.assertions(2);
|
|
126
|
+
|
|
127
|
+
const tree = {
|
|
128
|
+
$className: "DataModel",
|
|
129
|
+
ReplicatedStorage: {
|
|
130
|
+
nested: { $path: "packages/shared/default.project.json" },
|
|
131
|
+
},
|
|
132
|
+
} satisfies RojoTreeNode;
|
|
133
|
+
|
|
134
|
+
const classify = vi.fn<PathClassifier>();
|
|
135
|
+
|
|
136
|
+
expect(collectMounts(tree, "", classify)).toStrictEqual([]);
|
|
137
|
+
expect(classify).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should skip $path entries with non-string values", () => {
|
|
141
|
+
expect.assertions(1);
|
|
142
|
+
|
|
143
|
+
const tree = {
|
|
144
|
+
$className: "DataModel",
|
|
145
|
+
ReplicatedStorage: {
|
|
146
|
+
optional: { $path: { optional: "out/maybe" } },
|
|
147
|
+
},
|
|
148
|
+
} satisfies RojoTreeNode;
|
|
149
|
+
|
|
150
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should skip keys that start with $", () => {
|
|
154
|
+
expect.assertions(1);
|
|
155
|
+
|
|
156
|
+
const tree = {
|
|
157
|
+
$className: "DataModel",
|
|
158
|
+
$properties: { Name: "Game" },
|
|
159
|
+
} satisfies RojoTreeNode;
|
|
160
|
+
|
|
161
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should skip non-object values", () => {
|
|
165
|
+
expect.assertions(1);
|
|
166
|
+
|
|
167
|
+
const tree = {
|
|
168
|
+
$className: "DataModel",
|
|
169
|
+
$ignoreUnknownInstances: true,
|
|
170
|
+
} satisfies RojoTreeNode;
|
|
171
|
+
|
|
172
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should walk deeply nested tree structures", () => {
|
|
176
|
+
expect.assertions(1);
|
|
177
|
+
|
|
178
|
+
const tree = {
|
|
179
|
+
$className: "DataModel",
|
|
180
|
+
ReplicatedStorage: {
|
|
181
|
+
Packages: {
|
|
182
|
+
friends: {
|
|
183
|
+
Client: { $path: "src/Client" },
|
|
184
|
+
Shared: { $path: "src/Shared" },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
} satisfies RojoTreeNode;
|
|
189
|
+
|
|
190
|
+
expect(collectMounts(tree, "", allDirectories)).toStrictEqual([
|
|
191
|
+
{
|
|
192
|
+
dataModelPath: "ReplicatedStorage/Packages/friends/Client",
|
|
193
|
+
fsPath: "src/Client",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
dataModelPath: "ReplicatedStorage/Packages/friends/Shared",
|
|
197
|
+
fsPath: "src/Shared",
|
|
198
|
+
},
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should build DataModel paths from given current prefix", () => {
|
|
203
|
+
expect.assertions(1);
|
|
204
|
+
|
|
205
|
+
const subtree = {
|
|
206
|
+
lib: { $path: "out/lib" },
|
|
207
|
+
} satisfies RojoTreeNode;
|
|
208
|
+
|
|
209
|
+
expect(collectMounts(subtree, "ReplicatedStorage/shared", allDirectories)).toStrictEqual([
|
|
210
|
+
{ dataModelPath: "ReplicatedStorage/shared/lib", fsPath: "out/lib" },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe(pruneAncestors, () => {
|
|
216
|
+
it("should return empty array for empty input", () => {
|
|
217
|
+
expect.assertions(1);
|
|
218
|
+
|
|
219
|
+
expect(pruneAncestors([])).toStrictEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should pass unrelated paths through unchanged", () => {
|
|
223
|
+
expect.assertions(1);
|
|
224
|
+
|
|
225
|
+
const paths = ["ReplicatedStorage/Client", "ServerScriptService/Server"];
|
|
226
|
+
|
|
227
|
+
expect(pruneAncestors(paths)).toStrictEqual(paths);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should drop descendant when ancestor is present", () => {
|
|
231
|
+
expect.assertions(1);
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
pruneAncestors(["ReplicatedStorage/Client", "ReplicatedStorage/Client/Systems"]),
|
|
235
|
+
).toStrictEqual(["ReplicatedStorage/Client"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should drop descendant regardless of input order", () => {
|
|
239
|
+
expect.assertions(1);
|
|
240
|
+
|
|
241
|
+
expect(
|
|
242
|
+
pruneAncestors(["ReplicatedStorage/Client/Systems", "ReplicatedStorage/Client"]),
|
|
243
|
+
).toStrictEqual(["ReplicatedStorage/Client"]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should respect segment boundaries when testing ancestry", () => {
|
|
247
|
+
expect.assertions(1);
|
|
248
|
+
|
|
249
|
+
expect(
|
|
250
|
+
pruneAncestors(["ReplicatedStorage/Client", "ReplicatedStorage/ClientExtras"]),
|
|
251
|
+
).toStrictEqual(["ReplicatedStorage/Client", "ReplicatedStorage/ClientExtras"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should keep identical paths unchanged (caller dedupes)", () => {
|
|
255
|
+
expect.assertions(1);
|
|
256
|
+
|
|
257
|
+
expect(pruneAncestors(["A/B", "A/B"])).toStrictEqual(["A/B", "A/B"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should drop all descendants under a common ancestor", () => {
|
|
261
|
+
expect.assertions(1);
|
|
262
|
+
|
|
263
|
+
expect(
|
|
264
|
+
pruneAncestors([
|
|
265
|
+
"ReplicatedStorage/Client",
|
|
266
|
+
"ReplicatedStorage/Client/Systems",
|
|
267
|
+
"ReplicatedStorage/Client/Components",
|
|
268
|
+
"ServerScriptService/Server",
|
|
269
|
+
]),
|
|
270
|
+
).toStrictEqual(["ReplicatedStorage/Client", "ServerScriptService/Server"]);
|
|
271
|
+
});
|
|
272
|
+
});
|