@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 CHANGED
@@ -1,7 +1,90 @@
1
- # fntags
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
- > not your usual f'n framework
5
+ ---
4
6
 
5
- ### [Documentation](https://srfnstack.github.io/fntags)
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
- Check the docs to get started.
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.2",
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": "^6.8.0",
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] ? Promise.resolve(moduleCache[filePath]) : import(filePath).then(m => { moduleCache[filePath] = m })
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
- * If an argument is a non-node object it is considered an attributes object, attributes are combined with Object.assign in the order received.
4
- * All standard html attributes can be passed, as well as any other property.
5
- * Strings are added as attributes via setAttribute, functions are added as event listeners, other types are set as properties.
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 attrs and children for the element
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
- const tag = arguments[0]
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(arguments[firstChildIdx])) {
25
- const attrs = arguments[firstChildIdx]
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 < arguments.length; i++) {
32
- const child = arguments[i]
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 precreating a dom element with the entire template structure and cloning and populating
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 the same elements are created many times.
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
- const placeholders = { }
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.selector
74
+ let selector = element.__fnselector
65
75
  if (!selector) {
66
- selector = `fntpl-${prop}-${id++}`
67
- element.selector = selector
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 rendered = templateFn(initContext).cloneNode(true)
87
+ const compiled = templateFn(initContext).cloneNode(true)
78
88
  return ctx => {
79
- const clone = rendered.cloneNode(true)
89
+ const clone = compiled.cloneNode(true)
80
90
  for (const selectorClass in placeholders) {
81
- const targetElement = clone.classList.contains(selectorClass) ? clone : clone.getElementsByClassName(selectorClass)[0]
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. If you perform modifications to the object, be sure to set the value
112
- * when you're done or the changes won't be reflected correctly.
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
- return ctx.state.bindAs(element, update)
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
- throw new Error('Duplicate keys in a bound array are not allowed.')
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.selector && element.className) {
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
- children[0].style = Object.assign(style, firstChild.style)
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(';')