Automatically build a rbxm of documentation for Studio users (#287)

* Add types for (Cached)Query.has (#286)

* ts type update

* changed to hard tabs

* reverted back to old formatting for some

* more fixes

* only keep query has

* Build studio docs

* Fix the nightly.link

* Make the nightly.link work for the main Jecs repo, rename the workflow

* Add alias resolution

* Artifacts expire, run the action every month

* Add deleted flag to removed handler typings (#288)

* update types

* change "StatefulHook" to "HookWithData" and "StatelessHook" to "HookWithDeleted"

* Update deleted flag type

---------

Co-authored-by: maeriil <104389763+maeriil@users.noreply.github.com>
Co-authored-by: m10 <165406716+mrkboy10@users.noreply.github.com>
Co-authored-by: Marcus <ukendio@gmail.com>
This commit is contained in:
Magic 2026-01-02 06:41:05 +01:00 committed by GitHub
parent 64f8750f3c
commit e494c35b78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 382 additions and 1 deletions

View file

@ -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

1
.gitignore vendored
View file

@ -75,3 +75,4 @@ genhtml.perl
rokit.toml
package-lock.json
mirror.luau
studio_docs/

View file

@ -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

View file

@ -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

View file

@ -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"

256
scripts/alias_resolver.luau Normal file
View file

@ -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,
}

View file

@ -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
)

6
studio_docs.project.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "jecs_studio_docs",
"tree": {
"$path": "studio_docs"
}
}