@ldgerrits/rogen 1.4.1 → 1.4.3

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 CHANGED
@@ -5,20 +5,22 @@
5
5
  </div>
6
6
 
7
7
  <div align="center">
8
- <a href="https://rogen-playfully.vercel.app"><strong>Documentation</strong></a>
8
+ <a href="https://rogen-playfully.vercel.app"><strong>documentation</strong></a>
9
9
  </div>
10
10
 
11
11
  ---
12
12
 
13
- A CLI tool enabling feature-based architecture through automated routing of client, server, and shared code to Roblox services.
13
+ <div align="center">
14
+ A CLI tool that enables feature-based architecture for Roblox
15
+ </div>
14
16
 
15
17
  ## Features
16
18
 
17
- - **Feature Folders:** Store related client, server and shared scripts inside a single folder.
18
- - **Auto-Routing:** Route files to Roblox services automatically using Rojo or Argon.
19
+ - **Feature Folders:** Keep related client, server, and shared code inside a single folder.
20
+ - **Auto-Routing:** Map files to Roblox services automatically using Rojo or Argon.
19
21
  - **Multi-Place Support:** Merge multiple source directories to share core systems across different places.
20
- - **Environment Tags:** Filter test files and inject mock files at build time using tags.
21
- - **Custom Pathing:** Control the final output structure using invisible folders and marker files.
22
+ - **Environment Tags:** Filter test files and inject mock dependencies at build time using tags.
23
+ - **Granular Control:** Shape the project tree using invisible folders, hoisting, and marker files.
22
24
  - **Modern Tooling:** Support Luau, roblox-ts, and Darklua pipelines out of the box.
23
25
 
24
26
  ## Contributing
package/dist/build.js CHANGED
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import picomatch from "picomatch";
4
4
  import { applyCasing, getOrCreateNode, pruneObject, sortObject, findMissingPaths, toPosix, collapseFolders, isInitFile, isValidSource, findExposedDataFiles, } from "./tree.js";
5
5
  import { resolveRoute, } from "./route.js";
6
- import { serviceParents, generateRoutingMaps, defaultConfig, } from "./constants.js";
6
+ import { serviceParents, generateRoutingMaps, defaultConfig, services, } from "./constants.js";
7
7
  const SYSTEM_MARKERS = {
8
8
  structure: true,
9
9
  verbatim: true,
@@ -116,6 +116,25 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
116
116
  modeCopy.build = cliArgs.build;
117
117
  }
118
118
  const rojoTree = structuredClone(baseProjectTree);
119
+ // Pre-populate all routing containers to prevent desync issues
120
+ for (const targetService of Object.values(services)) {
121
+ let current = rojoTree.tree;
122
+ if (serviceParents[targetService]) {
123
+ current = getOrCreateNode(current, serviceParents[targetService]);
124
+ }
125
+ getOrCreateNode(current, targetService);
126
+ }
127
+ if (config.aliases) {
128
+ for (const aliasPath of Object.values(config.aliases)) {
129
+ if (typeof aliasPath === "string") {
130
+ const parts = aliasPath.split(".");
131
+ let current = rojoTree.tree;
132
+ for (const part of parts) {
133
+ current = getOrCreateNode(current, part);
134
+ }
135
+ }
136
+ }
137
+ }
119
138
  const mergedTags = { ...(config.tags || {}), ...(modeCopy.tags || {}) };
120
139
  const knownTags = new Set(Object.keys(config.tags || {}).map((t) => t.toLowerCase()));
121
140
  const activeTags = new Set(Object.keys(mergedTags)
@@ -146,6 +165,7 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
146
165
  tagRegexes,
147
166
  };
148
167
  const combinedGlobIgnorePaths = Array.from(new Set([
168
+ ...(rojoTree.globIgnorePaths || []),
149
169
  ...(config.globIgnorePaths || []),
150
170
  ...(modeCopy.globIgnorePaths || []),
151
171
  ]));
@@ -167,10 +187,8 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
167
187
  };
168
188
  walkSource(sourcePath, sourcePath, listings, (filepath, isInit, isSync) => {
169
189
  const relativePath = path.relative(sourcePath, filepath);
170
- if (relativePath && isIgnored(toPosix(relativePath)))
171
- return;
172
190
  const { targetService, wrapperFolder, virtualParts, nodeName, projectPath, dropped, unwrap, } = resolveRoute(relativePath, isInit, newContext, isSync);
173
- if (dropped)
191
+ if (dropped || (projectPath && isIgnored(toPosix(projectPath))))
174
192
  return;
175
193
  fileCount++;
176
194
  let current = rojoTree.tree;
@@ -194,10 +212,13 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
194
212
  const existingNode = existingNodeRaw || {};
195
213
  const newNode = {
196
214
  ...existingNode,
197
- $path: projectPath,
215
+ $path: { optional: projectPath },
198
216
  };
199
217
  if (newNode.$className === "Folder") {
200
218
  delete newNode.$className;
219
+ if (newNode.$ignoreUnknownInstances === false) {
220
+ delete newNode.$ignoreUnknownInstances;
221
+ }
201
222
  }
202
223
  current[nodeName] = newNode;
203
224
  nodeOrigins.set(newNode, {
@@ -209,8 +230,11 @@ export async function build(targetConfig, baseProjectTree, config, env, sourcePa
209
230
  const outputDir = path.dirname(modeCopy.output);
210
231
  const removed = [];
211
232
  const prunedTree = pruneObject(rojoTree.tree, context.build, outputDir, removed);
212
- collapseFolders(prunedTree, context.build, outputDir);
233
+ collapseFolders(prunedTree, context.build, outputDir, isIgnored);
213
234
  rojoTree.tree = prunedTree;
235
+ if (combinedGlobIgnorePaths.length > 0) {
236
+ rojoTree.globIgnorePaths = combinedGlobIgnorePaths;
237
+ }
214
238
  const sortedTree = sortObject(rojoTree);
215
239
  const missingPaths = findMissingPaths(sortedTree.tree, context.build, outputDir);
216
240
  const exposedDataFiles = findExposedDataFiles(sortedTree.tree);
package/dist/bundle.cjs CHANGED
@@ -8858,14 +8858,15 @@ var import_chokidar = __toESM(require_chokidar(), 1);
8858
8858
  var import_util = require("util");
8859
8859
  function printHelp(logger) {
8860
8860
  const helpText = `
8861
- Rogen - Feature-based folder routing for Roblox
8861
+ Rogen - Feature-based architecture for Roblox
8862
8862
 
8863
8863
  Usage:
8864
8864
  rogen [command] [options]
8865
8865
 
8866
8866
  Commands:
8867
8867
  init Generate a .rogen.json config file
8868
- watch Watch the source and generate automatically
8868
+ build Build the project
8869
+ watch Watch the source and build automatically
8869
8870
 
8870
8871
  Options:
8871
8872
  -c, --config <path> Specify custom config file
@@ -8916,6 +8917,7 @@ function parseCliArgs(args = process.argv.slice(2), logger) {
8916
8917
  parsedArgs.help = true;
8917
8918
  } else if (subcommand === "version") {
8918
8919
  parsedArgs.version = true;
8920
+ } else if (subcommand === "build") {
8919
8921
  } else {
8920
8922
  const errorMsg = `unknown subcommand "${positionals[0]}".
8921
8923
  Run 'rogen --help' to see a list of available commands.`;
@@ -8964,7 +8966,17 @@ var import_fs = __toESM(require("fs"), 1);
8964
8966
  var import_path = __toESM(require("path"), 1);
8965
8967
 
8966
8968
  // src/constants.ts
8967
- var version = "1.4.1";
8969
+ var version = "1.4.3";
8970
+ var dataExtensions = /* @__PURE__ */ new Set([
8971
+ ".csv",
8972
+ ".txt",
8973
+ ".json",
8974
+ ".toml",
8975
+ ".yaml",
8976
+ ".yml",
8977
+ ".msgpack",
8978
+ ".md"
8979
+ ]);
8968
8980
  var defaultProject = {
8969
8981
  name: "roblox-game",
8970
8982
  tree: { $className: "DataModel" }
@@ -9018,6 +9030,10 @@ var serviceParents = {
9018
9030
  StarterPlayerScripts: "StarterPlayer",
9019
9031
  StarterCharacterScripts: "StarterPlayer"
9020
9032
  };
9033
+ var combinedServices = /* @__PURE__ */ new Set([
9034
+ ...Object.values(services),
9035
+ ...Object.values(serviceParents)
9036
+ ]);
9021
9037
  var serverContainers = /* @__PURE__ */ new Set([
9022
9038
  "ServerScriptService",
9023
9039
  "ServerStorage"
@@ -9418,9 +9434,27 @@ function applyCasing(value, casing) {
9418
9434
  const firstCharacter = casing === "PascalCase" ? value[0].toUpperCase() : value[0].toLowerCase();
9419
9435
  return firstCharacter + value.slice(1);
9420
9436
  }
9437
+ function getPathString(node) {
9438
+ const p = node.$path;
9439
+ if (typeof p === "string") return p;
9440
+ if (p && typeof p === "object" && typeof p.optional === "string")
9441
+ return p.optional;
9442
+ return void 0;
9443
+ }
9421
9444
  function getOrCreateNode(parent, key, className) {
9445
+ const isRootService = combinedServices.has(key) || className && combinedServices.has(className);
9422
9446
  if (!parent[key]) {
9423
- parent[key] = className == null ? {} : { $className: className };
9447
+ parent[key] = className == null ? {} : {
9448
+ $className: className,
9449
+ ...isRootService ? {} : { $ignoreUnknownInstances: false }
9450
+ };
9451
+ } else if (className != null) {
9452
+ const existing = parent[key];
9453
+ if (existing.$ignoreUnknownInstances === void 0 && existing.$path === void 0) {
9454
+ if (!isRootService) {
9455
+ existing.$ignoreUnknownInstances = false;
9456
+ }
9457
+ }
9424
9458
  }
9425
9459
  return parent[key];
9426
9460
  }
@@ -9430,16 +9464,20 @@ function pruneObject(node, buildDir, outputDir, removed = [], treePath = "") {
9430
9464
  if (typeof val !== "object" || val === null) continue;
9431
9465
  const childTreePath = treePath ? `${treePath}.${key}` : key;
9432
9466
  const childNode = val;
9433
- if (childNode.$path) {
9434
- if (hasPathPrefix(childNode.$path, buildDir)) continue;
9435
- const absolutePath = import_path2.default.resolve(outputDir, childNode.$path);
9436
- if (!import_fs2.default.existsSync(absolutePath)) {
9437
- delete node[key];
9438
- removed.push({
9439
- treePath: childTreePath,
9440
- rojoPath: childNode.$path
9441
- });
9442
- continue;
9467
+ const childPath = getPathString(childNode);
9468
+ if (childPath) {
9469
+ if (hasPathPrefix(childPath, buildDir)) continue;
9470
+ const isOptional = typeof childNode.$path === "object";
9471
+ if (!isOptional) {
9472
+ const absolutePath = import_path2.default.resolve(outputDir, childPath);
9473
+ if (!import_fs2.default.existsSync(absolutePath)) {
9474
+ delete node[key];
9475
+ removed.push({
9476
+ treePath: childTreePath,
9477
+ rojoPath: childPath
9478
+ });
9479
+ continue;
9480
+ }
9443
9481
  }
9444
9482
  }
9445
9483
  pruneObject(childNode, buildDir, outputDir, removed, childTreePath);
@@ -9462,14 +9500,15 @@ function findMissingPaths(node, buildDir, outputDir, missing = [], treePath = ""
9462
9500
  if (typeof val !== "object" || val === null) continue;
9463
9501
  const childTreePath = treePath ? `${treePath}.${key}` : key;
9464
9502
  const childNode = val;
9465
- if (childNode.$path && hasPathPrefix(childNode.$path, buildDir)) {
9466
- const absolutePath = import_path2.default.resolve(outputDir, childNode.$path);
9503
+ const childPath = getPathString(childNode);
9504
+ if (childPath && hasPathPrefix(childPath, buildDir)) {
9505
+ const absolutePath = import_path2.default.resolve(outputDir, childPath);
9467
9506
  if (!import_fs2.default.existsSync(absolutePath)) {
9468
9507
  missing.push({
9469
9508
  parent: node,
9470
9509
  key,
9471
9510
  treePath: childTreePath,
9472
- path: childNode.$path,
9511
+ path: childPath,
9473
9512
  absolutePath
9474
9513
  });
9475
9514
  }
@@ -9488,8 +9527,10 @@ var isScript = (filename) => /\.(tsx?|luau|lua)$/i.test(filename) && !filename.t
9488
9527
  var isModel = (filename) => /\.(rbxm|rbxmx)$/i.test(filename);
9489
9528
  function isData(filename) {
9490
9529
  if (isScript(filename) || isModel(filename)) return false;
9530
+ if (filename.toLowerCase().endsWith(".d.ts")) return false;
9491
9531
  if (filename.startsWith(".")) return false;
9492
- return /\.[a-z0-9]+$/i.test(filename);
9532
+ const ext = import_path2.default.extname(filename).toLowerCase();
9533
+ return dataExtensions.has(ext);
9493
9534
  }
9494
9535
  var isValidSource = (filename) => isScript(filename) || isModel(filename) || isData(filename);
9495
9536
  var isInitFile = (filename) => isScript(filename) && /^(index|init)([.-][a-z0-9_]+)?\./i.test(filename);
@@ -9505,7 +9546,7 @@ function getRojoBaseName(filename) {
9505
9546
  }
9506
9547
  return filename;
9507
9548
  }
9508
- function collapseFolders(node, buildDir, outputDir) {
9549
+ function collapseFolders(node, buildDir, outputDir, isIgnored = () => false) {
9509
9550
  let childCount = 0;
9510
9551
  let canCollapse = node.$path === void 0;
9511
9552
  let commonDir = null;
@@ -9514,12 +9555,13 @@ function collapseFolders(node, buildDir, outputDir) {
9514
9555
  const val = node[key];
9515
9556
  if (typeof val !== "object" || val === null) continue;
9516
9557
  const childNode = val;
9517
- collapseFolders(childNode, buildDir, outputDir);
9558
+ collapseFolders(childNode, buildDir, outputDir, isIgnored);
9518
9559
  childCount++;
9519
- if (!childNode.$path) {
9560
+ const childPath = getPathString(childNode);
9561
+ if (!childPath) {
9520
9562
  canCollapse = false;
9521
9563
  } else {
9522
- const childAbsPath = import_path2.default.resolve(outputDir, childNode.$path);
9564
+ const childAbsPath = import_path2.default.resolve(outputDir, childPath);
9523
9565
  const parentDir = import_path2.default.dirname(childAbsPath);
9524
9566
  if (commonDir === null) {
9525
9567
  commonDir = parentDir;
@@ -9539,10 +9581,11 @@ function collapseFolders(node, buildDir, outputDir) {
9539
9581
  if (!hasPathPrefix(toPosix(commonDir), toPosix(absoluteBuildDir))) {
9540
9582
  return;
9541
9583
  }
9584
+ const relativeCommonDir = toPosix(import_path2.default.relative(outputDir, commonDir));
9542
9585
  try {
9543
9586
  const diskItems = import_fs2.default.readdirSync(commonDir);
9544
9587
  const visibleDiskItems = diskItems.filter(
9545
- (item) => !item.startsWith(".")
9588
+ (item) => !item.startsWith(".") && !isIgnored(toPosix(import_path2.default.join(relativeCommonDir, item)))
9546
9589
  );
9547
9590
  if (visibleDiskItems.length !== childCount) {
9548
9591
  return;
@@ -9550,13 +9593,12 @@ function collapseFolders(node, buildDir, outputDir) {
9550
9593
  } catch {
9551
9594
  return;
9552
9595
  }
9553
- const relativeCommonDir = toPosix(import_path2.default.relative(outputDir, commonDir));
9554
9596
  for (const key in node) {
9555
9597
  if (!key.startsWith("$")) {
9556
9598
  delete node[key];
9557
9599
  }
9558
9600
  }
9559
- node.$path = relativeCommonDir;
9601
+ node.$path = { optional: relativeCommonDir };
9560
9602
  delete node.$className;
9561
9603
  }
9562
9604
  function findExposedDataFiles(node, exposed = []) {
@@ -9565,8 +9607,9 @@ function findExposedDataFiles(node, exposed = []) {
9565
9607
  const val = node[key];
9566
9608
  if (typeof val !== "object" || val === null) continue;
9567
9609
  const childNode = val;
9568
- if (childNode.$path && isData(childNode.$path)) {
9569
- exposed.push({ parent: node, key, path: childNode.$path });
9610
+ const childPath = getPathString(childNode);
9611
+ if (childPath && isData(childPath)) {
9612
+ exposed.push({ parent: node, key, path: childPath });
9570
9613
  }
9571
9614
  findExposedDataFiles(childNode, exposed);
9572
9615
  }
@@ -10003,6 +10046,24 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10003
10046
  modeCopy.build = cliArgs.build;
10004
10047
  }
10005
10048
  const rojoTree = structuredClone(baseProjectTree);
10049
+ for (const targetService of Object.values(services)) {
10050
+ let current = rojoTree.tree;
10051
+ if (serviceParents[targetService]) {
10052
+ current = getOrCreateNode(current, serviceParents[targetService]);
10053
+ }
10054
+ getOrCreateNode(current, targetService);
10055
+ }
10056
+ if (config.aliases) {
10057
+ for (const aliasPath of Object.values(config.aliases)) {
10058
+ if (typeof aliasPath === "string") {
10059
+ const parts = aliasPath.split(".");
10060
+ let current = rojoTree.tree;
10061
+ for (const part of parts) {
10062
+ current = getOrCreateNode(current, part);
10063
+ }
10064
+ }
10065
+ }
10066
+ }
10006
10067
  const mergedTags = { ...config.tags || {}, ...modeCopy.tags || {} };
10007
10068
  const knownTags = new Set(
10008
10069
  Object.keys(config.tags || {}).map((t) => t.toLowerCase())
@@ -10038,6 +10099,7 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10038
10099
  };
10039
10100
  const combinedGlobIgnorePaths = Array.from(
10040
10101
  /* @__PURE__ */ new Set([
10102
+ ...rojoTree.globIgnorePaths || [],
10041
10103
  ...config.globIgnorePaths || [],
10042
10104
  ...modeCopy.globIgnorePaths || []
10043
10105
  ])
@@ -10067,7 +10129,6 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10067
10129
  listings,
10068
10130
  (filepath, isInit, isSync) => {
10069
10131
  const relativePath2 = import_path4.default.relative(sourcePath, filepath);
10070
- if (relativePath2 && isIgnored(toPosix(relativePath2))) return;
10071
10132
  const {
10072
10133
  targetService,
10073
10134
  wrapperFolder,
@@ -10077,7 +10138,8 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10077
10138
  dropped,
10078
10139
  unwrap
10079
10140
  } = resolveRoute(relativePath2, isInit, newContext, isSync);
10080
- if (dropped) return;
10141
+ if (dropped || projectPath && isIgnored(toPosix(projectPath)))
10142
+ return;
10081
10143
  fileCount++;
10082
10144
  let current = rojoTree.tree;
10083
10145
  if (serviceParents[targetService]) {
@@ -10109,10 +10171,13 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10109
10171
  const existingNode = existingNodeRaw || {};
10110
10172
  const newNode = {
10111
10173
  ...existingNode,
10112
- $path: projectPath
10174
+ $path: { optional: projectPath }
10113
10175
  };
10114
10176
  if (newNode.$className === "Folder") {
10115
10177
  delete newNode.$className;
10178
+ if (newNode.$ignoreUnknownInstances === false) {
10179
+ delete newNode.$ignoreUnknownInstances;
10180
+ }
10116
10181
  }
10117
10182
  current[nodeName] = newNode;
10118
10183
  nodeOrigins.set(newNode, {
@@ -10130,8 +10195,11 @@ async function build(targetConfig, baseProjectTree, config, env, sourcePaths, cl
10130
10195
  outputDir,
10131
10196
  removed
10132
10197
  );
10133
- collapseFolders(prunedTree, context.build, outputDir);
10198
+ collapseFolders(prunedTree, context.build, outputDir, isIgnored);
10134
10199
  rojoTree.tree = prunedTree;
10200
+ if (combinedGlobIgnorePaths.length > 0) {
10201
+ rojoTree.globIgnorePaths = combinedGlobIgnorePaths;
10202
+ }
10135
10203
  const sortedTree = sortObject(rojoTree);
10136
10204
  const missingPaths = findMissingPaths(
10137
10205
  sortedTree.tree,
@@ -10242,7 +10310,7 @@ var ConsoleLogger = class extends AbstractLogger {
10242
10310
  };
10243
10311
 
10244
10312
  // src/execute.ts
10245
- async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger()) {
10313
+ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger(), isInitialRun = false) {
10246
10314
  try {
10247
10315
  for (const activeMode of activeModes) {
10248
10316
  const targetConfig = activeMode.config;
@@ -10281,15 +10349,16 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10281
10349
  }
10282
10350
  }
10283
10351
  if (buildResult.missingPaths.length > 0) {
10352
+ const isCompiledProject = env.isTsProject || env.isDarkluaProject;
10284
10353
  for (const item of buildResult.missingPaths) {
10285
10354
  const ext = import_path5.default.extname(item.absolutePath).toLowerCase();
10286
- if (ext === ".luau" || ext === ".lua") {
10355
+ if (isCompiledProject && (ext === ".luau" || ext === ".lua")) {
10287
10356
  const dir = import_path5.default.dirname(item.absolutePath);
10288
10357
  if (!import_fs4.default.existsSync(dir)) {
10289
10358
  import_fs4.default.mkdirSync(dir, { recursive: true });
10290
10359
  }
10291
10360
  import_fs4.default.writeFileSync(item.absolutePath, "");
10292
- } else if (ext === "") {
10361
+ } else if (isCompiledProject && ext === "") {
10293
10362
  if (!import_fs4.default.existsSync(item.absolutePath)) {
10294
10363
  import_fs4.default.mkdirSync(item.absolutePath, {
10295
10364
  recursive: true
@@ -10312,14 +10381,18 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10312
10381
  shouldWrite = false;
10313
10382
  }
10314
10383
  }
10315
- if (!shouldWrite) {
10384
+ if (!shouldWrite && cliArgs.watch && !isInitialRun) {
10316
10385
  continue;
10317
10386
  }
10318
- const outputDir = import_path5.default.dirname(buildResult.output);
10319
- if (!import_fs4.default.existsSync(outputDir)) {
10320
- import_fs4.default.mkdirSync(outputDir, { recursive: true });
10387
+ if (shouldWrite) {
10388
+ const outputDir = import_path5.default.dirname(buildResult.output);
10389
+ if (!import_fs4.default.existsSync(outputDir)) {
10390
+ import_fs4.default.mkdirSync(outputDir, { recursive: true });
10391
+ }
10392
+ const tempOutput = `${buildResult.output}.tmp`;
10393
+ import_fs4.default.writeFileSync(tempOutput, finalContent);
10394
+ import_fs4.default.renameSync(tempOutput, buildResult.output);
10321
10395
  }
10322
- import_fs4.default.writeFileSync(buildResult.output, finalContent);
10323
10396
  const totalRemoved = buildResult.removed.length + dropped.length;
10324
10397
  if (totalRemoved > 0) {
10325
10398
  if (cliArgs.watch) {
@@ -10341,22 +10414,30 @@ async function execute(sourcePaths, env, activeModes, baseProjectTree, config, c
10341
10414
  logger.warn(collision);
10342
10415
  }
10343
10416
  }
10344
- if (cliArgs.watch) {
10417
+ if (!isInitialRun) {
10345
10418
  const outputName = import_path5.default.basename(buildResult.output);
10346
10419
  logger.success(
10347
10420
  `[${modeName}] Rebuilt "${buildResult.name}" -> ${outputName}`
10348
10421
  );
10349
10422
  } else {
10350
- logger.success(`Generated Rojo tree for "${buildResult.name}"`);
10351
- logger.info(` Processed: ${buildResult.fileCount} files`);
10352
- logger.info(` Build dir: ${buildResult.buildDir}`);
10353
- const activeTags = Object.keys(targetConfig.tags || {}).filter(
10354
- (t) => targetConfig.tags[t]
10355
- );
10356
- if (activeTags.length > 0) {
10357
- logger.info(` Tags: ${activeTags.join(", ")}`);
10423
+ if (shouldWrite) {
10424
+ logger.success(
10425
+ `Generated Rojo tree for "${buildResult.name}"`
10426
+ );
10427
+ logger.info(` Processed: ${buildResult.fileCount} files`);
10428
+ logger.info(` Build dir: ${buildResult.buildDir}`);
10429
+ const activeTags = Object.keys(
10430
+ targetConfig.tags || {}
10431
+ ).filter((t) => targetConfig.tags[t]);
10432
+ if (activeTags.length > 0) {
10433
+ logger.info(` Tags: ${activeTags.join(", ")}`);
10434
+ }
10435
+ logger.info(` Output to: ${buildResult.output}`);
10436
+ } else {
10437
+ logger.success(
10438
+ `Rojo tree for "${buildResult.name}" is already up to date`
10439
+ );
10358
10440
  }
10359
- logger.info(` Output to: ${buildResult.output}`);
10360
10441
  }
10361
10442
  }
10362
10443
  return true;
@@ -10440,7 +10521,8 @@ async function main() {
10440
10521
  config,
10441
10522
  cliArgs,
10442
10523
  anchor,
10443
- logger
10524
+ logger,
10525
+ true
10444
10526
  );
10445
10527
  if (!success) {
10446
10528
  process.exit(1);
@@ -10449,9 +10531,14 @@ async function main() {
10449
10531
  logger.info(
10450
10532
  `Watching for file changes in: "${sourceDirs.join(", ")}" (Ctrl+C to stop)`
10451
10533
  );
10534
+ const isWindows = process.platform === "win32";
10452
10535
  const watcher = import_chokidar.default.watch(sourcePaths, {
10453
10536
  persistent: true,
10454
- ignoreInitial: true
10537
+ ignoreInitial: true,
10538
+ // Use polling on Windows to avoid "folder in use" locks
10539
+ usePolling: isWindows,
10540
+ interval: isWindows ? 400 : void 0,
10541
+ binaryInterval: isWindows ? 800 : void 0
10455
10542
  });
10456
10543
  let debounceTimeout;
10457
10544
  let isBuilding = false;
@@ -10470,7 +10557,8 @@ async function main() {
10470
10557
  config,
10471
10558
  cliArgs,
10472
10559
  anchor,
10473
- logger
10560
+ logger,
10561
+ false
10474
10562
  );
10475
10563
  } catch (err) {
10476
10564
  logger.error(err instanceof Error ? err : String(err));
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { parseArgs } from "util";
2
2
  export function printHelp(logger) {
3
3
  const helpText = `
4
- Rogen - Feature-based folder routing for Roblox
4
+ Rogen - Feature-based architecture for Roblox
5
5
 
6
6
  Usage:
7
7
  rogen [command] [options]
8
8
 
9
9
  Commands:
10
10
  init Generate a .rogen.json config file
11
- watch Watch the source and generate automatically
11
+ build Build the project
12
+ watch Watch the source and build automatically
12
13
 
13
14
  Options:
14
15
  -c, --config <path> Specify custom config file
@@ -64,6 +65,9 @@ export function parseCliArgs(args = process.argv.slice(2), logger) {
64
65
  else if (subcommand === "version") {
65
66
  parsedArgs.version = true;
66
67
  }
68
+ else if (subcommand === "build") {
69
+ // build is the default command, so it falls through cleanly
70
+ }
67
71
  else {
68
72
  const errorMsg = `unknown subcommand "${positionals[0]}".\nRun 'rogen --help' to see a list of available commands.`;
69
73
  if (logger) {
package/dist/constants.js CHANGED
@@ -1,4 +1,14 @@
1
- export const version = "1.4.1";
1
+ export const version = "1.4.3";
2
+ export const dataExtensions = new Set([
3
+ ".csv",
4
+ ".txt",
5
+ ".json",
6
+ ".toml",
7
+ ".yaml",
8
+ ".yml",
9
+ ".msgpack",
10
+ ".md",
11
+ ]);
2
12
  export const defaultProject = {
3
13
  name: "roblox-game",
4
14
  tree: { $className: "DataModel" },
@@ -52,6 +62,10 @@ export const serviceParents = {
52
62
  StarterPlayerScripts: "StarterPlayer",
53
63
  StarterCharacterScripts: "StarterPlayer",
54
64
  };
65
+ export const combinedServices = new Set([
66
+ ...Object.values(services),
67
+ ...Object.values(serviceParents),
68
+ ]);
55
69
  export const serverContainers = new Set([
56
70
  "ServerScriptService",
57
71
  "ServerStorage",
package/dist/execute.js CHANGED
@@ -2,7 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { build } from "./build.js";
4
4
  import { ConsoleLogger, getTimeStamp } from "./logger.js";
5
- export async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger()) {
5
+ export async function execute(sourcePaths, env, activeModes, baseProjectTree, config, cliArgs, anchor, logger = new ConsoleLogger(), isInitialRun = false) {
6
6
  try {
7
7
  for (const activeMode of activeModes) {
8
8
  const targetConfig = activeMode.config;
@@ -24,16 +24,22 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
24
24
  }
25
25
  }
26
26
  if (buildResult.missingPaths.length > 0) {
27
+ // Only synthesize placeholder stubs/dirs for compiled projects (ts/darklua),
28
+ // where the build output legitimately does not exist yet at generation time. For a luau
29
+ // project the build dir IS the source, so a missing path means the file was removed —
30
+ // recreating it would fight an in-progress delete and desync Rojo. Drop the stale node instead.
31
+ const isCompiledProject = env.isTsProject || env.isDarkluaProject;
27
32
  for (const item of buildResult.missingPaths) {
28
33
  const ext = path.extname(item.absolutePath).toLowerCase();
29
- if (ext === ".luau" || ext === ".lua") {
34
+ if (isCompiledProject &&
35
+ (ext === ".luau" || ext === ".lua")) {
30
36
  const dir = path.dirname(item.absolutePath);
31
37
  if (!fs.existsSync(dir)) {
32
38
  fs.mkdirSync(dir, { recursive: true });
33
39
  }
34
40
  fs.writeFileSync(item.absolutePath, "");
35
41
  }
36
- else if (ext === "") {
42
+ else if (isCompiledProject && ext === "") {
37
43
  if (!fs.existsSync(item.absolutePath)) {
38
44
  fs.mkdirSync(item.absolutePath, {
39
45
  recursive: true,
@@ -54,14 +60,20 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
54
60
  shouldWrite = false;
55
61
  }
56
62
  }
57
- if (!shouldWrite) {
63
+ if (!shouldWrite && cliArgs.watch && !isInitialRun) {
58
64
  continue;
59
65
  }
60
- const outputDir = path.dirname(buildResult.output);
61
- if (!fs.existsSync(outputDir)) {
62
- fs.mkdirSync(outputDir, { recursive: true });
66
+ if (shouldWrite) {
67
+ const outputDir = path.dirname(buildResult.output);
68
+ if (!fs.existsSync(outputDir)) {
69
+ fs.mkdirSync(outputDir, { recursive: true });
70
+ }
71
+ // Write atomically (temp + rename) so a watcher (e.g. Rojo) never reads a
72
+ // half-written project file during rapid regenerations.
73
+ const tempOutput = `${buildResult.output}.tmp`;
74
+ fs.writeFileSync(tempOutput, finalContent);
75
+ fs.renameSync(tempOutput, buildResult.output);
63
76
  }
64
- fs.writeFileSync(buildResult.output, finalContent);
65
77
  const totalRemoved = buildResult.removed.length + dropped.length;
66
78
  if (totalRemoved > 0) {
67
79
  if (cliArgs.watch) {
@@ -82,19 +94,24 @@ export async function execute(sourcePaths, env, activeModes, baseProjectTree, co
82
94
  logger.warn(collision);
83
95
  }
84
96
  }
85
- if (cliArgs.watch) {
97
+ if (!isInitialRun) {
86
98
  const outputName = path.basename(buildResult.output);
87
99
  logger.success(`[${modeName}] Rebuilt "${buildResult.name}" -> ${outputName}`);
88
100
  }
89
101
  else {
90
- logger.success(`Generated Rojo tree for "${buildResult.name}"`);
91
- logger.info(` Processed: ${buildResult.fileCount} files`);
92
- logger.info(` Build dir: ${buildResult.buildDir}`);
93
- const activeTags = Object.keys(targetConfig.tags || {}).filter((t) => targetConfig.tags[t]);
94
- if (activeTags.length > 0) {
95
- logger.info(` Tags: ${activeTags.join(", ")}`);
102
+ if (shouldWrite) {
103
+ logger.success(`Generated Rojo tree for "${buildResult.name}"`);
104
+ logger.info(` Processed: ${buildResult.fileCount} files`);
105
+ logger.info(` Build dir: ${buildResult.buildDir}`);
106
+ const activeTags = Object.keys(targetConfig.tags || {}).filter((t) => targetConfig.tags[t]);
107
+ if (activeTags.length > 0) {
108
+ logger.info(` Tags: ${activeTags.join(", ")}`);
109
+ }
110
+ logger.info(` Output to: ${buildResult.output}`);
111
+ }
112
+ else {
113
+ logger.success(`Rojo tree for "${buildResult.name}" is already up to date`);
96
114
  }
97
- logger.info(` Output to: ${buildResult.output}`);
98
115
  }
99
116
  }
100
117
  return true;
package/dist/index.js CHANGED
@@ -67,15 +67,20 @@ async function main() {
67
67
  });
68
68
  const env = getEnvironment(anchor, cliArgs.mode);
69
69
  const activeModes = resolveActiveModes(config, cliArgs.mode, env);
70
- const success = await execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger);
70
+ const success = await execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger, true);
71
71
  if (!success) {
72
72
  process.exit(1);
73
73
  }
74
74
  if (cliArgs.watch) {
75
75
  logger.info(`Watching for file changes in: "${sourceDirs.join(", ")}" (Ctrl+C to stop)`);
76
+ const isWindows = process.platform === "win32";
76
77
  const watcher = chokidar.watch(sourcePaths, {
77
78
  persistent: true,
78
79
  ignoreInitial: true,
80
+ // Use polling on Windows to avoid "folder in use" locks
81
+ usePolling: isWindows,
82
+ interval: isWindows ? 400 : undefined,
83
+ binaryInterval: isWindows ? 800 : undefined,
79
84
  });
80
85
  let debounceTimeout;
81
86
  let isBuilding = false;
@@ -87,7 +92,7 @@ async function main() {
87
92
  return;
88
93
  isBuilding = true;
89
94
  try {
90
- execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger);
95
+ execute(sourcePaths, env, activeModes, config.project, config, cliArgs, anchor, logger, false);
91
96
  }
92
97
  catch (err) {
93
98
  logger.error(err instanceof Error ? err : String(err));
package/dist/tree.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { combinedServices, dataExtensions } from "./constants.js";
3
4
  function hasPathPrefix(p, dir) {
4
5
  return p === dir || p.startsWith(dir + "/");
5
6
  }
@@ -12,9 +13,36 @@ export function applyCasing(value, casing) {
12
13
  : value[0].toLowerCase();
13
14
  return firstCharacter + value.slice(1);
14
15
  }
16
+ export function getPathString(node) {
17
+ const p = node.$path;
18
+ if (typeof p === "string")
19
+ return p;
20
+ if (p && typeof p === "object" && typeof p.optional === "string")
21
+ return p.optional;
22
+ return undefined;
23
+ }
15
24
  export function getOrCreateNode(parent, key, className) {
25
+ const isRootService = combinedServices.has(key) ||
26
+ (className && combinedServices.has(className));
16
27
  if (!parent[key]) {
17
- parent[key] = className == null ? {} : { $className: className };
28
+ parent[key] =
29
+ className == null
30
+ ? {}
31
+ : {
32
+ $className: className,
33
+ ...(isRootService
34
+ ? {}
35
+ : { $ignoreUnknownInstances: false }),
36
+ };
37
+ }
38
+ else if (className != null) {
39
+ const existing = parent[key];
40
+ if (existing.$ignoreUnknownInstances === undefined &&
41
+ existing.$path === undefined) {
42
+ if (!isRootService) {
43
+ existing.$ignoreUnknownInstances = false;
44
+ }
45
+ }
18
46
  }
19
47
  return parent[key];
20
48
  }
@@ -25,17 +53,23 @@ export function pruneObject(node, buildDir, outputDir, removed = [], treePath =
25
53
  continue;
26
54
  const childTreePath = treePath ? `${treePath}.${key}` : key;
27
55
  const childNode = val;
28
- if (childNode.$path) {
29
- if (hasPathPrefix(childNode.$path, buildDir))
30
- continue;
31
- const absolutePath = path.resolve(outputDir, childNode.$path);
32
- if (!fs.existsSync(absolutePath)) {
33
- delete node[key];
34
- removed.push({
35
- treePath: childTreePath,
36
- rojoPath: childNode.$path,
37
- });
56
+ const childPath = getPathString(childNode);
57
+ if (childPath) {
58
+ if (hasPathPrefix(childPath, buildDir))
38
59
  continue;
60
+ // Optional paths are allowed to be absent so only required (string) template
61
+ // paths are pruned when missing.
62
+ const isOptional = typeof childNode.$path === "object";
63
+ if (!isOptional) {
64
+ const absolutePath = path.resolve(outputDir, childPath);
65
+ if (!fs.existsSync(absolutePath)) {
66
+ delete node[key];
67
+ removed.push({
68
+ treePath: childTreePath,
69
+ rojoPath: childPath,
70
+ });
71
+ continue;
72
+ }
39
73
  }
40
74
  }
41
75
  pruneObject(childNode, buildDir, outputDir, removed, childTreePath);
@@ -61,14 +95,15 @@ export function findMissingPaths(node, buildDir, outputDir, missing = [], treePa
61
95
  continue;
62
96
  const childTreePath = treePath ? `${treePath}.${key}` : key;
63
97
  const childNode = val;
64
- if (childNode.$path && hasPathPrefix(childNode.$path, buildDir)) {
65
- const absolutePath = path.resolve(outputDir, childNode.$path);
98
+ const childPath = getPathString(childNode);
99
+ if (childPath && hasPathPrefix(childPath, buildDir)) {
100
+ const absolutePath = path.resolve(outputDir, childPath);
66
101
  if (!fs.existsSync(absolutePath)) {
67
102
  missing.push({
68
103
  parent: node,
69
104
  key,
70
105
  treePath: childTreePath,
71
- path: childNode.$path,
106
+ path: childPath,
72
107
  absolutePath,
73
108
  });
74
109
  }
@@ -83,9 +118,12 @@ const isModel = (filename) => /\.(rbxm|rbxmx)$/i.test(filename);
83
118
  export function isData(filename) {
84
119
  if (isScript(filename) || isModel(filename))
85
120
  return false;
121
+ if (filename.toLowerCase().endsWith(".d.ts"))
122
+ return false;
86
123
  if (filename.startsWith("."))
87
124
  return false;
88
- return /\.[a-z0-9]+$/i.test(filename);
125
+ const ext = path.extname(filename).toLowerCase();
126
+ return dataExtensions.has(ext);
89
127
  }
90
128
  export const isValidSource = (filename) => isScript(filename) || isModel(filename) || isData(filename);
91
129
  export const isInitFile = (filename) => isScript(filename) && /^(index|init)([.-][a-z0-9_]+)?\./i.test(filename);
@@ -103,7 +141,7 @@ function getRojoBaseName(filename) {
103
141
  }
104
142
  return filename;
105
143
  }
106
- export function collapseFolders(node, buildDir, outputDir) {
144
+ export function collapseFolders(node, buildDir, outputDir, isIgnored = () => false) {
107
145
  let childCount = 0;
108
146
  let canCollapse = node.$path === undefined; // Prevent overwriting a folder with an explicit $path
109
147
  let commonDir = null;
@@ -115,13 +153,14 @@ export function collapseFolders(node, buildDir, outputDir) {
115
153
  continue;
116
154
  const childNode = val;
117
155
  // Process deepest nested children first
118
- collapseFolders(childNode, buildDir, outputDir);
156
+ collapseFolders(childNode, buildDir, outputDir, isIgnored);
119
157
  childCount++;
120
- if (!childNode.$path) {
158
+ const childPath = getPathString(childNode);
159
+ if (!childPath) {
121
160
  canCollapse = false;
122
161
  }
123
162
  else {
124
- const childAbsPath = path.resolve(outputDir, childNode.$path);
163
+ const childAbsPath = path.resolve(outputDir, childPath);
125
164
  const parentDir = path.dirname(childAbsPath);
126
165
  // All children should share the same directory
127
166
  if (commonDir === null) {
@@ -143,10 +182,12 @@ export function collapseFolders(node, buildDir, outputDir) {
143
182
  if (!hasPathPrefix(toPosix(commonDir), toPosix(absoluteBuildDir))) {
144
183
  return;
145
184
  }
185
+ const relativeCommonDir = toPosix(path.relative(outputDir, commonDir));
146
186
  try {
147
187
  const diskItems = fs.readdirSync(commonDir);
148
- // filter out marker files so they do not prevent folders from collapsing
149
- const visibleDiskItems = diskItems.filter((item) => !item.startsWith("."));
188
+ // filter out marker files and ignored files so they do not prevent folders from collapsing
189
+ const visibleDiskItems = diskItems.filter((item) => !item.startsWith(".") &&
190
+ !isIgnored(toPosix(path.join(relativeCommonDir, item))));
150
191
  if (visibleDiskItems.length !== childCount) {
151
192
  return;
152
193
  }
@@ -155,13 +196,12 @@ export function collapseFolders(node, buildDir, outputDir) {
155
196
  return;
156
197
  }
157
198
  // Replace all child files with a single folder $path
158
- const relativeCommonDir = toPosix(path.relative(outputDir, commonDir));
159
199
  for (const key in node) {
160
200
  if (!key.startsWith("$")) {
161
201
  delete node[key];
162
202
  }
163
203
  }
164
- node.$path = relativeCommonDir;
204
+ node.$path = { optional: relativeCommonDir };
165
205
  delete node.$className;
166
206
  }
167
207
  export function findExposedDataFiles(node, exposed = []) {
@@ -172,9 +212,10 @@ export function findExposedDataFiles(node, exposed = []) {
172
212
  if (typeof val !== "object" || val === null)
173
213
  continue;
174
214
  const childNode = val;
215
+ const childPath = getPathString(childNode);
175
216
  // Check if the current node exposes a raw data file path
176
- if (childNode.$path && isData(childNode.$path)) {
177
- exposed.push({ parent: node, key, path: childNode.$path });
217
+ if (childPath && isData(childPath)) {
218
+ exposed.push({ parent: node, key, path: childPath });
178
219
  }
179
220
  findExposedDataFiles(childNode, exposed);
180
221
  }
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "@ldgerrits/rogen",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
+ "description": "A CLI tool that enables feature-based architecture for Roblox",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },
7
- "description": "A CLI tool enabling feature-based architecture through automated routing of client, server, and shared code to Roblox services",
8
+ "homepage": "https://rogen-playfully.vercel.app/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/LDGerrits/rogen.git"
12
+ },
13
+ "keywords": [
14
+ "roblox",
15
+ "organization",
16
+ "feature-based architecture"
17
+ ],
8
18
  "author": "ldgerrits",
9
19
  "license": "MIT",
10
20
  "type": "module",