A foundational library to help building long-lasting web applications. Soil allows you to declaratively create type-safe HTML and SVG elements. One way to think of it might be as "HTML-in-JS".
The pace at which the web ecosystem evolves is unthinkably fast. At the same time, trendy web frameworks often offer poor interoperability with standard technologies, and the technical burden they introduce tends to be significant and clearer than the benefits. Projects based on them are left with the choice of deprecation and long-term unmaintainability, or the expense of unreasonable amounts of resources to match the community's speed.
Soil aims at putting together a minimal set of basic elements that embrace today's web standards and help you in developing high-quality, enduring applications, while being competitive with popular frameworks in areas such as reliability, testability, reusability, development experience and performance.
Soil encourages an architecture around components, conceptually similar to the Web Components' proposal. Components are responsible for rendering parts of UI and controlling the user interaction with them, and are framework-agnostic.
They create and manipulate HTML elements dynamically, with the help of type-safe functions with a one-to-one correspondence with standard HTML elements, which provides a look-and-feel similar to regular HTML.
import {h, element} from '@soil/lib'
export const counter = element(() => {
const count = h('span')
const tmpl = h('div', {}, [
h('button', {onclick: () => ctrl.value--}, ['-']),
count,
h('button', {onclick: () => ctrl.value++}, ['+'])
])
const ctrl = {
get value() {
return parseInt(count.textContent!, 10)
},
set value(v: number) {
count.textContent = '' + v
}
}
return [tmpl, ctrl]
})
Custom components can then be used in a way similar to native ones.
import {counter} from './counter'
const c = counter({value: 1})
document.body.appendChild(c)
c.value++
While purely presentational components have no dependencies, container components may have them, including other components. For this, pure dependency injection is recommended, which could be achieved through default parameters, or through explicit factory functions, as illustrated bellow.
import {counterService, CounterService} from './counterService'
export const counterFactory = (counterService: CounterService) => (props: {}) => {
// ...
}
export const counter = counterFactory(counterService)
export type Counter = typeof counter
Communicating adjacent components is usually easy. What about distant
components? There are plenty of alternatives
out there. One possibility is to use the native CustomEvent
,
which integrates nicely with web components based on native DOM elements.
To get a bit more familiar with the ideas presented above, you may head to the individual sub-projects to read their documentation, check out the examples, or dive directly into the source code!
The package is available at npm's registry, so it can be installed via npm or Yarn:
npm i -S @soil/lib
# AKA npm install --save @soil/lib
yarn add @soil/lib
Creating HTML using strings is not type-safe. Creating them from code is too
verbose. The h
function serves as a shortcut function to create any HTML
element. As a namespace, it contains type aliases to refer the types returned
by this function.
import {h} from '@soil/lib'
const button: h.Button = h('button', {onclick: () => alert('Clicked')}, ['Click me'])
const paragraph: h.P = h('p', {}, [
'Text with ',
h('a', {href: '...'}, ['link'])
])
const input: h.Input = h('input', {placeholder: 'Input...'})
They are provided under a namespace to avoid polluting the scope with plenty of
functions and types (a
, A
, b
, B
, ...); to prevent problems with reserved
words such as var
and switch
, which would be required for elements such as
<var>
and
<switch>
;
and to avoid long import statements. As a nice side effect the auto-completion
experience is better too.
Analogous to h
for SVG elements.
import {s} from '@soil/lib'
s('svg', {width: {baseVal: {value: 100}}, height: {baseVal: {value: 100}}}, [
s('circle', {
cx: {baseVal: {value: 50}},
cy: {baseVal: {value: 50}},
r: {baseVal: {value: 40}},
style: {
stroke: 'green',
strokeWidth: '4',
fill: 'yellow'
}
})
])
Unfortunately, creating type-safe SVG programmatically results in verbose code, and the difference between attributes and properties is bigger than in the HTML case. The code above produces the same circle than the following HTML:
<svg width="100" height="100">
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
</svg>
On the other hand, we have access to the whole SVG API, richer than its
attribute-based counterpart, and there will be no differences between creating
elements and modifying them, e.g. you would otherwise need <circle stroke="green" />
for creation but circle.style.stroke = 'red'
for modification.
Components are the main building block of modern web applications. They are
functions responsible for creating instances of custom elements, which typically
are regular HTML or SVG elements extended with custom functions, getters and
setters. The element()
function facilitates and streamlines the creation of
such components, in a way that makes them resemble native elements.
element()
accepts a function which serves as the definition of the custom
component. In turn, that function is responsible for returning a two-size tuple
containing the internal DOM structure of the element (its "template") and a
number of functions which will determine the ways one is allowed to interact
with it (its "controller"), and optionally accepts a series of children.
import {h, element} from '@soil/lib'
const fancyLink = element(() => {
const tmpl = h('a', {href: 'https://example.org/'}, ['Fancy Link'])
const ctrl = {
set secret(s: number) {
tmpl.dataset.secret = '' + s
},
fly: () => console.log('Taking off...')
}
return [tmpl, ctrl]
})
const aFancyLink = fancyLink({secret: 1, className: 'a-fancy-link'})
aFancyLink.secret = 2
aFancyLink.fly()
Note how the controller implement the interface of the properties supported by the component. Following from this, there is no need to perform an initial assignment of the properties to the internal DOM elements: this will happen automatically, enforcing consistent behaviour between initialization and later usage, as we are accustomed to with native interfaces.
When components need dependencies, they can be defined as a high-order function. To facilitate both regular development and testing, both the factory function and the default instance can be exported.
import {element} from '@soil/lib'
import {serviceX, ServiceX} from './ServiceX'
export const elemXFactory = (serviceX: ServiceX) => element(() => {
// ...
})
export const elemX = elemXFactory(serviceX)
Feel free and encouraged to open new discussions on any non-technical topic that may help maturing Soil. For technical contributions, pull requests are also welcomed.
The Soil project is licensed under the GNU Affero General Public License.