vkit-js
v0.0.4
Published
vKit is a web application framework with a command line interface.
Downloads
3
Readme
Example app using the TypeScript module:
import {htmlTags, signal, render} from "vkit-js";
const {Br, Button} = htmlTags;
function CounterApp() {
const count = signal(0);
return [
Button("Increment", {
onclick: () => count.add(1)
}),
Button("Reset counter", {
disabled: () => count() === 0,
onclick: () => count.set(0)
}),
Br(),
"Click count: ", count
];
}
render(CounterApp, document.body);
Table of Contents
- Getting Started
- Components and Views
- Styles
- Signals
- View Blocks
- View List Blocks
- Component Lifecycle
- Dependency Injection
- Observables and Subscription
- Routing
- References
- Serializing a Form
- Custom Elements
- Windows and Components
- Cookies
- Dragging Elements
- Text Selection
- Lexical Scanner
- Syntactic Parser
- Recording Audio
Getting Started
Before using vKit, you need to have NodeJS installed with npm.
Install as a TypeScript module
You can add vKit to your existing TypeScript web project:
- Install vKit with
npm i vkit-js
- Import the methods you wish to use
You can import the dependencies by name:
import {htmlTags, render, signal} from "vkit-js";
Or as a single object:
import * as $ from "vkit-js";
Use the CLI
You can also use the vKit CLI if you do not want to use any other build tools and you prefer simple JavaScript:
- Run
npm i -g vkit-js
to install vKit globally - Run
vkit
to create/open a project in the current directory
If you edit your js
and css
files inside the generated src
directory, you can immediately see the changes in your browser.
Use the export
command to create a standalone html
or js
file.
Use on the server
There is also a limited version of vKit that can be run on a NodeJS server. It includes templating, styling and routing but no reactivity.
You can install the vKit module with this command:
npm i vkit-js
An example server app:
import http from "http";
import {href, html, htmlTags, meta, param, path, router, server, styledHtmlTag, title} from "vkit-js/server";
const {A, Li, Main, Nav, Ul} = htmlTags;
const RedH1 = styledHtmlTag("h1", `::this{color: red;}`);
const App = () => [
title("Example App"),
meta("description", "This is an example application."),
RedH1("Hello, you are on page ", path()),
Nav(
Ul(
Li(A("Home", href("?"))),
Li(A("About", href("?page=about")))
)
),
Main(
router(param("page"), [
{
path: "",
component: () => [
title((t) => `Home | ${t}`),
"Home"
]
},
{
path: "about",
component: () => [
title((t) => `About | ${t}`),
"About"
]
}
])
)
];
const requestListener = server.view((server) => html`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${server.meta()}
<title>${server.title()}</title>
<style>${server.style()}</style>
</head>
<body>
${App()}
</body>
</html>`);
http.createServer(requestListener).listen(1234);
You can configure the CLI to produce different js
files for the client and the server in order to enable server side rendering.
Components and Views
The fundamental building block of a vKit application is a view. It is most commonly a DOM node, a text or an array but many other types are also allowed. You can create a DOM node list with the $.html
function:
const myView = $.html(
'<h1>Hello world</h1>',
'<p>This is a <code>hello world</code> application.</p>'
);
Or you can create DOM elements with the $.htmlTags
proxy object:
const {Code, H1, P} = $.htmlTags;
const myView = [
H1("Hello world"),
P("This is a ", Code("hello world"), "application.")
];
In vKit, a component is a function that returns a view:
const HelloComponent = name => $.html('<h1>Hello ', [name], '</h1>');
As you can see, you can wrap any text in an array to safely insert it in HTML. Alternatively, you can use tagged template literals.
const HelloComponent = name => $.html`<h1>Hello ${name}</h1>`;
To render a component in document.body
, just call render
on it. This is what a typical application root looks like.
const App = () => HelloComponent("world");
$.render(App, document.body);
As you have more components, you can build a tree of them:
const {Footer, Header, Main} = $.htmlTags;
function App() {
return [
Header(
HelloComponent("A"),
),
Main(
HelloComponent("B")
),
Footer(
HelloComponent("C")
)
]
}
$.render(App, document.body);
The arguments of element factory functions can be a mix of child nodes to be appended, function directives and property binding objects.
Div(
{
className: "box",
style: {
color: "red"
}
},
H1("Element node"),
"Text node",
[
"Arrays can also be used for grouping",
{
style: {
color: "blue"
}
}
],
(div) => console.log("Color:", div.style.color)
)
Event Listeners
You can bind a property named on*
to attach an event listener to a DOM element.
const {Button} = $.htmlTags;
function ClickableButton() {
return Button("Click me", {
onclick(event) {
console.log("Clicked.", event);
}
});
}
A global event listener can be attached with bind
. It will be detached when the current component is destroyed.
$.bind(document, {
onclick(event) {
console.log("Clicked.", event);
}
});
Styles
The $.style
method can be used with the special ::this
selector to apply CSS rules to DOM elements easily.
const SpecialButtonStyle = $.style(`
::this{
border: 0;
background-color: #ffffff;
color: #000000;
cursor: pointer;
}
`);
function SpecialButton(...args) {
return $.htmlTags.Button(SpecialButtonStyle, args);
}
In many cases, an element and its style can be defined as a component. The $.styledHtmlTag
method is more useful in this case.
const SpecialButton = $.styledHtmlTag("button", `
::this{
border: 0;
background-color: #ffffff;
color: #000000;
cursor: pointer;
}
`);
const myButton = SpecialButton("Some label");
Signals
A signal is a container object whose value may change over time. There are two types of signals: writable and computed (read-only).
Writable Signals
A writable signal can be created with $.signal
.
const count = $.signal(42);
Its value can be written with set
and read with get
.
count.set(50);
console.log(count.get()); // 50
It is also possible to add
a value, similarly to the +=
operator.
count.add(12); // equivalent to count.set(count.get() + 12);
The toggle
method is useful if the signal contains a boolean value. It transforms true
into false
and vice versa.
const show = $.signal(false);
show.toggle(); // equivalent to count.set(!count.get());
Computed Signals
A computed (or read-only) signal can be created with $.computed
. Read-only signals do not have add
, set
and toggle
methods.
const myText = $.computed(() => "Hello world");
You can use the ()
operator on any signal inside the callback function of $.computed
. It automatically updates the computed signal when an input signal changes.
const name = $.signal("world");
const myText = $.computed(() => "Hello " + name());
A computed signal is lazy, which means that its value is not calculated until it is needed somewhere (in the DOM or in a side effect).
const notUsedAnywhere = $.computed(() => (
"This will never be calculated"
));
A computed signal caches its value, so unless at least one of its inputs change, the value is not recalculated.
const array = $.signal([]);
const query = $.signal("");
const filtered = $.computed(() => (
array().filter((item) => (
item.name.contains(query())
))
));
Computed signals are not immediately updated. The updates are added to a queue instead. To make sure all computed signals are up to date, you can call $.update
.
const a = $.signal(3);
const b = $.signal(5);
const aPlusB = $.computed(() => a() + b());
console.log(aPlusB.get()); // 8
a.set(13);
console.log(aPlusB.get()); // 8
$.update();
console.log(aPlusB.get()); // 18
Using a Signal
A signal can be simply used as a dynamic text in the DOM.
const name = $.signal("world");
return P("Hello ", name);
It can also be used as a dynamic property of a DOM element.
const name = $.signal("world");
const color = $.signal("#ff0000");
return Input({
value: name,
style: {
color
}
});
Attributes can be dynamic too, not just properties.
return Div(
$.attributes({
"my-attribute": () => name() + "!"
})
);
Or classes.
return Div(
$.classes({
"my-class": () => isMyClassTrue()
})
);
Side effects can also be created that run when the value of an input signal changes.
$.effect(() => {
console.log(`Hello ${name()}`);
$.onUnmount(() => {
console.log("Optional cleanup function");
});
});
A dynamic text node can also be created without a computed signal.
$.text(() => `Hello ${name()}`)
Reactivity of Nested Objects
If you have a nested object, it could be difficult to detect deep changes.
const object = {
count: 0
};
++object.count;
Unfortunately, computed signals are not updated.
const count = $.computed(() => object.count);
To fix this, you can use $.of
. It is recommended to always put it in a $.computed
block.
const count = $.computed(() => $.of(object).count);
Mapping Signals
The $.map
method can be used to create a reusable function that maps input signals to an output signal. Its only parameter is a pure function that returns the output signal's current value.
const sum = $.map((x, y) => x + y);
It can be used to combine multiple signals into a new (computed) one.
const a = $.signal(3);
const b = $.signal(5);
const aPlusB = sum(a, b);
If you need to transform a single signal, you can just simply call map
on it.
const doubleCount = count.map(x => x * 2);
This means the same as:
const doubleCount = $.computed(() => count() * 2);
View Blocks
Sometimes, modifying existing DOM nodes is not enough. You may want to insert new nodes and remove old ones. A view block is a part of the DOM tree which is destroyed and re-created every time a value changes.
$.view(() => show() && Div("This text is shown now!"));
Note that unwanted DOM updates may occur if you use a non-boolean signal as a condition.
$.view(() => count() > 3 && Div("This text is shown now!"));
If you wrap condition expressions in $.is(() => ...)
, many unwanted DOM updates can be avoided.
$.view(() => {
if ($.is(() => count() > 3)) {
return Div("This text is shown now!");
}
});
You can also use the alternative syntax if the view is generated from a single signal.
show.view((doShow) => {
if (doShow) {
return Div("This text is shown now!");
}
});
View List Blocks
A view list block can be used to render a dynamic list of views (most commonly list items or table rows) efficiently. First, you need a signal that contains an array.
const items = $.signal([
{
value: "Hello world"
}
]);
Then you can use its views
method to create the list items.
return Ul(
items.views((item) => (
Li(item.value)
))
);
In some cases you might need to identify array items by a key (a string or a number) instead of their value. You can do this with the $.useKey
method.
const BooksTable = (books) => $.html`
<table>
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Year</th>
</tr>
</thead>
<tbody>${
$.useKey(books, "id").views(BookRow)
}</tbody>
</table>'
`;
const BookRow = (bookSignal) => $.html`
<tr>
<td>${() => bookSignal().title}</td>
<td>${() => bookSignal().author}</td>
<td>${() => bookSignal().year}</td>
</tr>
`;
Component Lifecycle
Components can disappear from the tree when the value of $.view
changes or the corresponding item is no longer in the array used in $.views
. When this happens, all side effects caused by creating the component must be reverted. This includes all timeouts, AJAX requests, external state changes initiated by the component.
Fortunately, the $.onUnmount
function can be used here.
function Clock() {
const date = $.signal(new Date());
const interval = setInterval(() => {
date.set(new Date());
}, 1000);
$.onUnmount(() => {
clearInterval(interval);
});
return $.computed(() => date().toLocaleString());
}
You can enqueue a function to be called after the current render cycle using $.tick
. This is useful for interacting with the DOM after it has been rendered (e.g. when playing videos, scrolling, measuring CSS properties of elements, auto-focusing).
const AutoFocus = (element) => {
$.tick(() => element.focus());
};
const {Input} = $.htmlTags;
function AutoFocusedInput() {
return Input(AutoFocus);
}
Dependency Injection
There are two ways a component can get data: from function parameters and from injected services. The difference is that in the latter case, intermediary components do not need to handle data that does not belong to them. By default, service instances are singletons lazily constructed and injected with $.inject
.
const {P} = $.htmlTags;
function MyComponent() {
const myService = $.inject(MyService);
return P(myService.getText());
}
class MyService {
constructor() { // No arguments
this.anotherService = $.inject(AnotherService);
}
getText() {
return this.anotherService.text;
}
}
class AnotherService {
text = "Hello world";
}
As your application grows, you might need to limit the scope of these services. You can do this easily with $.provide
.
function ProviderComponent() {
return $.provide([
MyService,
AnotherService
], MyComponent);
}
This means that in the scope of $.provide
you can access the same instance of the service class, but a different instance outside.
Observables and Subscription
An observable in vKit is a simple function whose behavior is not yet defined during creation.
const saveFile = $.observable();
You can later subscribe to an observable with subscribe
.
const unsubscribe = saveFile.subscribe((file) => {
// Save the file somehow
});
And when you no longer need to be subscribed, be sure to unsubscribe. Not doing so may lead to memory leaks.
$.onUnmount(unsubscribe);
An observable can be called like any function. When this happens, all of its subscribers are called with the same arguments. The observable has no return value.
saveFile(file);
You can also unsubscribe all subscribers with the clear
method.
saveFile.clear();
Routing
Any signal can be used to provide the current path of the application. For instance, vKit has a $.hash
factory function which can be used for hash based routing. Since its return value is a signal, you can create a view from it.
$.hash().view((path) => {
switch(path) {
case "": return HomeComponent();
case "about": return AboutComponent();
default: return NotFoundComponent();
}
});
vKit provides a router
function to implement more sophisticated routing.
function App() {
const router = $.router($.hash(), [
{
path: "",
component: HomeComponent
},
{
path: "about",
component: AboutComponent,
exact: false
},
{
component: NotFoundComponent
}
]);
return [
MenuComponent(router),
router
]);
}
const {A, Li, Ul} = $.htmlTags;
function MenuComponent(router) {
const menuItems = [
{
title: "Home",
path: ""
},
{
title: "About",
path: "about"
}
];
return Ul(
menuItems.map(({title, path}) => Li(
A(
title,
$.href("#" + path),
{
className: $.classNames({
"menu-button": true,
"menu-button-selected": router.isActive(path)
})
}
)
))
);
}
A dynamic query parameter can be accessed with $.param
. It can be useful for routing.
const {A, Li, Ul} = $.htmlTags;
function App() {
const router = $.router($.param("page"), [
{
path: "a",
component: () => "Page A"
},
{
path: "b",
component: () => "Page B"
}
]);
return [
Ul(
Li(A("Page A", $.href("?page=a"))),
Li(A("Page B", $.href("?page=b")))
),
router
]);
}
References
Although element (or other) references can be set with simple functions, there is a built-in ref
function to create references.
function InputFocusComponent() {
const inputRef = $.ref();
return $.html(
'<input>', inputRef,
'<input type="button" value="Focus">', {
onclick: () => inputRef.current.focus()
}
);
}
Serializing a Form
In order to serialize a form the way you want, you can use $.serialize
. It iterates over all name-value pairs of the form, just as if it was parsed on the server side.
const {Button, Form, Input, Label} = $.htmlTags;
function FormComponent() {
function onsubmit(e) {
e.preventDefault();
const data = {};
$.serialize(this, (name, value) => data[name] = value);
sendRequest(data);
}
return Form(
{onsubmit},
Label("Name: ", Input({name: "name"})),
Button("Submit")
);
}
Custom Elements
Custom elements can be registered with $.customElement
to encapsulate some functionality. They have their own component tree and can be used by non-vKit applications.
const {Div, H1} = $.htmlTags;
$.customElement("hello-element", function({name}) {
name = $.defaultValue(name, "world");
console.log("The <hello-element> is connected!");
$.onUnmount(() => {
console.log("The <hello-element> is disconnected!");
});
return $.shadow(
H1("Hello ", name),
Div(this.childNodes)
);
}, {
observedAttributes: ["name"]
});
const {Hello_Element, P} = $.htmlTags;
const App = () => Hello_Element(
$.attributes({
name: "world"
}),
P("Some contents")
);
$.render(App, document.body);
Windows and Components
Creating a component in another window (e.g. a tab or an iframe) is difficult because it might get unloaded before your app does and the side effects remain. To avoid this, you should use windowContent
or frameContent
.
const {Br, Button, H1, Iframe} = $.htmlTags;
const NewWindowContent = $.windowContent((window) => {
return [
H1("This is a new window!"),
Button("Close it", {
onclick: () => window.close()
})
];
});
function NewWindowOpener() {
return [
Button("Open new window", {
onclick: () => NewWindowContent(window.open())
}),
Br(),
Iframe($.frameContent(() => [
H1("This is the content of an iframe!")
]))
];
}
Cookies
There is no easy way to get or set cookies natively. $.cookies
provides a powerful interface for this purpose which also works on the server.
function CookieComponent() {
const cookies = $.cookies();
cookies.forEach((name, value) => {
console.log("Cookie:", name, value);
});
const cookieName = "MyCookie";
return [
Input({
value: cookies.getCookie(cookieName),
oninput() {
const expiry = Date.now() + 1000 * 60 * 30; // null for session cookie
const options = {
httpOnly: false,
path: "/",
sameSite: "Strict",
secure: true
};
cookies.setCookie(
cookieName,
this.value,
expiry,
options
);
}
}),
Button("Delete", {
onclick() {
cookies.deleteCookie(cookieName);
}
})
];
}
Dragging Elements
You can turn elements to be draggable with both touch and mouse events, move other elements or even perform a custom action.
const {Div} = $.htmlTags;
function DragTest(){
const dragZone = $.dragZone($.document());
Div("Movable", dragZone.draggable(), {
style: {
position: "absolute",
cursor: "move"
}
});
}
Text Selection
A cross-browser implementation of selection in input fields and textareas. It can be used for rich text editors, for example.
const {Br, Button, Textarea} = $.htmlTags;
function EditorComponent() {
let textarea;
return $.html(
Button("Insert tab", {
onclick() {
$.insertText(textarea, "\t");
}
}),
Button("Select first character", {
onclick() {
$.selectText(textarea, 0, 1);
}
}),
Button("Show selected text", {
onclick() {
const {start, end} = $.textSelection(textarea);
console.log(
textarea.value.substring(start, end)
);
}
}),
Br(),
Textarea((el) => textarea = el)
);
}
Lexical Scanner
To implement a syntax highlighter or parser, you might need a lexical scanner. vKit has a built-in function for that purpose: $.lexer
.
const rules = {
"identifier": /[a-zA-Z][a-zA-Z0-9]*/,
"whitespace": /\s+/,
"illegal": /[^]/
};
const lexer = $.lexer(rules);
You can use it to scan your text input and generate tokens.
for (const token of lexer.scan("Hello world")) {
if (token.type === "whitespace") {
continue;
}
console.log(token);
}
Syntactic Parser
vKit is capable of building a parse tree based on the specified syntactic rules. To see how it works, take a look at this snippet from an equation solver application.
const input = "x^2 + 1 = 2*x";
const parseTree = $.parseTree(operators);
const outputMessage = $.parse(
// Lexer instance
$.lexer(rules, input),
// Top-level syntax
["EXPR", "=", "EXPR"],
// Syntactic rules
syntax,
// How to apply a rule (optional)
function(expect, node, replacement) {
if (expect === "EXPR") {
if (node.type === "-") {
node.type = "negation";
}
}
parseTree.add(node);
},
// Which tokens to skip (optional)
node => node.type === "whitespace"
);
console.log( parseTree.root.toString() );
Syntactic rules can be given in the following form.
const syntax = {
"EXPR": {
"number": ["EXPR_AFTER"],
"(": ["EXPR", ")", "EXPR_AFTER"],
"x": ["EXPR_AFTER"],
"function": ["(", "ARGS)", "EXPR_AFTER"],
"+": ["EXPR", "EXPR_AFTER"],
"-": ["EXPR", "EXPR_AFTER"]
},
"EXPR_AFTER": {
"+": ["EXPR"],
"-": ["EXPR"],
"*": ["EXPR"],
"/": ["EXPR"],
"^": ["EXPR"],
"composition": ["EXPR"],
"iteration": ["EXPR"],
"": []
},
"ARGS)": {
")": [],
"": ["EXPR", "COMMA_EXPR", ")"]
},
"COMMA_EXPR": {
",": ["EXPR", "COMMA_EXPR"],
"": []
}
};
Operators can be configured such as:
const operators = {
"^": {
"precedence": 11
},
"*": {
"precedence": 10,
"left": true
},
"/": {
"precedence": 10,
"left": true
},
"+": {
"precedence": 9,
"left": true
},
"-": {
"precedence": 9,
"left": true
},
"negation": {
"unary": true,
"precedence": 9,
"left": true
},
"=": {
"precedence": 6
},
",": {
"precedence": 5
},
"(": {
"parenthesis": "opening"
},
")": {
"parenthesis": "closing"
}
};
Recording Audio
Since the built-in MediaRecorder
API has some problems, vKit provides an alternative for audio recording. $.audioRecorder
can be efficiently used with the Web Audio API. Its output is a WAV file.
const ctx = new AudioContext();
async function recordMicrophone() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
const source = ctx.createMediaStreamSource(stream);
const rec = $.audioRecorder(source);
rec.start();
return async function() {
for (const track of stream.getTracks()) {
track.stop();
}
rec.stop();
return await rec.exportWAV();
};
}