Introduction

Funa is a JavaScript data binding library that allows you to create web apps with ease.

By extending HTML declarative syntax with interpolations and binding expressions, it seamlessly connects UI to underlying logical code, eliminating the need for complex DOM manipulation.

The library was made for those who prefer the traditional approach, keeping HTML and JS separated:
HTML just describes the layout and content, while JS just handles the data and logic.

Installation

Funa is a zero-dependency library.

Get the latest release here, then import it as a module:

import { Funa } from './funa.js';

For TypeScript support, store the funa.d.ts file alongside the funa.js file.

Quick Start

Here is a basic example to give you a glance at:

<!DOCTYPE html>
<html>
<head>
	<script src="demo.js" type="module"></script>
</head>
<body>
<template>
	<p>Hello, {name}!</p>
</template>
</body>
</html>
import { Funa } from './funa.js';

const app = new Funa({
	data: {
		name: 'Funa'
	}
});

app.render();

Text Interpolation

<any>{data}</any>
<any attr={data}></any>

<!-- reactive -->
<any>{$data}</any>
<any attr={$data}></any>

Interpolation enables representing dynamic data placed in curly braces within text nodes or attribute values.

If there is a $ prefix in the expression, it will automatically update when the data changes (reactive interpolation).
Otherwise, it is evaluated once when rendered.

The data path can be a property or sub-property (using dot notation).

null or undefined will result in an empty string.

Example

<template>
	<p>Count down for {k} seconds: {$k}</p>
</template>
const app = new Funa({
	data: {
		k: 10
	}
});

app.render();

let timer = setInterval(() => {
	if (!(--app.data.k)) {
		clearInterval(timer);
	}
}, 1000);

Property Binding

<any .property={data}></any>

<!-- reactive -->
<any .property={$data}></any>

Property binding is somewhat like text interpolation, but sets values to element properties instead of printing them out.

The syntax is exactly the same as setting an attribute, except for prepending a ..

When there is only one expression, the data value is passed directly without string conversion, allowing the binding of non-string data types (such as booleans, numbers).

If multiple expressions or strings are involved, string concatenation is performed to form a composite value.

Example

<template>
	<button .disabled={busy}>Send</button>
</template>
const app = new Funa({
	data: {
		busy: true
	}
});

app.render();

Data Context

<any :data></any>
<any :=data></any>

<!-- reactive -->
<any $data></any>
<any $=data></any>

Data context serves as the default source when resolving property access during interpolation.

It is inherited from the parent, unless explicitly overridden by setting a source expression.

- Use : directive to create a one-time binding, the element is rendered once, subsequent data changes won't trigger re-rendering.

- Use $ directive to create a reactive binding, the element is re-rendered whenever the referenced data changes.

The data path can be a property or sub-property, but it must resolve to an object.

Note that HTML attribute names are normalized to lowercase on parsing, to represent uppercase letters:

- Add a - before the character wanted uppercase.

- Place the data path within the attribute value.

<any :data-path></any>
<any :=dataPath></any>

When the data context is set to an array, Funa will loop through and render each element, creating a repeating UI pattern.

If the array contains non-object elements, use ? within interpolation to display its value.

<ul :list>
	<li>{?}</li>
</ul>

Example

<template>
	<p>Planets of the solar system:</p>
	<ul :planets>
		<li>{name}</li>
	</ul>
</template>
const app = new Funa({
	data: {
		planets: [
			{ name: 'Mercury' },
			{ name: 'Venus' },
			{ name: 'Earth' },
			{ name: 'Mars' },
			{ name: 'Jupiter' },
			{ name: 'Saturn' },
			{ name: 'Uranus' },
			{ name: 'Neptune' },
		]
	}
});

app.render();

Data Formatting

<any>{data:format}</any>
<any>{data:format(...args)}</any>
{
	as: {
		format: {
			/**
			 * Formats data for representation.
			 * 
			 * @param value - the raw data
			 * @param args - callback arguments in the expression
			 */
			convert(value, ...args) {
				...
			},
			
			/**
			 * Parses formatted data into raw.
			 * 
			 * @param value - the formatted data
			 * @param args - callback arguments in the expression
			 */
			revert(value, ...args) {
				...
			},
		}
	}
}

During the interpolation process, the data is first casted to a string.

To control how data is displayed, append an as expression after the data path to apply custom formatting.

This can also apply for the case when the datatype of the data field and element property are not matched, so a custom formatting acts like the bridge.

The formatting descriptor is defined in the as registry, and must implement one or both of the convert and revert callback functions.

Parentheses are optional when calling with no arguments.

Example

<template>
	<p :planet>{name} is the {pos:nth} planet from the Sun.</p>
</template>
const app = new Funa({
	data: {
		planet: {
			name: 'Earth',
			pos: 3,
		}
	},
	
	as: {
		nth: {
			convert: (value) => (value === 1) ? '1st' : (value === 2) ? '2nd' : (value === 3) ? '3rd' : value + 'th'
		}
	},
});

app.render();

Data Model

<any %=model></any>
{
	is: {
		model: {
			property: 'format',
			...
		}
	}
}

Specifying an is expression allows implicitly applying custom formatting based on pre-defined metadata, eliminating the need for repetitive as expression and ensuring a consistent approach to data representation throughout the templates.

Model metadata is defined in the is registry, with each property mapping links a property name to its associated formatting descriptor.

Explicitly specified as expression always take precedence over model-based formatting.

Example

<template>
	<p :planet %=Planet>{name} is the {pos} planet from the Sun.</p>
</template>
const app = new Funa({
	data: {
		planet: {
			name: 'Earth',
			pos: 3,
		}
	},
	
	as: {
		nth: {
			convert: (value) => (value === 1) ? '1st' : (value === 2) ? '2nd' : (value === 3) ? '3rd' : value + 'th'
		}
	},
	
	is: {
		Planet: {
			pos: 'nth'
		}
	},
});

app.render();

Conditional Rendering

<any ?=data></any>
<any ?=test(...args)></any>

<any !=data></any>
<any !=test(...args)></any>
{
	if: {
		/**
		 * @param args - callback arguments in the expression
		 */
		test(...args): boolean {
			...
		}
	}
}

Conditional rendering controls whether to render an element or not, by specifying an if expression.

- ? directive renders the element if the expression is truthy.

- ! directive renders the element if the expression is falsey.

Expression value can be either:

- A property of the current data context.

- A callback function defined in the if registry.

Example

<template>
	<section :products>
		<article>
			<h3>{name}</h3>
			<div class="price">
				<span>${price}</span>
				<s ?=discount()>${listprice}</s>
			</div>
		</article>
	</section>
</template>
const app = new Funa({
	data: {
		products: [
			{
				name: 'Sweat Design Storage Bin',
				price: 19,
				listprice: 24,
			},
			{
				name: 'Strong Storage Basket',
				price: 32,
				listprice: 32,
			},
		]
	},
	
	if: {
		discount() {
			return this.price < this.listprice;
		}
	},
});

app.render();

Event Handling

<any @event=func></any>
<any @event=func(...args)></any>
{
	on: {
		/**
		 * @param sender - the rendered element
		 * @param event - event object representing the triggered event
		 * @param args - callback arguments in the expression
		 */
		func(sender: HTMLElement, event: Event, ...args): void {
			...
		}
	}
}

The on expression adds an event listener to the current rendering DOM element.

The callback function is defined in the on registry.

Parentheses are optional when calling with no arguments.

Omitting the event name will execute the callback function immediately after rendering.

Example

<template>
	<p>{$count}</p>
	<button @click=inc>Click me</button>
</template>
const app = new Funa({
	data: {
		count: 0
	},
	
	on: {
		inc() {
			app.data.count++;
		}
	},
});

app.render();

Two-way Binding

<any .property@event={$data}></any>
<any .property@event={pre(...args) -> $data:format(...args) -> post(...args)}></any>

Two-way binding establishes a connection between UI elements and data, ensuring that changes in one are instantly reflected in the other.

This combines the power of property binding and event handling in one declaration.

$ prefix is required on the data path.

Nested data path is not supported.

pre and post are optional on expression callbacks.

Example

<template>
	<input type="text" .disabled={$lock}>
	<label>
		<input type="checkbox" .checked@change={$lock}>
		Lock
	</label>
</template>
const app = new Funa({
	data: {
		lock: false
	}
});

app.render();

Template Switching

<any #=tplId></any>

Template switching changes the current rendering element to another element.

It is intended to allow splitting up large templates into smaller, manageable pieces for improved organization and maintainability.

It is also useful for inserting the content of one template into another, creating nested components or dynamically replacing sections of the UI.

Example

<template>
<ul :tree>
	<li #=node></li>
</ul>
</template>

<template id="node">
<li>
	<span>{name}</span>
	<ul :children ?=length>
		<li #=node></li>
	</ul>
</li>
</template>
const app = new Funa({
	data: {
		tree: [
			{
				name: 'A', children: [
					{
						name: 'A.1', children: [
							{
								name: 'A.1.i', children: []
							}
						]
					}
				]
			}
		]
	}
});

app.render();

Template Rendering

/**
 * Renders template from its name.
 * 
 * @param target - the parent element that hold templates
 * @param name - the id of the template to render
 */
render(target?: Element = document.body, name?: string): void;

This function first parses all templates that are direct children of the target, and then renders the specified one.

If none is specified, the first one will be applied.

Dependency Property

Declaring dependencies between data properties allows automatically triggering updates in the connected UI elements.

/**
 * Binds `target.dependencyProperty` to `source.sourceProperty`.
 * When the source changes, the target's listeners will receive a notification.
 */
depend(target: any, dependencyProperty: string, source: any, sourceProperty: string | string[]);
/**
 * Defines a new property for the target object, as well as its dependencies.
 * Combines the `Object.defineProperty` and the `depend` function.
 */
define(target: any, property: string, descriptor: {
	get: () => any,
	set: (value: any) => void,
	links: (string | { src: any, prop: string })[],
});
/**
 * Signal to update all the `target.property` dependencies.
 */
notify(target: any, property: string);

funa.d.ts

type FunaAs<T, U> = {
    convert?: (value: T, ...args: (string | number)[]) => U;
    revert?: (value: U, ...args: (string | number)[]) => T;
};

type FunaIf = (...args: (string | number)[]) => boolean;

type FunaIs = {
    [key: string]: (string | FunaIs);
};

type FunaOn = (sender: Element, event: Event, ...args: (string | number)[]) => void;

interface FunaConfig {
    bypassTags: string[];
}

interface FunaInit {
    config?: FunaConfig;
    data?: Record<string, any>;
    as?: Record<string, FunaAs<any, any>>;
    if?: Record<string, FunaIf>;
    is?: Record<string, FunaIs>;
    on?: Record<string, FunaOn>;
}

interface FunaPropertyDescriptor extends PropertyDescriptor {
    links: (string | { src: any; prop: string; })[];
}

declare class Funa<T extends FunaInit> {
    constructor(init?: T);
	
    version: number;
	
    data: Record<string, any> & T['data'];
    as: Record<string, FunaAs<any, any>> & T['as'];
    if: Record<string, FunaIf> & T['if'];
    is: Record<string, FunaIs> & T['is'];
    on: Record<string, FunaOn> & T['on'];
	
    render: (target?: Element, name?: string) => void;
	
    depend(target: any, dependencyProperty: string, sourceProperty: string | string[]): void;
    depend(target: any, dependencyProperty: string, source: any, sourceProperty: string | string[]): void;
	
    define(target: any, property: string, descriptor: FunaPropertyDescriptor): void;
	
    notify(target: any, property: string): void;
}

export { Funa };