Script system

Anything in scripts/ runs while your project is being compiled. Scripts can rewrite source on its way to Roblox, claim a file type and turn it into instances, read other files on disk, and react to files being added or removed.

What they are

A hook script is a normal Luau file. It runs on the server, not in Roblox, and its job is to register callbacks. Those callbacks are then called during every compile.

-- scripts/banner.luau
roflux.onRead(function(file)
	return "-- built by RoFlux\n" .. file.source
end)

Every .luau file under scripts/ is loaded, in name order, including files in subfolders. .d.luau files are skipped.

Editing anything in scripts/ reloads all of them and rebuilds the project, so you can work on a hook with the server running.

Loading rules

There are two rules, and they exist so a bad hook cannot stall your build.

Scripts must not yield while loading

The top level of a hook script runs once and must finish. If it yields, RoFlux stops with an error. Register your callbacks and return.

Callbacks run inside coroutines

Every callback is run in its own coroutine, so a hook that does yield will not block the compile. If one does yield, RoFlux warns, skips its result for that pass, and keeps the coroutine alive to resume on the next rebuild.

Sharing code between scripts

Use require("./helpers") to load another file, relative to the script calling it. Each file runs only once, no matter how many scripts require it. See Modules and require.

Hook scripts run in the server's Luau, not Roblox's

They do not have game, workspace, task, or any Roblox API. They also run on an older Luau than Studio does, so const is not available in a hook script. Use local. Your actual game code is untouched by this and can use anything Roblox supports.

Source hooks

Two hooks see script source. Both get the same file table and both can replace the source by returning a string.

HookWhen it runs
roflux.onReadRight after a source file is read from disk.
roflux.onTransferLast step before the source is handed to Roblox.

They run in that order, and they chain. If two hooks both return a string, the second one sees the output of the first.

roflux.onTransfer(function(file)
	if file.className ~= "ModuleScript" then
		return nil
	end

	return minify(file.source)
end)

Return nil to leave the source alone. This is where things like a build stamp, a licence header, stripping debug calls, or obfuscation belong.

Transpilers

A transpiler claims a file extension. RoFlux normally ignores file types it does not know, but once a hook claims one, every file with that extension is handed to it and whatever it returns becomes instances.

roflux.transpile("md", function(file)
	return {
		["$ClassName"] = "StringValue",
		["$Properties"] = { Value = file.text },
		["$Tags"] = { "Docs" },
	}
end)

Returning a plain string is shorthand for a ModuleScript with that source. Returning nil falls back to the built in behaviour for that extension. Setting $Ignore drops the file entirely.

A transpiler can claim a built in type too. Claiming json replaces the default handling of every .json file in the project.

Your editor cannot read the file you started from, so it does not know what the instance became. Call roflux.meta(file, ...) to give it a small Luau stub to read instead, and requiring the result is typed like anything else. See Types for files you transpile.

Making many instances

Declarations nest, so one file can build a whole tree. Return an array to produce several separate instances from a single file.

roflux.transpile("html", function(file)
	local screen = {
		["$ClassName"] = "Frame",
		["$Attributes"] = { Source = file.path },
	}

	local order = 0

	for tag, body in file.text:gmatch("<(%a[%w]*)[^>]*>%s*([^<]*)%s*</") do
		order += 1
		screen[tag .. order] = {
			["$ClassName"] = "TextLabel",
			["$Properties"] = { Text = body, LayoutOrder = order },
		}
	end

	return {
		screen,
		{
			["$Name"] = "Markup",
			["$ClassName"] = "StringValue",
			["$Properties"] = { Value = file.text },
		},
	}
end)

Everything a transpiler makes is a normal tree node from then on. Each instance records the file it came from, so the sourcemap points back at it, and the diff engine treats it like anything else:

-- adding one line to the html
sync +1 ~1 -0
     + ReplicatedStorage/UI/page/p2 [TextLabel]
     ~ ReplicatedStorage/UI/Markup (Value)

-- deleting the html
sync +0 ~0 -2
     - ReplicatedStorage/UI/page
     - ReplicatedStorage/UI/Markup

Only what actually changed is patched, and deleting the file removes everything it produced, children included.

A declaration can also carry $Parent, so a single file can put its output anywhere in the DataModel rather than where the file sits.

Reading files

Hooks can read anything inside the project folder. This is what makes generators practical, since a transpiler usually needs more than the one file it was handed. A design token file, a shared palette, a folder of icons, a schema.

local palette = roflux.json("design/palette.json")

roflux.transpile("ui", function(file)
	local lines = { "return {" }

	for _, name in roflux.list("design/icons") do
		table.insert(lines, `\ticons_{name} = true,`)
	end

	table.insert(lines, `\taccent = "{palette.accent}",`)
	table.insert(lines, "}")

	return table.concat(lines, "\n")
end)

All paths are relative to the project root. Absolute paths and .. are rejected, so a hook cannot reach outside the project.

FunctionReturns
roflux.read(path)File contents as a string, or nil if it is missing.
roflux.json(path)Decoded JSON as a table, or nil if missing. Errors if the file is not valid JSON.
roflux.list(path)Names of the entries directly inside a folder, sorted.
roflux.walk(path)Every file below a folder, as project relative paths.
roflux.exists(path)true if the path exists.
roflux.isDirectory(path)true if the path is a folder.
roflux.root()Absolute path of the project folder.
Reads are not tracked

A file you read with these functions does not become a rebuild trigger on its own. If a generator depends on design/palette.json, editing that file only rebuilds if it is somewhere the watcher already looks, which is anywhere in the project folder. Files outside the project cannot be read at all.

File events

Three hooks fire when the watcher sees something happen. They are for reacting, not for changing the tree, and their return value is ignored.

roflux.onAdded(function(file)
	roflux.log("new file", file.path)
end)
HookFires when
roflux.onAddedA file appears.
roflux.onRemovedA file is deleted.
roflux.onChangedA file is written to.

These run before the rebuild, so anything they write to disk is picked up by the compile that follows.

Full API

Scripts can do a lot more than what is shown here. They can write files, read JSON and TOML, generate Luau, build instances with roflux.new, change the finished tree with roflux.onTree, keep a cache between builds, read settings from the project file, and more.

All of it is listed on the Script API page. The generated types.d.luau declares every function too, so hook scripts get autocomplete and type checking in your editor.

Examples

Strip debug calls from a release build

local release = roflux.exists("RELEASE")

roflux.onTransfer(function(file)
	if not release then
		return nil
	end

	return (file.source:gsub("print%b()", ""))
end)

Turn a folder of images into a lookup table

roflux.transpile("assets", function()
	local lines = { "return {" }

	for _, path in roflux.walk("art") do
		local id = roflux.read(path)

		if id then
			local key = path:match("([^/]+)%.%w+$")
			table.insert(lines, `\t{key} = "{id:gsub("%s+$", "")}",`)
		end
	end

	table.insert(lines, "}")
	return table.concat(lines, "\n")
end)

Generate a UI tree from a data file

roflux.transpile("screen", function(file)
	local spec = roflux.json(file.path)
	local screen = { ["$ClassName"] = "ScreenGui" }

	for name, button in spec.buttons do
		screen[name] = {
			["$ClassName"] = "TextButton",
			["$Properties"] = {
				Text = button.label,
				Position = button.position,
				BackgroundColor3 = button.color,
			},
			["$Tags"] = { "Generated" },
		}
	end

	return screen
end)

Because property values are plain JSON, button.position can be [0.5, 0, 0.5, 0] and it becomes a UDim2, and button.color can be [60, 145, 245] and it becomes a Color3.