@tilua-types/roblox 9.0.0 → 10.0.1

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.
Files changed (4) hide show
  1. package/README.md +71 -3
  2. package/lowering.mjs +14 -0
  3. package/package.json +12 -4
  4. package/rojo.mjs +133 -0
package/README.md CHANGED
@@ -71,6 +71,74 @@ caller's `xpcall`. `coroutine` is not wrapped either: `coroutine.resume`
71
71
  returning `false, err` is part of how it behaves. Outside a bundle, nothing is
72
72
  wrapped.
73
73
 
74
+ ## A game through Rojo
75
+
76
+ In a Rojo project as `rojo init` makes it:
77
+
78
+ ```bash
79
+ npm i -D @tilua/compiler @tilua-types/roblox
80
+ npx tilua init # src is what you write, out what Rojo reads
81
+ npx tilua --watch # beside `rojo serve`
82
+ ```
83
+
84
+ ```
85
+ src/ what you write
86
+ shared/Greet.tilua
87
+ shared/Hello.luau Luau written by hand
88
+ out/ the build's: compiled, copied, and nothing else
89
+ shared/Greet.luau
90
+ shared/Hello.luau
91
+ shared/_tilua.luau the runtime
92
+ ```
93
+
94
+ `tilua init` writes `{ "types": ["roblox"], "output": "files", "rootDir": "src", "outDir": "out" }`
95
+ — `src` being the folder the project's `$path`s share — points those `$path`s
96
+ at `out`, and adds `/out` to `.gitignore`. Each build makes `out` a mirror of
97
+ `src`: every `.tilua` compiled, every other file (Luau written by hand,
98
+ `.meta.json`, models) copied as it is, every folder there even when empty,
99
+ and nothing else — what is in `out` and no longer in `src` is removed.
100
+ `--watch` does that again on every change under `src`, and `rojo serve`
101
+ syncs it into Studio. A project set up the older way, each `.luau` compiled
102
+ beside its `.tilua`, is moved over by `tilua init` the same way, and the first
103
+ build removes those `.luau` from `src`.
104
+
105
+ The tree the project builds is typed as you write, read straight off
106
+ `default.project.json` and the files in `src` — nothing else to run:
107
+ `game.Workspace.Baseplate` is the project's part, `script` is the file's own
108
+ instance (`script.Parent` its folder), and `FindFirstChild("")` and
109
+ `WaitForChild("")` offer the children's names.
110
+
111
+ Where each file goes is read off the Rojo project — `default.project.json`
112
+ beside the config, or the one `"rojo"` names — and the files require each
113
+ other by the paths the game knows them by:
114
+
115
+ ```jsonc
116
+ // default.project.json, as `rojo init` writes it
117
+ {
118
+ "name": "game",
119
+ "tree": {
120
+ "$className": "DataModel",
121
+ "ReplicatedStorage": { "Shared": { "$path": "src/shared" } },
122
+ "ServerScriptService": { "Server": { "$path": "src/server" } },
123
+ "StarterPlayer": { "StarterPlayerScripts": { "Client": { "$path": "src/client" } } }
124
+ }
125
+ }
126
+ ```
127
+
128
+ | in `src` | in the game | requires |
129
+ |---|---|---|
130
+ | `server/main.server.tilua` | `ServerScriptService.Server.main`, a Script | `./data` beside it; `@game/ReplicatedStorage/Shared/net` across services |
131
+ | `client/hud.client.tilua` | a LocalScript under `StarterPlayerScripts` | waits for `game.Loaded` first: a `require` by path does not wait for what it names to replicate |
132
+ | `shared/net/init.tilua` | `ReplicatedStorage.Shared.net`, the folder as a ModuleScript | `@self/remote` below it, `./util` beside it |
133
+
134
+ - The runtime every file requires, `_tilua.luau`, goes in the folder the
135
+ project puts in `ReplicatedStorage`, where the server and the clients both
136
+ reach it. A project with none there is an error.
137
+ - `.server` and `.client` files are scripts the game runs, not modules:
138
+ importing one is an error, and what one returns is not typed.
139
+ - `import "./net"` finds `net/init.tilua`, as Rojo reads `net/init.luau`.
140
+ - A file the project puts nowhere is a warning: Rojo would leave it out.
141
+
74
142
  ## Where it comes from
75
143
 
76
144
  `index.d.tilua` is generated. Don't edit it by hand; change
@@ -90,11 +158,11 @@ luau-lsp is MIT licensed. Its notice is in
90
158
  <!-- compatibility:start -->
91
159
  ## Compatibility
92
160
 
93
- `@tilua-types/roblox@9.0.0` works with these tilua packages. *Released with* is the
161
+ `@tilua-types/roblox@10.0.1` works with these tilua packages. *Released with* is the
94
162
  version it was built and tested against.
95
163
 
96
164
  | Package | Range | Released with | |
97
165
  | --- | --- | --- | --- |
98
- | `@tilua-types/luau` | `^8.0.0` | 8.0.0 | dependency |
99
- | `@tilua/parser` | `^8.0.0` | 8.0.0 | peer |
166
+ | `@tilua-types/luau` | `^9.0.1` | 9.0.1 | dependency |
167
+ | `@tilua/parser` | `^9.0.0` | 9.0.1 | peer |
100
168
  <!-- compatibility:end -->
package/lowering.mjs CHANGED
@@ -26,11 +26,17 @@
26
26
  *
27
27
  * `print`, `error` and the console are the compiler's own: Lua's standard
28
28
  * library is built into tilua.
29
+ *
30
+ * With `"output": "files"`, where each file ends up in the game is read off
31
+ * the project's Rojo project (`rojo`, `default.project.json` unless set), and
32
+ * the files require each other by the paths the game knows them by (see
33
+ * rojo.mjs).
29
34
  */
30
35
  import { readFileSync } from "node:fs"
31
36
  import { dirname, join } from "node:path"
32
37
  import { fileURLToPath } from "node:url"
33
38
  import { hasMembers, isInstanceOf, luauString, returnsTuple } from "@tilua/parser"
39
+ import { rojoLayout } from "./rojo.mjs"
34
40
 
35
41
  /** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
36
42
 
@@ -58,6 +64,9 @@ const SIGNAL_MEMBERS = ["Connect", "Once", "Wait"]
58
64
  /** Luau globals answering several values, which the runtime gathers. */
59
65
  const GATHERED_GLOBALS = new Set(["string.unpack", "coroutine.close", "utf8.codepoint", "utf8.codes", "utf8.graphemes"])
60
66
 
67
+ /** The Rojo project a config that names none has. */
68
+ const DEFAULT_PROJECT = "default.project.json"
69
+
61
70
  /** @type {LoweringPlugin} */
62
71
  const plugin = {
63
72
  runtime: { roblox },
@@ -83,6 +92,11 @@ const plugin = {
83
92
  if (GATHERED_GLOBALS.has(name)) return `${use("roblox")}.globals.${name.replace(".", "_")}`
84
93
  return undefined
85
94
  },
95
+
96
+ files(layout) {
97
+ const project = typeof layout.options.rojo === "string" ? layout.options.rojo : DEFAULT_PROJECT
98
+ return rojoLayout(layout, project, project !== DEFAULT_PROJECT)
99
+ },
86
100
  }
87
101
 
88
102
  export default plugin
package/package.json CHANGED
@@ -1,24 +1,32 @@
1
1
  {
2
2
  "name": "@tilua-types/roblox",
3
- "version": "9.0.0",
3
+ "version": "10.0.1",
4
4
  "description": "Luau and the whole Roblox API as tilua types: every class, enum, data type, service and global",
5
5
  "license": "ISC",
6
6
  "files": [
7
7
  "index.d.tilua",
8
8
  "lowering.mjs",
9
+ "rojo.mjs",
9
10
  "runtime",
10
11
  "README.md",
11
12
  "THIRD_PARTY_NOTICES.md"
12
13
  ],
13
14
  "tilua": {
14
15
  "types": "index.d.tilua",
15
- "lowering": "lowering.mjs"
16
+ "lowering": "lowering.mjs",
17
+ "options": {
18
+ "rojo": {
19
+ "type": "string",
20
+ "default": "default.project.json",
21
+ "description": "With \"output\": \"files\", the Rojo project that says where each file goes in the game, from the config's folder."
22
+ }
23
+ }
16
24
  },
17
25
  "dependencies": {
18
- "@tilua-types/luau": "^8.0.0"
26
+ "@tilua-types/luau": "^9.0.1"
19
27
  },
20
28
  "peerDependencies": {
21
- "@tilua/parser": "^8.0.0"
29
+ "@tilua/parser": "^9.0.0"
22
30
  },
23
31
  "keywords": [
24
32
  "tilua",
package/rojo.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ /**
3
+ * Where the files of `"output": "files"` end up in the game, read off a Rojo
4
+ * project, and how they reach each other there.
5
+ *
6
+ * A Rojo project maps folders to places in the game — `dist/shared` to
7
+ * `ReplicatedStorage.Shared`, `dist/server` to `ServerScriptService` — so a
8
+ * file's place is its folder's, and its own name below that:
9
+ *
10
+ * dist/shared/util.luau ReplicatedStorage.Shared.util a ModuleScript
11
+ * dist/server/main.server.luau ServerScriptService.main a Script
12
+ * dist/client/hud.client.luau StarterPlayer.StarterPlayerScripts.hud a LocalScript
13
+ * dist/shared/net/init.luau ReplicatedStorage.Shared.net the folder, as a ModuleScript
14
+ *
15
+ * Within one service a file requires another by the path between them
16
+ * (`./util`, `../net`); across services, from the top: `@game/ReplicatedStorage/Shared/util`.
17
+ * The runtime every file requires goes where both the server and the
18
+ * clients can reach it: the folder the project puts in ReplicatedStorage.
19
+ *
20
+ * A script the game runs (`.server`, `.client`) is not a module, so nothing
21
+ * may import one; a LocalScript waits for the game to load before it
22
+ * requires anything, since a `require` by path does not wait for what it
23
+ * names to replicate.
24
+ */
25
+ import { readFileSync } from "node:fs"
26
+ import { dirname, relative, resolve, sep } from "node:path"
27
+
28
+ /** `.server.luau` and the rest: a script the game runs, not a module. */
29
+ const SCRIPT = /\.(server|client|plugin|local|legacy)\.luau?$/
30
+ const CLIENT = /\.client\.luau?$/
31
+
32
+ /** Where the runtime goes, best first: somewhere every client reaches. */
33
+ const SHARED_SERVICES = ["ReplicatedStorage", "ReplicatedFirst"]
34
+
35
+ /**
36
+ * @typedef {{ folder: string, place: string[] }} Mapping
37
+ * A `$path` of the project — absolute — and the place in the game it fills.
38
+ */
39
+
40
+ /**
41
+ * The layout of the files of `layout` under the Rojo project `projectPath`
42
+ * (from the config's folder), or `undefined` when there is none to read.
43
+ * @param {import("@tilua/parser").FilesLayout} layout
44
+ * @param {string} projectPath
45
+ * @param {boolean} named whether the config named the project, rather than its default
46
+ * @returns {import("@tilua/parser").FilesAnswer}
47
+ */
48
+ export function rojoLayout(layout, projectPath, named) {
49
+ const file = resolve(layout.directory, projectPath)
50
+ /** @type {import("@tilua/parser").FilesAnswer["problems"] & object[]} */
51
+ const problems = []
52
+ const script = (/** @type {string} */ path) => SCRIPT.test(path)
53
+ const prelude = (/** @type {string} */ path) => (CLIENT.test(path) ? "if not game:IsLoaded() then game.Loaded:Wait() end" : undefined)
54
+
55
+ let project
56
+ try {
57
+ project = JSON.parse(readFileSync(file, "utf8"))
58
+ } catch (error) {
59
+ // Without a project the files stay where they are written, side by side.
60
+ if (named) problems.push({ message: `Cannot read the Rojo project '${projectPath}': ${/** @type {Error} */ (error).message}` })
61
+ return { script, prelude, problems }
62
+ }
63
+ const tree = project && typeof project === "object" ? project.tree : undefined
64
+ if (!tree || typeof tree !== "object") {
65
+ problems.push({ file, message: "The Rojo project has no 'tree'" })
66
+ return { script, prelude, problems }
67
+ }
68
+
69
+ /** @type {Mapping[]} */
70
+ const mappings = []
71
+ const walk = (/** @type {Record<string, unknown>} */ node, /** @type {string[]} */ place) => {
72
+ const path = typeof node.$path === "string" ? node.$path
73
+ : node.$path && typeof node.$path === "object" ? /** @type {{ optional?: unknown }} */ (node.$path).optional : undefined
74
+ if (typeof path === "string") mappings.push({ folder: resolve(dirname(file), path), place })
75
+ for (const [name, child] of Object.entries(node)) {
76
+ if (!name.startsWith("$") && child && typeof child === "object") walk(/** @type {Record<string, unknown>} */ (child), [...place, name])
77
+ }
78
+ }
79
+ walk(tree, [])
80
+ // The deepest mapping a file is under is the one that places it.
81
+ mappings.sort((a, b) => b.folder.length - a.folder.length)
82
+
83
+ /** @param {string} path */
84
+ const place = path => {
85
+ for (const mapping of mappings) {
86
+ const below = relative(mapping.folder, path)
87
+ if (below === "") return mapping.place
88
+ if (below.startsWith("..") || resolve(mapping.folder, below) !== resolve(path)) continue
89
+ const parts = below.split(sep)
90
+ const last = parts.pop() ?? ""
91
+ const name = last.replace(SCRIPT, "").replace(/\.luau?$/, "")
92
+ // `init.luau` is the folder it is in.
93
+ return name === "init" ? [...mapping.place, ...parts] : [...mapping.place, ...parts, name]
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ for (const path of layout.files) {
99
+ if (!place(path)) {
100
+ problems.push({
101
+ file: path, severity: "warning",
102
+ message: `Nothing in the Rojo project '${projectPath}' puts this file in the game`,
103
+ })
104
+ }
105
+ }
106
+
107
+ // The runtime: in the folder the project puts in a service every client
108
+ // reaches, so the server's files and the clients' all require one.
109
+ let runtime
110
+ for (const service of SHARED_SERVICES) {
111
+ const mapping = mappings.find(m => m.place[0] === service && isInside(layout.outDir, m.folder) && !/\.luau?$/.test(m.folder))
112
+ if (mapping) {
113
+ runtime = resolve(mapping.folder, "_tilua.luau")
114
+ break
115
+ }
116
+ }
117
+ if (runtime === undefined) {
118
+ problems.push({
119
+ message: `Nothing in the Rojo project '${projectPath}' puts a folder of outDir in ReplicatedStorage, `
120
+ + "where the runtime every file requires has to be for the server and the clients to reach it",
121
+ })
122
+ }
123
+
124
+ // `@game` names the DataModel; a project that is not one is a single tree.
125
+ const root = tree.$className === "DataModel" ? "@game" : undefined
126
+ return { place, root, runtime, script, prelude, problems }
127
+ }
128
+
129
+ /** Is `path` `folder`, or under it? */
130
+ function isInside(/** @type {string} */ folder, /** @type {string} */ path) {
131
+ const below = relative(folder, path)
132
+ return below === "" || (!below.startsWith("..") && resolve(folder, below) === resolve(path))
133
+ }