XML to UI Converter module

Hi, idk if this fits here because it is my first time posting a resource,
But I think it fits everything that was mentioned in the About community resources post.

Anyway, this is a module that can parse xml (if written correctly, so if you type syntax errors then it won’t load lol) and instantiate the given types declared in your source.

It exposes the bloxmlTokenizer, bloxmlParser, bloxmlLoader & bloxmlStarter modules that help in loading the xml.
the bloxmlTokenizer creates tokens from source, examples of tokens are: <, >, / , = , “something in quotes” and identifiers.

then the bloxmlParser takes the tokens and returns an array of xml_containers that represent the relationships between each element.
this is then used to create the UI using the bloxmlLoader,

the bloxmlStarter exposes one function that takes a string and returns an array of UI elements, so instead of calling each class’s fn manually you call starter’s load function and under the hood it calls all class’s corresponding fn, disposes of resources and returns the result.

there are also helper modules, one for type conversion, so if you have a property of an unsupported type you can add a convert method in the module, the name needs to be ‘ToType’ where Type is your typename.

There is also string helper methods for the bloxmlTokenizer to use during lexical analysis, methods like isdigit() (unused, was used before but deleted because I made all values a string), isalphanum(), isalpha() (used in alphanum), and isspace().

you might want to run this loading operation on a separate thread because it creates a token instance for each valid character sequence (which in worst case scenario could be for every character), which means a lot of instances.

you might notice that the load fn from bloxmlStarter also accepts a dictionary called ‘listeners’, well this is were you define event handlers and then reference them in the xml by name. The corresponding fn will be called when your event is fired.

so, different types have different formatting, here are the formatting of the currently supported types:

  • Vector3 - (used before, not anymore) “x, y, z” no parens no special character, each component separated by a comma. like in the example.

  • UDim2 - same as vector3 but with four number values instead: “xscale, xoffset, yscale, yoffset”

  • UDim - same as UDim2 but with two numbers instead: “scale, offset”

  • color3 - same as vector3 but now the values represent rgb instead of xyz: “r, g b” each component is a number between 0 and 255.

  • boolean - either True/true or False/false.

  • Enum values - the name of the Enum item, the enum type will be aquired from the context, so say if I want to modify ApplyStrokeMode from UIStroke I’d do: <UIStroke ApplyStrokeMode = "Border"/>
    as you can see, only border is allowed, no dots no enum type name. (although that would make parsing easier ig)

  • number - any number 0 - 9 and a dot are allowed.

  • event handlers - to connect a handler to an event you type the handler’s name, that corresponds to the name assigned to it in the given listeners dictionary.

so to create a UI element or modifier, all you need to do is follow this format:

 <!--do this if your element does not have children-->
<typename Property = "value" ... />
<!--and this if it has children-->
<typename Property= "value">
  <childtype Property = "value" />
</typename>

children are allowed to have children themselves.
so if you write /> then the element will have no children, that is a shorthand for writing an open tag and then immediately afterwards a closing tag.

if you have elements in between an element’s open & closing tag they will be parented to that element.
commenting is supported, so you can type <!-- --> and anything in between will be ignored

one more warning, since in roblox, you can’t really check the type of a property itself, only values, when we do Instance.new() on the given type and a property you edited in xml 's default value is nil, it will error so it is recommended to edit those manually after the element has been loaded, until roblox adds reflection support like .GetType() and .Properties like in .NET we’re stuck with this problem.

If roblox adds that support though I’ll update the converter to use the new api instead, which would make my life easier.

here is an example of a toggleswitch UI component created using Luau & XML (I call it bloxml, xml for roblox)

local bloxmlStarter = require(game.ReplicatedStorage:WaitForChild("BloxMl"):WaitForChild("BloXMLStarter"))
local ts = game:GetService("TweenService")

local info = TweenInfo.new(0.5, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
local linfo = TweenInfo.new(0.5, Enum.EasingStyle.Linear)

local toggled = false
local backend = {
	["Toggle_Activated"] = function(sender: TextButton, obj : InputObject)
		local goal = toggled and {Position = UDim2.fromScale(0, 0)} or {Position = UDim2.fromScale(0.583, 0)}
		local bgg = toggled and {BackgroundColor3 = Color3.new(1, 1, 1)} or {BackgroundColor3 = Color3.fromRGB(0, 170, 255)}
		
		local st = ts:Create(sender, info, goal)
		local bgt = ts:Create(sender.Parent, linfo, bgg)
		
		st:Play()
		bgt:Play()
		
		st.Completed:Wait()
		toggled = not toggled
	end,
}

local source = [[
<!--this is a comment, and should be skipped!-->
<Frame Name = "ToggleSwitch" Size = "0, 96, 0, 73" Position = "0.5, 0, 0.5, 0" BackgroundColor3 = "255, 255, 255">
  <UIAspectRatioConstraint AspectRatio = "2.3"/>
  <UICorner CornerRadius = "0.5, 0"/>
  <UIStroke ApplyStrokeMode = "Border" Color = "173, 173, 173"/>
  
  <TextButton Name = "Toggle" Activated = "Toggle_Activated"  BackgroundColor3 = "255, 255, 255" Size = "0.5, 0, 0.95, 0" Text = " ">
  	 <UIAspectRatioConstraint />
  	 <UICorner CornerRadius = "1, 0"/>
  	 <UIStroke ApplyStrokeMode = "Border" Color = "230, 230, 230"/>
  </TextButton>
</Frame>
]]

local objects = bloxmlStarter.load(source, backend)
objects[1].Parent = script.Parent

If there are any bugs / feature requests please tell me, so I can update the XML parser/UI loader depending on if it is a syntatical feature request or a UI different one.

Idk why you’d use this, but It makes creating UI on roblox easier, I got inspired by Microsoft’s XAML where the frontend is in xaml and the backend is in C# unlike winforms/ roblox where everything is one language and there is no separation of concerns.

Hopefully you people find this useful!
Thanks for reading!

modules:

BloxmlTokenizer:

local stringExtensions = require (script.Parent:WaitForChild("StringExtensions"))

local bloxTokenizer = {}
bloxTokenizer.__index = bloxTokenizer

--[[
TYPES:
 ATTRIBUTE_VALUE: 0
 IDENTIFIER: 1
 
 OPEN_TAG: 3
 CLOSE_TAG: 4
 FORWARD_SLASH: 5
 EQUAL: 6
]]

export type BloxmlToken = {
	CharacterSequence: string,
	TokenType: number,
}

export type BloxmlTokenizer =  typeof(setmetatable({} :: {
	sourceCode: {string},
	source: string,
	position: number,
	result: {BloxmlToken}
}, bloxTokenizer))

local function make_tkn(char: string, t: number): BloxmlToken
	return {
		CharacterSequence = char,
		TokenType = t,
	}
end

local function tokenize_attr(tokenizer_state: BloxmlTokenizer)
	tokenizer_state.position += 1--skip the "
	local start = tokenizer_state.position
	
	while tokenizer_state.position < #tokenizer_state.sourceCode and tokenizer_state.sourceCode[tokenizer_state.position] ~= '"' do 
		tokenizer_state.position += 1
	end
	
	local attr = string.sub(tokenizer_state.source, start, tokenizer_state.position - 1)--exclude the end "
	tokenizer_state.position += 1--discard end "
	
	table.insert(tokenizer_state.result, make_tkn(attr, 0))--insert new token
end

local function tokenize_identifier(tokenize_state: BloxmlTokenizer)
	local start = tokenize_state.position
	while tokenize_state.position < #tokenize_state.sourceCode and (stringExtensions.isalphanum(tokenize_state.sourceCode[tokenize_state.position]) or tokenize_state.sourceCode[tokenize_state.position] == '_') do
		tokenize_state.position += 1
	end
	
	local identifier = string.sub(tokenize_state.source, start, tokenize_state.position - 1)--skip whatever character ended the loop.
	table.insert(tokenize_state.result, make_tkn(identifier, 1))
end

local function discard_comment(tokenize_state: BloxmlTokenizer)
	local start = tokenize_state.position
	while tokenize_state.position < #tokenize_state.sourceCode and tokenize_state.sourceCode[tokenize_state.position] ~= '>' do
		tokenize_state.position += 1
	end
	
	--commenting is <! > now technically, but you can stil do <!-- -->, I am not going to enforce it.
	tokenize_state.position += 1 --skip the > sign.
end

function bloxTokenizer.new(source: string): BloxmlTokenizer
	assert(source and source ~= "", "Failed to create tokenizer, source was nil or empty.")
	
	return setmetatable({
		sourceCode = string.split(source, ""),
		source = source,
		result = {},
		position = 0,
	}, bloxTokenizer)
end

function bloxTokenizer:Tokenize()
	--reset the values
	self.result = {}
	self.position = 1
	
	while self.position < #self.sourceCode do
		--discard spaces
		--print(self.position, self.sourceCode[self.position])
		if stringExtensions.isspace(self.sourceCode[self.position]) then 
			self.position += 1 
			continue 
		end
		--multi character tokens (have own tokenize method)
		if self.sourceCode[self.position] == '"' then
			tokenize_attr(self)
		elseif stringExtensions.isalphanum(self.sourceCode[self.position]) or self.sourceCode[self.position] == '_' then
			tokenize_identifier(self)
			--single character tokens (all have same logic)
		elseif self.sourceCode[self.position] == '<' then
			if self.sourceCode[self.position + 1] == '!' then
				discard_comment(self)
				continue
			end
			
			table.insert(self.result, make_tkn(nil, 3))
			self.position += 1
		elseif self.sourceCode[self.position] == '>' then
			table.insert(self.result, make_tkn(nil, 4))
			self.position += 1
		elseif self.sourceCode[self.position] == '/' then
			table.insert(self.result, make_tkn(nil, 5))
			self.position += 1
		elseif self.sourceCode[self.position] == '=' then
			table.insert(self.result, make_tkn(nil, 6))
			self.position += 1
		else
			warn("Unknown token detected, token is discarded and tokenization continues: ", self.sourceCode[self.position])
			self.position += 1
		end
	end
	
	print("Tokenization Completed! Tokens available in BloxmlTokenizer.result!")
end

function bloxTokenizer:Dispose()
	table.clear(self.result)
	table.clear(self.sourceCode)
	table.clear(self)
end

return table.freeze(bloxTokenizer)

bloxmlParser:

local BloxmlTokenizer = require (script.Parent:WaitForChild("BloXMLTokenizer"))

local bloxmlparser = {}
bloxmlparser.__index = bloxmlparser

export type xml_Container = {
	typename: string,
	properties: {[string]: string},
	children: {xml_Container},
}

export type BloxmlParser = typeof(setmetatable({} :: {
	sourceTokens: {BloxmlToken},
	result: {xml_Container},
	position: number,
}, bloxmlparser))

local function parse_tag(bloxmlparser: BloxmlParser): xml_Container
	assert(bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 3, "A tag must start with a '<'!")
	bloxmlparser.position += 1 --skip the '<'
	
	assert(bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 1, "Expected identifier for type name!")
	local typename = bloxmlparser.sourceTokens[bloxmlparser.position].CharacterSequence
	bloxmlparser.position += 1 --type name already handled, skip to next token
	
	local kvps = {}
	while bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 1 do
		local identifier = bloxmlparser.sourceTokens[bloxmlparser.position].CharacterSequence
		bloxmlparser.position += 1--skip identifier, already handled
		
		assert(bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 6, "Expected '=' after attribute name!")
		bloxmlparser.position += 1--skip equal sign
		
		assert(bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 0, "Expected attribute value after '='!")
		local attr_val = bloxmlparser.sourceTokens[bloxmlparser.position].CharacterSequence
		bloxmlparser.position += 1--go to next identifier
		
		kvps[identifier] = attr_val--save value as kvp
	end
	
	--broken out of loop, current sign is either '/' or '>'.
	if bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 5 then
		bloxmlparser.position += 2 --skip / and >
		return {typename = typename, properties = kvps, children = {}} --forwardslash -> no children, cuz empty tag.
	end
	
	--container tag, there are children, skip closing brace and recursively parse children.
	bloxmlparser.position += 1
	local children = {}
	while bloxmlparser.sourceTokens[bloxmlparser.position].TokenType == 3 and bloxmlparser.sourceTokens[bloxmlparser.position + 1].TokenType ~= 5 do
		table.insert(children, parse_tag(bloxmlparser))
	end
	
	bloxmlparser.position += 4 --one for the <, one for the /, one for the typename, & one for the >.
	return {typename = typename, properties = kvps, children = children}
end

function bloxmlparser.new(tokens: {BloxmlToken}): BloxmlParser
	return setmetatable({
		result = {},
		sourceTokens = tokens,
		position = 1,
	}, bloxmlparser)
end

function bloxmlparser:Parse()
	self.position = 1
	self.result = {}
	
	while self.position < #self.sourceTokens do
		table.insert(self.result, parse_tag(self))
	end
	
	print("Parsing complete! result is available in BloXMLParser.result!")
end

function bloxmlparser:Dispose()
	table.clear(self.result)
	table.clear(self.sourceTokens)
	table.clear(self)
end

return table.freeze(bloxmlparser)

bloxmlloader:

local bloxmlParser = require (script.Parent:WaitForChild("BloXMLParser"))
local StringConverter = require (script.Parent:WaitForChild("StringConverter"))

local bloxmlloader = {}
bloxmlloader.__index = bloxmlloader

export type BloxmlLoader = typeof(setmetatable({} :: {
	containers: {xml_Container},
	backend: {[string]: (...any) -> ()}?
}, bloxmlloader))

local function load_container(container: xml_Container, listeners: {[string]: (...any) -> ()}): GuiObject
	local success, err:Instance = pcall(Instance.new, container.typename)
	assert(success, "Failed to load bloXML, type not found: "..tostring(err))
	assert(err:IsA("GuiObject") or err:IsA("UIBase"), "Failed to load bloXML, cannot create instances which are not Gui Elements or Modifiers.")
	
	for key, value in pairs(container.properties) do
		local typen = typeof(err[key])
		if typen == "RBXScriptSignal" then
			assert(listeners[value], "Event listener not found!")
			local targetfn = listeners[value]
			err[key]:Connect(function(...) targetfn(err, ...) end)
			
			continue
		elseif typen == "string" then
			err[key] = value
			continue
		elseif typen == "EnumItem" then
			err[key] = StringConverter.ToEnum(tostring(err[key]), value)
			continue
		end
		
		local converterFn = StringConverter["To"..typen]
		assert(converterFn, "Converter not found for type: "..typen)
		
		local iresult = converterFn(value)
		assert(iresult, "Failed to convert string rep, result was nil")
		
		err[key] = iresult
	end
	
	for _, child in ipairs(container.children) do
		local ui = load_container(child, listeners)
		ui.Parent = err
	end
	
	return err
end

function bloxmlloader.new(parsed_containers: {xml_Container}, listeners: {[string]: (...any) -> ()}?): BloxmlLoader
	return setmetatable({
		containers = parsed_containers,
		backend = listeners or {},
	}, bloxmlloader)
end

function bloxmlloader:GetResult(): {GuiObject}
	local result = {}
	
	for _, container in ipairs(self.containers) do
		table.insert(result, load_container(container, self.backend))
	end
	
	print("Component loaded successfully!")
	return result
end

function bloxmlloader:Dispose()
	table.clear(self.containers)
	table.clear(self.backend)
	table.clear(self)
end

return table.freeze(bloxmlloader)

stringextensions:

local module = {}

function module.isdigit(char: string): boolean
	local byte = char:byte()
	return byte >= 48 and byte <= 57
end

function module.isspace(char: string): boolean
	local byte = char:byte()
	return byte == 32 or (byte >= 9 and byte <= 13)
end

function module.isalpha(char: string):boolean
	local byte = char:byte()
	return (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122)
end

function module.isalphanum(char: string)
	return module.isalpha(char) or module.isdigit(char)
end

return table.freeze(module)

stringconverter:

local converter = {}

function converter.ToVector3(t: string): Vector3?
	local nums = string.split(t, ",")
	if #nums ~= 3 then return nil end
	
	local x, y , z = tonumber(nums[1]), tonumber(nums[2]), tonumber(nums[3])
	return Vector3.new(x, y, z)
end

function converter.ToEnum(typename: string, value: string): Enum?
	local enumName = string.split(typename, ".")[2] -- eg: Enum.PartType.Block where [2] = PartType.
	if not Enum[enumName] then return nil end
	
	return Enum[enumName][value]
end

function converter.ToColor3(t: string): Color3?
	local nums = string.split(t, ",")
	if #nums ~= 3 then return nil end

	local r, g , b = tonumber(nums[1]), tonumber(nums[2]), tonumber(nums[3])	
	return Color3.fromRGB(r, g, b)
end

function converter.Toboolean(t: string): boolean
	return string.lower(t) == "true"
end

function converter.ToUDim2(t: string): UDim2?
	local nums = string.split(t, ",")
	if #nums ~= 4 then return nil end
	
	local xs, xo, ys, yo = tonumber(nums[1]), tonumber(nums[2]), tonumber(nums[3]), tonumber(nums[4])
	return UDim2.new(xs, xo, ys, yo)
end

function converter.ToUDim(t: string): UDim?
	local nums = string.split(t, ",")
	if #nums ~= 2 then return nil end
	
	local s, o = tonumber(nums[1]), tonumber(nums[2])
	return UDim.new(s, o)
end

--add to number for number functionality, required by loading algo
converter.Tonumber = tonumber
return table.freeze(converter)

bloxml starter:

local bloxmlTokenizer = require (script.Parent:WaitForChild("BloXMLTokenizer"))
local bloxmlParser = require (script.Parent:WaitForChild("BloXMLParser"))
local bloxmlLoader = require (script.Parent:WaitForChild("BloXMLLoader"))

local bloxmlstarter = {}

function bloxmlstarter.load(source: string, backend: {[string]: (...any) -> ()}?): {GuiObject}
	local tkn = bloxmlTokenizer.new(source)
	tkn:Tokenize()
	
	local prsr = bloxmlParser.new(tkn.result)
	prsr:Parse()
	tkn:Dispose()
	
	local objectLoader = bloxmlLoader.new(prsr.result, backend)
	local result = objectLoader:GetResult()
	
	prsr:Dispose()
	objectLoader:Dispose()
	
	print("Element with given BloXML description loaded successfully!")
	return result
end

return bloxmlstarter

Lastly, here is a link to a github repository of mine were I’ll post new versions and we can collaborate there!
Brushymilbil/XML-to-Roblox-UI-converter (github.com)

Thanks for reading!

4 Likes

Yeah. I don’t know either. We aren’t expected to write the XML ourselves, right? Is there a software that does this? If not, there’s really no point to this.

2 Likes

It probably can be used as an UI library like Fusion or React/Roact. The syntax is similar that of TypeScript JSX

I would argue that the JSX style has great benefits over static XML, ie. you can use reactive state, bind events at component-level and passing down props to descendant components. This allows you to reuse Buttons for example.

It’s a cool project, but I can’t think of actual use cases where this would be more helpful compared to Fusion / react-lua.

2 Likes