@srfnstack/fntags 0.3.2 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -4
- package/package.json +8 -3
- package/src/fnroute.mjs +6 -1
- package/src/fntags.mjs +66 -41
package/README.md
CHANGED
|
@@ -1,7 +1,90 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img alt="fntags header" src="https://raw.githubusercontent.com/SRFNStack/fntags/master/docs/fntags_header.gif">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
---
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
## What the f?
|
|
8
|
+
fntags primary goal is to make the developer experience pleasant while providing high performance and neato features.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
- No dependencies and no build tools
|
|
11
|
+
<br> - Import the framework directly from your favorite cdn and start building.
|
|
12
|
+
|
|
13
|
+
- Everything is javascript
|
|
14
|
+
<br> - There's no special templating language to learn, and you won't be writing html. <br> - This removes the template+functionality duality and helps keep you focused by removing context switching.
|
|
15
|
+
|
|
16
|
+
- Real DOM elements
|
|
17
|
+
<br> - Every element is a real dom element, there's no virtual dom and no wrapper objects.
|
|
18
|
+
|
|
19
|
+
- It's familiar
|
|
20
|
+
<br> - fntags was inspired by React, and the state management is similar to react hooks.
|
|
21
|
+
|
|
22
|
+
- [State binding is explicit and granular](https://srfnstack.github.io/fntags/state#Binding%20State)
|
|
23
|
+
<br> - Bind only the text of an element, an attribute, or a style. You control the binding, replace as much or as little as you want.
|
|
24
|
+
|
|
25
|
+
- State exists without components
|
|
26
|
+
<br> - This allows app wide states to be maintained using export/import and removes the need for complex state management like redux.
|
|
27
|
+
|
|
28
|
+
- [Dynamic routing](https://srfnstack.github.io/fntags/routing#Dynamic%20Path%20Based%20Routing%3A%20modRouter)
|
|
29
|
+
<br> - The modRouter only loads the files required by each route and doesn't require bundling.
|
|
30
|
+
<br> - Bye bye fat bundles, hello highly cacheable routes.
|
|
31
|
+
|
|
32
|
+
- [Async Rendering](https://srfnstack.github.io/fntags/components#Async%20Rendering)
|
|
33
|
+
<br> - fntags will resolve promises that are passed to element functions, no special handling required.
|
|
34
|
+
|
|
35
|
+
## [Documentation](https://srfnstack.github.io/fntags)
|
|
36
|
+
Check out the [documentation](https://srfnstack.github.io/fntags) to learn more!
|
|
37
|
+
|
|
38
|
+
### f'n examples
|
|
39
|
+
<hr>
|
|
40
|
+
|
|
41
|
+
Components are plain functions
|
|
42
|
+
```javascript
|
|
43
|
+
import { div } from 'https://cdn.jsdelivr.net/npm/@srfnstack/fntags@0.3.3/src/fnelements.min.mjs'
|
|
44
|
+
|
|
45
|
+
const hello = name => div('Hello ', name)
|
|
46
|
+
|
|
47
|
+
document.body.append( hello('world!') )
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Two-way binding is a breeze with the bind functions provided by fnstate objects.
|
|
51
|
+
```javascript
|
|
52
|
+
import { fnstate } from 'https://cdn.jsdelivr.net/npm/@srfnstack/fntags@0.3.3/src/fntags.min.mjs'
|
|
53
|
+
import { div, input, br } from 'https://cdn.jsdelivr.net/npm/@srfnstack/fntags@0.3.3/src/fnelements.min.mjs'
|
|
54
|
+
|
|
55
|
+
export const userName = fnstate('world')
|
|
56
|
+
export const appColor = fnstate('MediumTurquoise')
|
|
57
|
+
|
|
58
|
+
document.body.append(
|
|
59
|
+
div( { style: { color: appColor.bindStyle() } },
|
|
60
|
+
'Hello ', userName.bindAs(), '!'
|
|
61
|
+
),
|
|
62
|
+
br(),
|
|
63
|
+
input({
|
|
64
|
+
value: userName.bindAttr(),
|
|
65
|
+
oninput: e => userName(e.target.value)
|
|
66
|
+
}),
|
|
67
|
+
br(),
|
|
68
|
+
input({
|
|
69
|
+
value: appColor.bindAttr(),
|
|
70
|
+
oninput: e => appColor(e.target.value)
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Required HTML
|
|
76
|
+
Unfortunately browsers lack the ability to render a js file directly, and thus you still need a tiny bit of html to bootstrap your app.
|
|
77
|
+
|
|
78
|
+
Here's an example of the only html you need to write for your entire application.
|
|
79
|
+
|
|
80
|
+
Now that these two lines are there you're set to write sweet sweet es6+ and no more html.
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<html><body><script type="module">
|
|
84
|
+
import { div } from 'https://cdn.jsdelivr.net/npm/@srfnstack/fntags@0.3.3/src/fnelements.min.mjs'
|
|
85
|
+
document.body.append(div('hello world!'))
|
|
86
|
+
</script></body></html>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Benchmark
|
|
90
|
+
Check the latest benchmark results in the widely used [JS Web Frameworks Benchmark](https://krausest.github.io/js-framework-benchmark/current.html)!
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@srfnstack/fntags",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"author": "Robert Kempton <r@snow87.com>",
|
|
5
5
|
"private": false,
|
|
6
6
|
"homepage": "https://github.com/srfnstack/fntags",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"state-management"
|
|
31
31
|
],
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"cypress": "^
|
|
33
|
+
"cypress": "^9.2.0",
|
|
34
|
+
"pre-commit": "^1.2.2",
|
|
34
35
|
"standard": "^16.0.4"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
@@ -38,5 +39,9 @@
|
|
|
38
39
|
"cypress": "cypress run --spec test/** -b chrome",
|
|
39
40
|
"lint": "standard --env browser src && standard --env browser --env jest --global Prism --global cy test docs",
|
|
40
41
|
"lint:fix": "standard --env browser --fix src && standard --env browser --env jest --global Prism --global cy --fix test docs"
|
|
41
|
-
}
|
|
42
|
+
},
|
|
43
|
+
"pre-commit": [
|
|
44
|
+
"lint",
|
|
45
|
+
"test"
|
|
46
|
+
]
|
|
42
47
|
}
|
package/src/fnroute.mjs
CHANGED
|
@@ -104,7 +104,12 @@ export const modRouter = ({ routePath, attrs, onerror, frame, sendRawPath, forma
|
|
|
104
104
|
}
|
|
105
105
|
const filePath = path ? routePath + ensureOnlyLeadingSlash(path) : routePath
|
|
106
106
|
|
|
107
|
-
const p = moduleCache[filePath]
|
|
107
|
+
const p = moduleCache[filePath]
|
|
108
|
+
? Promise.resolve(moduleCache[filePath])
|
|
109
|
+
: import(filePath).then(m => {
|
|
110
|
+
moduleCache[filePath] = m
|
|
111
|
+
return m
|
|
112
|
+
})
|
|
108
113
|
|
|
109
114
|
p.then(module => {
|
|
110
115
|
const route = module.default
|
package/src/fntags.mjs
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A function to create dom elements with the given attributes and children.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* The first element of the children array can be an object containing element attributes.
|
|
5
|
+
* The attribute names are the standard attribute names used in html, and should all be lower case as usual.
|
|
6
|
+
*
|
|
7
|
+
* Any attribute starting with 'on' that is a function is added as an event listener with the 'on' removed.
|
|
8
|
+
* i.e. { onclick: fn } gets added to the element as element.addEventListener('click', fn)
|
|
9
|
+
*
|
|
10
|
+
* The style attribute can be an object and the properties of the object will be added as style properties to the element.
|
|
11
|
+
* i.e. { style: { color: blue } } becomes element.style.color = blue
|
|
6
12
|
*
|
|
7
13
|
* The rest of the arguments will be considered children of this element and appended to it in the same order as passed.
|
|
8
14
|
*
|
|
9
15
|
* @param tag html tag to use when created the element
|
|
10
|
-
* @param children optional
|
|
16
|
+
* @param children optional attributes object and children for the element
|
|
11
17
|
* @returns HTMLElement an html element
|
|
12
18
|
*
|
|
13
19
|
*/
|
|
14
|
-
export function h () {
|
|
15
|
-
|
|
16
|
-
let firstChildIdx = 1
|
|
20
|
+
export function h (tag, ...children) {
|
|
21
|
+
let firstChildIdx = 0
|
|
17
22
|
let element
|
|
18
23
|
if (tag.startsWith('ns=')) {
|
|
19
24
|
element = document.createElementNS(...(tag.slice(3).split('|')))
|
|
@@ -21,15 +26,15 @@ export function h () {
|
|
|
21
26
|
element = document.createElement(tag)
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
if (isAttrs(
|
|
25
|
-
const attrs =
|
|
29
|
+
if (isAttrs(children[firstChildIdx])) {
|
|
30
|
+
const attrs = children[firstChildIdx]
|
|
26
31
|
firstChildIdx += 1
|
|
27
32
|
for (const a in attrs) {
|
|
28
33
|
setAttribute(a, attrs[a], element)
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
|
-
for (let i = firstChildIdx; i <
|
|
32
|
-
const child =
|
|
36
|
+
for (let i = firstChildIdx; i < children.length; i++) {
|
|
37
|
+
const child = children[i]
|
|
33
38
|
if (Array.isArray(child)) {
|
|
34
39
|
for (const c of child) {
|
|
35
40
|
element.append(renderNode(c))
|
|
@@ -45,26 +50,31 @@ export function h () {
|
|
|
45
50
|
* Create a compiled template function. The returned function takes a single object that contains the properties
|
|
46
51
|
* defined in the template.
|
|
47
52
|
*
|
|
48
|
-
* This allows fast rendering by
|
|
53
|
+
* This allows fast rendering by pre-creating a dom element with the entire template structure then cloning and populating
|
|
49
54
|
* the clone with data from the provided context. This avoids the work of having to re-execute the tag functions
|
|
50
|
-
* one by one and can speed up situations where
|
|
55
|
+
* one by one and can speed up situations where a similar element is created many times.
|
|
51
56
|
*
|
|
52
57
|
* You cannot bind state to the initial template. If you attempt to, the state will be read, but the elements will
|
|
53
58
|
* not be updated when the state changes because they will not be bound to the cloned element.
|
|
54
|
-
*
|
|
55
|
-
* All state bindings must be passed to the compiled template function to work correctly.
|
|
59
|
+
* All state bindings must be passed in the context to the compiled template to work correctly.
|
|
56
60
|
* @param templateFn {function(object): Node}
|
|
57
61
|
* @return {function(*): Node}
|
|
58
62
|
*/
|
|
59
63
|
export const fntemplate = templateFn => {
|
|
60
|
-
|
|
64
|
+
if (typeof templateFn !== 'function') {
|
|
65
|
+
throw new Error('You must pass a function to fntemplate. The function must return an html node.')
|
|
66
|
+
}
|
|
67
|
+
const placeholders = {}
|
|
61
68
|
let id = 1
|
|
62
69
|
const initContext = prop => {
|
|
70
|
+
if (!prop || typeof prop !== 'string') {
|
|
71
|
+
throw new Error('You must pass a non empty string prop name to the context function.')
|
|
72
|
+
}
|
|
63
73
|
const placeholder = (element, type, attrOrStyle) => {
|
|
64
|
-
let selector = element.
|
|
74
|
+
let selector = element.__fnselector
|
|
65
75
|
if (!selector) {
|
|
66
|
-
selector = `fntpl-${
|
|
67
|
-
element.
|
|
76
|
+
selector = `fntpl-${id++}`
|
|
77
|
+
element.__fnselector = selector
|
|
68
78
|
element.classList.add(selector)
|
|
69
79
|
}
|
|
70
80
|
if (!placeholders[selector]) placeholders[selector] = []
|
|
@@ -74,16 +84,20 @@ export const fntemplate = templateFn => {
|
|
|
74
84
|
return placeholder
|
|
75
85
|
}
|
|
76
86
|
// The initial render is cloned to prevent invalid state bindings from changing it
|
|
77
|
-
const
|
|
87
|
+
const compiled = templateFn(initContext).cloneNode(true)
|
|
78
88
|
return ctx => {
|
|
79
|
-
const clone =
|
|
89
|
+
const clone = compiled.cloneNode(true)
|
|
80
90
|
for (const selectorClass in placeholders) {
|
|
81
|
-
|
|
91
|
+
let targetElement = clone.getElementsByClassName(selectorClass)[0]
|
|
92
|
+
if (!targetElement) {
|
|
93
|
+
if (clone.classList.contains(selectorClass)) {
|
|
94
|
+
targetElement = clone
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Cannot find template element for selectorClass ${selectorClass}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
82
99
|
targetElement.classList.remove(selectorClass)
|
|
83
100
|
for (const placeholder of placeholders[selectorClass]) {
|
|
84
|
-
if (!ctx[placeholder.prop]) {
|
|
85
|
-
console.warn(`No value provided for template prop: ${placeholder.prop}`)
|
|
86
|
-
}
|
|
87
101
|
switch (placeholder.type) {
|
|
88
102
|
case 'node':
|
|
89
103
|
targetElement.replaceWith(renderNode(ctx[placeholder.prop]))
|
|
@@ -108,11 +122,10 @@ export const fntemplate = templateFn => {
|
|
|
108
122
|
* @param initialValue The initial state
|
|
109
123
|
* @param mapKey A map function to extract a key from an element in the array. Receives the array value to extract the key from.
|
|
110
124
|
* @returns function A function that can be used to get and set the state.
|
|
111
|
-
* When getting the state, you get the actual reference to the underlying value.
|
|
112
|
-
*
|
|
125
|
+
* When getting the state, you get the actual reference to the underlying value.
|
|
126
|
+
* If you perform modifications to the value, be sure to call the state function with the updated value when you're done
|
|
127
|
+
* or the changes won't be reflected correctly and binding updates won't be triggered even though the state appears to be correct.
|
|
113
128
|
*
|
|
114
|
-
* SideNote: this _could_ be implemented such that it returned a clone, however that would add a great deal of overhead, and a lot of code. Thus, the decision
|
|
115
|
-
* was made that it's up to the caller to ensure that the fnstate is called whenever there are modifications.
|
|
116
129
|
*/
|
|
117
130
|
export const fnstate = (initialValue, mapKey) => {
|
|
118
131
|
const ctx = {
|
|
@@ -126,9 +139,10 @@ export const fnstate = (initialValue, mapKey) => {
|
|
|
126
139
|
if (arguments.length === 0 || (arguments.length === 1 && arguments[0] === ctx.state)) {
|
|
127
140
|
return ctx.currentValue
|
|
128
141
|
} else {
|
|
142
|
+
const oldState = ctx.currentValue
|
|
129
143
|
ctx.currentValue = newState
|
|
130
144
|
for (const observer of ctx.observers) {
|
|
131
|
-
observer.fn(newState)
|
|
145
|
+
observer.fn(newState, oldState)
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
return newState
|
|
@@ -299,6 +313,7 @@ function createBoundAttr (attr) {
|
|
|
299
313
|
if (typeof attr !== 'function') {
|
|
300
314
|
throw new Error('You must pass a function to bindAttr')
|
|
301
315
|
}
|
|
316
|
+
// wrap the function to avoid modifying it
|
|
302
317
|
const boundAttr = () => attr()
|
|
303
318
|
boundAttr.isBoundAttribute = true
|
|
304
319
|
return boundAttr
|
|
@@ -341,7 +356,7 @@ function doSelect (ctx, key) {
|
|
|
341
356
|
|
|
342
357
|
function doBindChildren (ctx, parent, element, update) {
|
|
343
358
|
parent = renderNode(parent)
|
|
344
|
-
if (parent === undefined) {
|
|
359
|
+
if (parent === undefined || parent.nodeType === undefined) {
|
|
345
360
|
throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.')
|
|
346
361
|
}
|
|
347
362
|
if (typeof element !== 'function' && typeof update !== 'function') {
|
|
@@ -353,11 +368,11 @@ function doBindChildren (ctx, parent, element, update) {
|
|
|
353
368
|
}
|
|
354
369
|
|
|
355
370
|
if (!Array.isArray(ctx.currentValue)) {
|
|
356
|
-
|
|
371
|
+
throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.')
|
|
357
372
|
}
|
|
358
373
|
ctx.currentValue = ctx.currentValue.map(v => v.isFnState ? v : fnstate(v))
|
|
359
374
|
ctx.bindContexts.push({ element, update, parent })
|
|
360
|
-
ctx.state.subscribe(() => {
|
|
375
|
+
ctx.state.subscribe((newState, oldState) => {
|
|
361
376
|
if (!Array.isArray(ctx.currentValue)) {
|
|
362
377
|
console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.')
|
|
363
378
|
new Promise((resolve) => {
|
|
@@ -371,7 +386,7 @@ function doBindChildren (ctx, parent, element, update) {
|
|
|
371
386
|
throw e
|
|
372
387
|
})
|
|
373
388
|
} else {
|
|
374
|
-
reconcile(ctx)
|
|
389
|
+
reconcile(ctx, oldState)
|
|
375
390
|
}
|
|
376
391
|
})
|
|
377
392
|
reconcile(ctx)
|
|
@@ -443,12 +458,12 @@ const doBindAs = (ctx, element, update) =>
|
|
|
443
458
|
/**
|
|
444
459
|
* Reconcile the state of the current array value with the state of the bound elements
|
|
445
460
|
*/
|
|
446
|
-
function reconcile (ctx) {
|
|
461
|
+
function reconcile (ctx, oldState) {
|
|
447
462
|
for (const bindContext of ctx.bindContexts) {
|
|
448
463
|
if (bindContext.boundElementByKey === undefined) {
|
|
449
464
|
bindContext.boundElementByKey = {}
|
|
450
465
|
}
|
|
451
|
-
arrangeElements(ctx, bindContext)
|
|
466
|
+
arrangeElements(ctx, bindContext, oldState)
|
|
452
467
|
}
|
|
453
468
|
}
|
|
454
469
|
|
|
@@ -462,7 +477,7 @@ function keyMapper (mapKey, value) {
|
|
|
462
477
|
}
|
|
463
478
|
}
|
|
464
479
|
|
|
465
|
-
function arrangeElements (ctx, bindContext) {
|
|
480
|
+
function arrangeElements (ctx, bindContext, oldState) {
|
|
466
481
|
if (ctx.currentValue.length === 0) {
|
|
467
482
|
bindContext.parent.textContent = ''
|
|
468
483
|
bindContext.boundElementByKey = {}
|
|
@@ -479,7 +494,8 @@ function arrangeElements (ctx, bindContext) {
|
|
|
479
494
|
}
|
|
480
495
|
const key = keyMapper(ctx.mapKey, valueState())
|
|
481
496
|
if (keys[key]) {
|
|
482
|
-
|
|
497
|
+
if (oldState) ctx.state(oldState)
|
|
498
|
+
throw new Error('Duplicate keys in a bound array are not allowed, state reset to previous value.')
|
|
483
499
|
}
|
|
484
500
|
keys[key] = i
|
|
485
501
|
keysArr[i] = key
|
|
@@ -636,8 +652,8 @@ const setAttribute = function (attrName, attr, element) {
|
|
|
636
652
|
setStyle(style, attr[style], element)
|
|
637
653
|
}
|
|
638
654
|
} else if (attrName === 'class') {
|
|
639
|
-
//special handling for class to ensure the selector classes from fntemplate don't get overwritten
|
|
640
|
-
if(element.
|
|
655
|
+
// special handling for class to ensure the selector classes from fntemplate don't get overwritten
|
|
656
|
+
if (element.__fnselector && element.className) {
|
|
641
657
|
element.className += ` ${attr}`
|
|
642
658
|
} else {
|
|
643
659
|
element.className = attr
|
|
@@ -690,9 +706,18 @@ export const getAttrs = (children) => Array.isArray(children) && isAttrs(childre
|
|
|
690
706
|
export const styled = (style, tag, children) => {
|
|
691
707
|
const firstChild = children[0]
|
|
692
708
|
if (isAttrs(firstChild)) {
|
|
693
|
-
|
|
709
|
+
if (typeof firstChild.style === 'string') {
|
|
710
|
+
firstChild.style = [stringifyStyle(style), stringifyStyle(firstChild.style)].join(';')
|
|
711
|
+
} else {
|
|
712
|
+
firstChild.style = Object.assign(style, firstChild.style)
|
|
713
|
+
}
|
|
694
714
|
} else {
|
|
695
715
|
children.unshift({ style })
|
|
696
716
|
}
|
|
697
717
|
return h(tag, ...children)
|
|
698
718
|
}
|
|
719
|
+
|
|
720
|
+
const stringifyStyle = style =>
|
|
721
|
+
typeof style === 'string'
|
|
722
|
+
? style
|
|
723
|
+
: Object.keys(style).map(prop => `${prop}:${style[prop]}`).join(';')
|