diff --git a/.github/workflows/build-studio-docs.yaml b/.github/workflows/build-studio-docs.yaml new file mode 100644 index 0000000..aa464ed --- /dev/null +++ b/.github/workflows/build-studio-docs.yaml @@ -0,0 +1,26 @@ +name: build-studio-docs + +on: + push: + schedule: + - cron: "10 0 1 * *" # Artifacts expire every 90 days. Doesn't hurt to just run this workflow first day of every month at 00:10 UTC. + +jobs: + studio-docs: + name: Build Studio Docs + runs-on: ubuntu-latest + steps: + - name: Checkout Project + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Build Studio Docs + run: zune run scripts/build_studio_docs + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: studio_docs + path: studio_docs.rbxm diff --git a/.gitignore b/.gitignore index 5445cd0..c7f09a7 100755 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,4 @@ genhtml.perl rokit.toml package-lock.json mirror.luau +studio_docs/ diff --git a/README.md b/README.md index 12a8e2b..c85b3eb 100755 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repository includes a few subfolders that can help you get started with jec - examples: These are larger programs that showcase real use cases and can help you understand how everything fits together. +If you wish to view the documentation inside Roblox Studio, you can find an importable model file [here](https://nightly.link/Ukendio/jecs/workflows/build-studio-docs.yaml/main/studio_docs.zip). ### Benchmarks diff --git a/modules/lifetime_tracker.luau b/modules/lifetime_tracker.luau index fa486a4..4bc5c9d 100755 --- a/modules/lifetime_tracker.luau +++ b/modules/lifetime_tracker.luau @@ -4,7 +4,7 @@ local ECS_ID = jecs.ECS_ID local __ = jecs.Wildcard local pair = jecs.pair -local prettify = require("@tools/entity_visualiser").prettify +local prettify = require("@modules/entity_visualiser").prettify local pe = prettify diff --git a/rokit.toml b/rokit.toml index 5f95fc2..9488feb 100755 --- a/rokit.toml +++ b/rokit.toml @@ -2,3 +2,4 @@ wally = "upliftgames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.7.0-rc.1" luau = "luau-lang/luau@0.703.0" +zune = "scythe-technology/zune@0.5.1" \ No newline at end of file diff --git a/scripts/alias_resolver.luau b/scripts/alias_resolver.luau new file mode 100644 index 0000000..d2de37e --- /dev/null +++ b/scripts/alias_resolver.luau @@ -0,0 +1,256 @@ +local fs = zune.fs +local stdpath = fs.path + +export type FsFileNode = { + name: string, + kind: "file", + parent: FsDirNode?, +} + +export type FsDirNode = { + name: string, + kind: "dir", + parent: FsDirNode?, + children: { [string]: FsNode }, +} + +export type FsNode = FsFileNode | FsDirNode + +local function build_fs_tree(path: string, parent: FsDirNode?): FsDirNode + local children = {} + local node = { + name = stdpath.basename(path), + kind = "dir", + parent = parent, + children = children, + } + + for _, entry in fs.entries(path) do + local child_path = stdpath.join(path, entry.name) + local child_node: FsNode = if entry.kind == "directory" + then build_fs_tree(child_path, node) + else { name = entry.name, kind = "file", parent = node } + children[entry.name] = child_node + end + + return node +end + +local function path_to_node(root: FsDirNode, path: string, traverse_modules: boolean?): FsNode? + local names = string.split(path, "/") + assert(#names > 0, `Invalid path: {path}`) + + local node = root + for idx, name in names do + if name == "" then + break + end + + if name == "." then + continue + end + + if name == ".." then + local parent = node.parent + if not parent then + return nil + end + node = node.parent + continue + end + + local child = node.children[name] + + if not child and traverse_modules then + child = node.children[name .. ".luau"] + if not child then + child = node.children["init.luau"] + if not child then + return nil + end + end + end + + if child.kind == "file" and idx < #names then + return nil + end + + node = child + end + + if traverse_modules and node.kind == "dir" then + local possible_module = node.children["init.luau"] + if possible_module then + return possible_module + end + end + + return node +end + +local function get_closest_common_ancestor(from: FsNode, to: FsNode): FsNode? + local from_ancestors: { FsDirNode } = {} + do + local ancestor = from.parent + while ancestor ~= nil do + table.insert(from_ancestors, ancestor) + ancestor = ancestor.parent + end + + if #from_ancestors <= 0 then + return nil + end + end + + local to_ancestor_lookup: { [FsDirNode]: true } = {} + local to_ancestors: { FsDirNode } = {} + do + local ancestor = to.parent + while ancestor ~= nil do + to_ancestor_lookup[ancestor] = true + table.insert(to_ancestors, ancestor) + ancestor = ancestor.parent + end + + if #to_ancestors <= 0 then + return nil + end + end + + local closest_common_ancestor: FsDirNode? = nil + for _, ancestor in from_ancestors do + if to_ancestor_lookup[ancestor] then + closest_common_ancestor = ancestor + break + end + end + + return closest_common_ancestor +end + +local function get_relative_module_path(from: FsFileNode, to: FsFileNode): string? + local common_ancestor = get_closest_common_ancestor(from, to) + if not common_ancestor then + return nil + end + + local path = "" + if not from.parent then + return nil + end + + local traverse_from: FsNode = nil :: any + local is_init = stdpath.stem(from.name) == "init" + if common_ancestor == from.parent then + path = if is_init then "@self" else "." + traverse_from = from.parent :: FsNode + else + if not from.parent.parent then + return nil + end + + if common_ancestor == from.parent.parent then + path = if is_init then "." else ".." + traverse_from = from.parent.parent :: FsNode + else + if not from.parent.parent or not from.parent.parent.parent then + return nil + end + + path = if is_init then ".." else "../.." + traverse_from = from.parent.parent.parent :: FsNode + end + end + + local from_ancestor = traverse_from.parent :: FsNode? + while from_ancestor ~= nil and from_ancestor ~= common_ancestor and from_ancestor ~= common_ancestor.parent do + path ..= "/.." + from_ancestor = from_ancestor.parent + end + + local to_ancestor = to.parent + local to_stem = stdpath.stem(to.name) + if to_stem == "init" then + to_ancestor = assert(to_ancestor).parent + end + + local to_ancestors: { FsNode } = {} + while to_ancestor ~= nil and to_ancestor ~= common_ancestor do + table.insert(to_ancestors, to_ancestor) + to_ancestor = to_ancestor.parent + end + for idx = #to_ancestors, 1, -1 do + local descendant = to_ancestors[idx] + path ..= `/{descendant.name}` + end + + local is_to_init = stdpath.stem(to.name) == "init" + path ..= if is_to_init then `/{assert(to.parent).name}` else `/{to_stem}` + + return path +end + +local function build_resolver( + fs_root: FsDirNode, + aliases: { [string]: string } +): (path: string, from: string) -> string? + local alias_nodes = {} + for alias, path in aliases do + local node = path_to_node(fs_root, path) + if not node then + error(`Alias {alias} with path {path} cannot be resolved into a node`) + end + alias_nodes[alias] = node + end + + return function(require_path: string, from: string): string? + local alias = string.match(require_path, "^@([^/]+)") + if not alias or alias == "self" or alias == "game" then + return nil + end + + if not aliases[alias] then + error(`Unresolvable alias: {alias}`) + end + local alias_node = alias_nodes[alias] + + local origin_node = path_to_node(fs_root, from, true) + if not origin_node or origin_node.kind ~= "file" then + error(`The path {from} cannot be resolved into a node`) + end + + local target_node: FsNode = alias_node + local first_slash_idx = string.find(require_path, "/") + if first_slash_idx then + local len = #require_path + local target_path = string.sub(require_path, first_slash_idx + 1, len) + if #target_path + 1 ~= len then + assert(alias_node.kind == "dir") + target_node = assert( + path_to_node(alias_node, target_path, true), + `The path {require_path} cannot be resolved into a node` + ) + elseif alias_node.kind == "dir" then + target_node = assert( + path_to_node(alias_node, "init.luau", true), + `The path {require_path} cannot be resolved into a node` + ) + end + elseif alias_node.kind == "dir" then + target_node = assert( + path_to_node(alias_node, "init.luau", true), + `The path {require_path} cannot be resolved into a node` + ) + end + assert(target_node.kind == "file") + + local path = get_relative_module_path(origin_node, target_node) + assert(path, `Failed to navigate from {from} to {require_path}`) + return path + end +end + +return { + build_fs_tree = build_fs_tree, + build_resolver = build_resolver, +} diff --git a/scripts/build_studio_docs.luau b/scripts/build_studio_docs.luau new file mode 100644 index 0000000..512d119 --- /dev/null +++ b/scripts/build_studio_docs.luau @@ -0,0 +1,90 @@ +--!strict +-- Copies all docs related files into a dist folder with all aliases resolved to relative require paths +local fs = zune.fs +local stdpath = fs.path +local process = zune.process +local alias_resolver = require("./alias_resolver") + +-- stylua: ignore +local DISABLED_SCRIPT = [[ +{ + "properties": { + "Disabled": true + } +} +]] + +local text_files = { "README.md", "LICENSE" } +local allowed_extensions = { ".luau", ".json" } +local directories = { "examples", "how_to", "modules" } +local output = "studio_docs" + +local function traverse( + dir: string, + fn: (name: string, path: string, local_path: string, metadata: Metadata) -> (), + start_name: string? +) + local local_name = start_name or stdpath.basename(dir) + + for _, entry in fs.entries(dir) do + local path = stdpath.join(dir, entry.name) + local child = fs.metadata(path) + + local local_path = stdpath.join(local_name, entry.name) + fn(entry.name, path, local_path, child) + if child.kind == "directory" then + traverse(path, fn, local_path) + end + end +end + +if fs.stat(output).kind ~= "none" then + fs.deleteDir(output, true) +end +fs.makeDir(output) +fs.copy("src/jecs.luau", `{output}/jecs.luau`) + +for _, file in text_files do + local source = fs.readFile(file) + local luau_source = `--[\[\n{source}\n]\]` + local stem = fs.path.stem(file) + fs.writeFile(`{output}/{stem}.server.luau`, luau_source) + fs.writeFile(`{output}/{stem}.meta.json`, DISABLED_SCRIPT) +end + +for _, dir in directories do + fs.copy(dir, `{output}/{dir}`) +end + +local vfs_root = alias_resolver.build_fs_tree(output) +local resolve_alias = alias_resolver.build_resolver(vfs_root, { jecs = "jecs.luau", modules = "modules" }) +traverse(output, function(name, path, local_path, metadata) + local extension = stdpath.extension(path) + if name == "jecs.luau" or metadata.kind ~= "file" or not table.find(allowed_extensions, extension) then + return + end + local_path = string.gsub(local_path, "\\", "/") -- vindovs + + local contents = fs.readFile(path) + local lines = string.split(contents, "\n") + for number, line in lines do + local require_path = string.match(line, 'require%("(@[^"]+)"%)') + if not require_path then + continue + end + + local resolved_path = resolve_alias(require_path, local_path) + if not resolved_path then + continue + end + lines[number] = string.gsub(line, require_path, resolved_path) + end + fs.writeFile(path, table.concat(lines, "\n")) +end, "") + +process.run( + "rojo", + { "build", "studio_docs.project.json", "-o", "studio_docs.rbxm" }, + -- Luau + { stdout = "inherit", stderr = "inherit" } :: any +) diff --git a/studio_docs.project.json b/studio_docs.project.json new file mode 100644 index 0000000..d5c68d4 --- /dev/null +++ b/studio_docs.project.json @@ -0,0 +1,6 @@ +{ + "name": "jecs_studio_docs", + "tree": { + "$path": "studio_docs" + } +}