├── .gitignore ├── bun.lockb ├── ssr ├── index.js ├── expr.js ├── fn.js ├── compile.js └── render.js ├── test ├── compile.js ├── client │ ├── loops.html │ ├── basics.html │ ├── basics.nue │ ├── img │ │ ├── paint.svg │ │ ├── box.svg │ │ ├── spray.svg │ │ ├── pin.svg │ │ └── glue.svg │ ├── test.css │ └── loops.nue ├── parse.test.js ├── compile.test.js └── render.test.js ├── package.json ├── .prettierrc.yaml ├── scripts └── minify.js ├── LICENSE ├── src ├── if.js ├── for.js └── nue.js ├── CONTRIBUTING.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .idea 5 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lorenzofox3/nuejs/master/bun.lockb -------------------------------------------------------------------------------- /ssr/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // for server-side rendering (SSG) 4 | export { parse, render, parseFile, renderFile } from './render.js' 5 | 6 | // for client-side / reactive apps 7 | export { compile, compileFile } from './compile.js' 8 | 9 | -------------------------------------------------------------------------------- /test/compile.js: -------------------------------------------------------------------------------- 1 | 2 | import { compileFile } from '..' 3 | 4 | for (const name of ['basics', 'loops']) { 5 | const to = `dist/${name}.js` 6 | await compileFile(`client/${name}.nue`, to) 7 | console.log('created', `test/${to}`) 8 | } 9 | -------------------------------------------------------------------------------- /test/client/loops.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | -------------------------------------------------------------------------------- /test/client/basics.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Basics

5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "nuejs-core", 4 | "main": "ssr/index.js", 5 | "version": "0.1.2", 6 | "scripts": { 7 | "test": "cd test && bun test", 8 | "minify": "bun scripts/minify.js", 9 | "compile": "cd test && bun compile.js" 10 | }, 11 | "dependencies": { 12 | "htmlparser2": "^9.0.0" 13 | }, 14 | "engines": { 15 | "bun": ">= 1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | tabWidth: 2 3 | useTabs: false 4 | semi: false 5 | singleQuote: true 6 | quoteProps: 'as-needed' 7 | trailingComma: 'es5' 8 | bracketSpacing: true 9 | arrowParens: 'avoid' 10 | requirePragma: false 11 | insertPragma: false 12 | proseWrap: 'preserve' 13 | htmlWhitespaceSensitivity: 'css' 14 | endOfLine: 'lf' 15 | embeddedLanguageFormatting: 'auto' 16 | singleAttributePerLine: false 17 | -------------------------------------------------------------------------------- /scripts/minify.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | Minifies source files into a single executable: dist/nue.js 5 | */ 6 | 7 | import { promises as fs } from 'node:fs' 8 | import { join } from 'node:path' 9 | 10 | 11 | try { 12 | let Bundler = process.isBun ? Bun : await import('esbuild') 13 | 14 | // recursive forces creation 15 | await fs.mkdir('dist', { recursive: true }) 16 | 17 | // minify (with Bun or esbuild) 18 | await Bundler.build({ 19 | entryPoints: [join('src', 'nue.js')], 20 | outdir: 'dist', 21 | format: 'esm', 22 | minify: true, 23 | bundle: true, 24 | }) 25 | 26 | console.log('Minified Nue to dist/nue.js with', process.isBun ? 'Bun' : 'ESBuild') 27 | 28 | } catch (e) { 29 | console.log('No bundler found. Please install Bun or ESbuild') 30 | 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-present, Tero Piirainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/if.js: -------------------------------------------------------------------------------- 1 | 2 | // Internal use only 3 | 4 | import createApp from './nue.js' 5 | 6 | export default function(opts) { 7 | const { root, fn, fns, deps, ctx } = opts 8 | const blocks = [] 9 | var node = root 10 | var anchor 11 | var next 12 | 13 | function addBlock(node, fn) { 14 | opts.processAttrs(node) // run event handlers on the parent context 15 | const impl = createApp({ fns, dom: node }, ctx, deps, ctx) 16 | blocks.push(impl) 17 | impl.fn = fn 18 | } 19 | 20 | addBlock(root, fn) 21 | 22 | while (node = node.nextElementSibling) { 23 | const val = node.getAttribute(':else-if') 24 | if (val) { 25 | addBlock(node, fns[val]) 26 | node.removeAttribute(':else-if') 27 | 28 | } else if (node.hasAttribute(':else')) { 29 | addBlock(node, () => true) 30 | node.removeAttribute(':else') 31 | 32 | } else { 33 | next = node 34 | break 35 | } 36 | } 37 | 38 | var _prev 39 | 40 | function update() { 41 | if (!anchor) { 42 | const wrap = root.parentElement 43 | anchor = new Text('') 44 | wrap.insertBefore(anchor, root) 45 | } 46 | 47 | const active = blocks.find(bl => bl.fn(ctx)) 48 | blocks.forEach(bl => bl == active ? bl.before(anchor) : bl.unmount()) 49 | } 50 | 51 | return { update, next } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /test/parse.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { parseFor, parseClass, setContext, setContextTo, parseExpr } from '../ssr/expr.js' 3 | 4 | 5 | // helper function to run multiple tests at once 6 | function runTests(fn, tests) { 7 | Object.keys(tests).slice(0, 100).forEach(key => { 8 | const val = fn(key) 9 | // console.info(val) 10 | expect(val).toEqual(tests[key]) 11 | }) 12 | } 13 | 14 | test('For loop expression', () => { 15 | runTests(parseFor, { 16 | 'el in items': [ "el", "_.items", "$index" ], 17 | 'el, i of cat.items': [ "el", "_.cat.items", "i" ], 18 | '{ name, age } in items': [[ "name", "age" ], "_.items", "$index"], 19 | '({ name, age }) in items': [[ "name", "age" ], "_.items", "$index"], 20 | '({ name, age } , $i) in items': [[ "name", "age" ], "_.items", "$i"], 21 | '[ name, age, $i ] in entries': [[ "name", "age" ], "_.entries", "$i", true], 22 | }) 23 | }) 24 | 25 | test('Class attributes', () => { 26 | runTests(parseClass, { 27 | 'active: foo && Date.now()': [ "_.foo && Date.now() && 'active '" ], 28 | 29 | 'is-active: isActive, danger: hasError()': 30 | [ "_.isActive && 'is-active '", "_.hasError() && 'danger '" ], 31 | }) 32 | }) 33 | 34 | test('Context (_.variable)', () => { 35 | runTests(setContext, { 36 | 'foo': '_.foo', 37 | "foo + 'yo foo'" : "_.foo + 'yo foo'", 38 | 'a/b + !foo': '_.a/_.b + !_.foo', 39 | 'location.href': 'location.href', 40 | '$foo + _bar + baz/5': '_.$foo + _._bar + _.baz/5', 41 | }) 42 | }) 43 | 44 | 45 | test('Expressions', () => { 46 | 47 | runTests(parseExpr, { 48 | 'Hey { name }': [ "'Hey '", '_.name' ], 49 | 'foo { alarm } is-alert': [ "'foo '", '_.alarm', "' is-alert'" ], 50 | 'is-cool { danger: hasError }': [ "'is-cool '", "_.hasError && 'danger '" ], 51 | 52 | '{ is-active: isActive } is-cool { danger: hasError }': [ 53 | "_.isActive && 'is-active '", 54 | "' is-cool '", 55 | "_.hasError && 'danger '" 56 | ] 57 | }) 58 | }) 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to Nue 3 | 4 | First and foremost: thank you for helping with Nue! ❤️❤️ 5 | 6 | 7 | ### Guidelines 8 | 9 | 1. **Most important** If you are adding a new feature, please _discuss it first_ by creating a new issue with the tag "New feature". You probably avoid doing redundant work, because not all features are automatically accepted. Nue JS strives for minimalism. 10 | 11 | 2. Features that add lots of new code, complexity, or several new/heavy NPM packages are most likely rejected. Particularly if the first rule wasn't applied. 12 | 13 | 3. Please add one fix/feature per pull request. Easier to accept and less potential merge conflicts. 14 | 15 | 3. Please add a test for every bug fix 16 | 17 | 3. Please write JavaScript (Not TypeScript) 18 | 19 | 20 | ### Formatting rules 21 | Please try to use the original style in the codebase. Do not introduce new rules or patterns. The most notable rules are: 22 | 23 | 1. No semicolons, because it's redundant 24 | 25 | 2. Strings with single quotes 26 | 27 | 3. Indent with two spaces 28 | 29 | 4. Prefer `==` over `===`. Only strict equality only when truly needed, which is rarely 30 | 31 | Nue is not using Prettier or ESLint because they will increase the project size to 40MB. A single `.prettierrc.csj` file is preferred on the root directory. Not sure if [Biome](//biomejs.dev/) is better. 32 | 33 | 34 | ### Nue JS codebase 35 | Nues JS codebase has two distinct parts: 36 | 37 | * The reactive client is under [src](src) directory 38 | * Server parts are under [ssr](ssr) directory (SSR: "Server Side Rendering") 39 | 40 | [Bun](//bun.sh) is the preferred test and development environment because it's noticeably faster than Node or Deno. 41 | 42 | 43 | ### Running tests 44 | Nue uses [Bun](//bun.sh) for running tests: 45 | 46 | 1. Go to root directory: `cd nuejs` 47 | 2. Run `bun test` 48 | 49 | 50 | ### Running client tests 51 | 52 | 1. Go to root directory: `cd nuejs` 53 | 2. Start a web server, for example: `python -m SimpleHTTPServer` 54 | 2. Open a test page. Either [basics][basics] or [loops][loops] 55 | 56 | [basics]: http://localhost:8000/test/client/basics.html 57 | [loops]: http://localhost:8000/test/client/loops.html 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /test/client/basics.nue: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 |
8 | 9 |
10 |

Ref: { $refs.email.placeholder }

11 | 12 | 13 | 18 |
19 | 20 |
21 |

Count: { count }

22 |

23 | 24 | 25 | 26 |

27 | 28 |

29 | 30 | 31 |

32 | 33 |
34 | 35 | 44 |
45 | 46 |
47 | 48 | 49 |

${ price }.00

50 | 51 | 52 | 🛒 In the bag 53 |
54 | 55 | 63 |
64 | 65 | 66 |
67 | 68 | 69 | 74 |
75 | 76 | 77 |
78 |

Inheritance tests

79 | 80 | 81 | 83 | 84 | 89 | 90 | 91 | 92 | 103 | 104 | -------------------------------------------------------------------------------- /test/client/img/paint.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/client/img/box.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/compile.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { compile, compileFile } from '../ssr/compile.js' 3 | 4 | 5 | /* 6 | Format of individual tests on the TESTS array: 7 | [ 8 | nue_source_code, 9 | compiled_code_match1, 10 | compiled_code_match2 11 | ] 12 | */ 13 | const TESTS = [ 14 | 15 | // basics 16 | ['
', '
', '_.item'], 17 | 18 | ['
{{ title }}
', '
', '_.title'], 19 | 20 | ['', '', '`_ || _.src`'], 21 | 22 | ['', 23 | '', '_._ || _.src'], 24 | [`{ time || '12:00' }`, ':0:', "[_.time || '12:00']"], 25 | 26 | // class name 27 | ['
{ flag ? "ok" : "fail"}
', 28 | '
:1:
', "['item ',_.isActive(_.el) && 'is-active ']"], 29 | 30 | // for loop 31 | ['{ foo }', 32 | ':1:', "[['foo','bar'], _.items, '$index']"], 33 | 34 | ['', 35 | ':for="0"', "['el', _.survey.sources, 'j']"], 36 | 37 | ['

', 38 | ':for="0"', "[['key','value'], Object.entries(_.person), 'i', true]"], 39 | 40 | 41 | // global script tag 42 | ['', 'const a = 10', 'lib = [\n]'], 43 | 44 | ['', 45 | '', 'class { s = 1\ng = 1 }'], 46 | 47 | // event handlers 48 | ['', '', '_.count++'], 49 | ['{ baz }', '@click="0"', "{ _.foo(); _.bar() }"], 50 | 51 | // click modifiers 52 | ['', 53 | '@click="0"', 'e.preventDefault();_.do.call(_, e);e.target.onclick = null'], 54 | 55 | ['', '@keyup="0"', "if (!['space'"], 56 | 57 | ['','@keyup="0"', "if (!['up'"], 58 | 59 | ['','@click="0"', "if (e.target != this)"], 60 | 61 | 62 | // event argument 63 | [``, '@click', "_.say('yo', e)"], 64 | [``, '@click', "_.say(e, 'yo')"], 65 | 66 | // newline 67 | ['\n', '@click="0"', '_.open.call'], 68 | 69 | ] 70 | 71 | function testOne([src, ...matches]) { 72 | const js = compile(src) 73 | const [ok1, ok2] = matches.map(m => js.includes(m)) 74 | 75 | // debug 76 | if (!ok1 || !ok2) console.info('Error in', src, '-->\n\n', js) 77 | 78 | // run tests 79 | expect(ok1).toBe(true) 80 | expect(ok2).toBe(true) 81 | } 82 | 83 | test('All compile tests', () => { 84 | TESTS.forEach(testOne) 85 | }) 86 | 87 | // good for testing a single thing with test.only() 88 | test('Unit test', () => { 89 | const last = TESTS.slice(-1)[0] 90 | testOne(last) 91 | }) 92 | 93 | 94 | -------------------------------------------------------------------------------- /test/client/test.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font-family: -apple-system, BlinkMacSystemFont; 4 | background-color: #f9f9f9; 5 | padding: 6%; 6 | } 7 | 8 | a { 9 | color: blue; 10 | cursor: pointer; 11 | } 12 | 13 | a:hover { 14 | text-decoration: underline; 15 | } 16 | 17 | h3, h4, h5 { 18 | margin: 0 0 .3em; 19 | } 20 | 21 | h4 { 22 | color: #789; 23 | } 24 | 25 | .card > h3:first-child { 26 | margin-bottom: 1em; 27 | } 28 | 29 | p { 30 | margin: 0 0 1.2em; 31 | color: #777; 32 | } 33 | 34 | .mb { 35 | margin-bottom: 1.2em; 36 | } 37 | 38 | hr { 39 | border-width: 0 0 1px; 40 | margin-bottom: 1em; 41 | } 42 | 43 | .icon { 44 | width: 5em; 45 | margin-bottom: 1em; 46 | } 47 | 48 | input { 49 | padding: .8em; 50 | font-family: inherit; 51 | font-size: 95%; 52 | } 53 | 54 | button { 55 | border: 1px solid #f9f9f9; 56 | background-color: #f2f2f2; 57 | font-family: inherit; 58 | padding: .3em 1em; 59 | border-radius: 8px; 60 | cursor: pointer; 61 | margin-right: .6em; 62 | font-weight: 500; 63 | } 64 | 65 | button:hover { 66 | border-color: #bbb; 67 | } 68 | 69 | button:active { 70 | box-shadow: 0 0 4px #ccc inset; 71 | transform: scale(0.97); 72 | } 73 | 74 | .primary { 75 | background-color: #2196f3; 76 | color: white; 77 | } 78 | 79 | .field { 80 | margin-bottom: 1.5em; 81 | display: block; 82 | } 83 | 84 | .field input { 85 | width: 100%; 86 | } 87 | 88 | .error input { 89 | border-color: red; 90 | } 91 | 92 | .is-fatal h5:after { 93 | content: " \01F525"; 94 | font-size: 120%; 95 | } 96 | 97 | .required h5:after { 98 | content: ' *'; 99 | color: orange; 100 | font-size: 110%; 101 | } 102 | 103 | .tag { 104 | background-color: #d0f3ff; 105 | margin-right: .6em; 106 | font-size: 90%; 107 | padding: .3em .6em; 108 | border-radius: 5px; 109 | color: #21718b; 110 | font-weight: 500; 111 | } 112 | 113 | /* grid */ 114 | .grid { 115 | grid-template-columns: repeat( auto-fit, minmax(320px, 1fr)); 116 | display: grid; 117 | gap: 2.2em; 118 | } 119 | 120 | .card { 121 | background-color: white; 122 | box-shadow: 1px 1px .3em #ddd; 123 | border-radius: 4px; 124 | padding: 2em; 125 | max-width: 350px; 126 | } 127 | 128 | 129 | /* loop items */ 130 | .row { 131 | border-bottom: 1px solid #eee; 132 | align-items: center; 133 | display: flex; 134 | padding: .3em; 135 | font-size: 90%; 136 | } 137 | 138 | .row > * { 139 | flex: 1; 140 | } 141 | 142 | .right { 143 | text-align: right; 144 | } 145 | 146 | .fade-in { 147 | transition: .4s; 148 | max-height: 0; 149 | opacity: 0; 150 | } 151 | 152 | .faded-in { 153 | max-height: 40px; 154 | opacity: 1; 155 | } 156 | 157 | /* tabs */ 158 | .tabs { 159 | margin-bottom: 1em; 160 | display: flex; 161 | gap: 1em; 162 | } 163 | 164 | .selected { 165 | border-bottom: 3px solid black; 166 | } 167 | 168 | 169 | /* dark mode */ 170 | .dark { 171 | background-color: #222; 172 | color: #c9d1d9; 173 | } 174 | 175 | .dark h3 { 176 | color: white; 177 | } 178 | -------------------------------------------------------------------------------- /src/for.js: -------------------------------------------------------------------------------- 1 | 2 | // Internal use only 3 | 4 | import createApp from './nue.js' 5 | 6 | export default function(opts) { 7 | const { root, fn, fns, deps, ctx } = opts 8 | var anchor, current, items, $keys, $index, is_object_loop, blocks = [] 9 | 10 | 11 | function createProxy(item) { 12 | return new Proxy({}, { 13 | get(__, key) { 14 | if (is_object_loop) { 15 | const i = $keys.indexOf(key) 16 | if (i >= 0) return item[i] 17 | } 18 | return key === $keys ? item : 19 | $keys.includes(key) ? item[key] : 20 | key == $index ? items.indexOf(item) : 21 | ctx[key] 22 | } 23 | }) 24 | } 25 | 26 | function mountItem(item, i, arr, first) { 27 | const block = createApp({ fns, dom: root.cloneNode(true) }, createProxy(item), deps, ctx) 28 | 29 | blocks[first ? 'unshift' : 'push'](block) 30 | block.before(first || anchor) 31 | 32 | // oninsert callback for transition/animation purposes 33 | ctx.oninsert?.call(ctx, block.$el, item, { 34 | index: i, 35 | is_repaint: !!arr, 36 | is_first: !i, 37 | is_last: i == items.length -1, 38 | items 39 | }) 40 | 41 | } 42 | 43 | function repaint() { 44 | blocks.forEach(el => el.unmount()) 45 | blocks = [] 46 | items.forEach(mountItem) 47 | } 48 | 49 | function arrProxy(arr) { 50 | const { unshift, splice, push, sort, reverse } = arr 51 | 52 | return Object.assign(arr, { 53 | 54 | // adding 55 | push(item) { 56 | push.call(items, item) 57 | mountItem(item, items.length - 1) 58 | }, 59 | 60 | unshift(item) { 61 | unshift.call(items, item) 62 | mountItem(item, 0, null, blocks[0].$el) 63 | }, 64 | 65 | // sorting 66 | sort(fn) { 67 | sort.call(items, fn) 68 | repaint() 69 | }, 70 | 71 | reverse() { 72 | reverse.call(items) 73 | repaint() 74 | }, 75 | 76 | // removing 77 | splice(i, len) { 78 | blocks.slice(i, i + len).forEach(el => el.unmount()) 79 | blocks.splice(i, len) 80 | splice.call(items, i, len) 81 | }, 82 | 83 | shift() { arr.splice(0, 1) }, 84 | 85 | pop() { arr.splice(arr.length -1, 1) }, 86 | 87 | // handy shortcut for a common operation 88 | remove(item) { 89 | const i = items.indexOf(item) 90 | if (i >= 0) arr.splice(i, 1) 91 | } 92 | }) 93 | } 94 | 95 | // update function 96 | function update() { 97 | var arr 98 | [$keys, arr, $index, is_object_loop] = fn(ctx) 99 | 100 | if (items) { 101 | // change of current array --> repaint 102 | if (arr !== current) { 103 | items = arrProxy(arr); repaint(); current = arr 104 | } 105 | return blocks.forEach(el => el.update()) 106 | } 107 | 108 | if (arr) { 109 | // anchor 110 | const p = root.parentElement 111 | anchor = new Text('') 112 | p.insertBefore(anchor, root) 113 | 114 | p.removeChild(root) 115 | items = arrProxy(arr) 116 | arr.forEach(mountItem) 117 | current = arr 118 | } 119 | } 120 | 121 | return { update } 122 | 123 | } -------------------------------------------------------------------------------- /test/client/img/spray.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ssr/expr.js: -------------------------------------------------------------------------------- 1 | 2 | const VARIABLE = /(^|[\-\+\*\/\!\s\(\[]+)([\$a-z_]\w*)\b/g 3 | const STRING = /('[^']+'|"[^"]+")/ 4 | const EXPR = /\{([^{}]+)\}/g 5 | 6 | 7 | // https://github.com/vuejs/core/blob/main/packages/shared/src/globalsWhitelist.ts 8 | const RESERVED = ` 9 | alert arguments Array as BigInt Boolean confirm console delete Date decodeURI decodeURIComponent 10 | document else encodeURI encodeURIComponent false get history if in Infinity instanceof 11 | Intl isFinite isNaN JSON localStorage location Map Math NaN navigator new null Number 12 | number Object of parseFloat parseInt prompt RegExp sessionStorage Set String this 13 | throw top true typeof undefined window $event`.trim().split(/\s+/) 14 | 15 | 16 | // foo -> _.foo 17 | export function setContextTo(expr) { 18 | return expr.replace(VARIABLE, function(match, prefix, varname, i) { 19 | const is_reserved = RESERVED.includes(varname) 20 | return prefix + (is_reserved ? varname == '$event' ? 'e' : varname : '_.' + varname.trimStart()) 21 | }) 22 | } 23 | 24 | // 'the foo' + foo -> 'the foo' + _.foo 25 | export function setContext(expr) { 26 | return ('' + expr).split(STRING).map((el, i) => i % 2 == 0 ? setContextTo(el) : el).join('') 27 | } 28 | 29 | 30 | // style="color: blue; font-size: { size }px" 31 | export function parseExpr(str, is_style) { 32 | const ret = [] 33 | 34 | str.trim().split(EXPR).map((str, i) => { 35 | 36 | // normal string 37 | if (i % 2 == 0) { 38 | if (str) ret.push(`'${str}'`) 39 | 40 | // Object: { is-active: isActive() } 41 | } else if (isObject(str.trim())) { 42 | const vals = parseClass(str) 43 | ret.push(...vals) 44 | 45 | } else { 46 | ret.push(setContext(str.trim())) 47 | } 48 | 49 | }) 50 | 51 | return ret 52 | } 53 | 54 | function isObject(str) { 55 | const i = str.indexOf(':') 56 | return i > 0 && !str.includes('?') && /^[\w-]+$/.test(str.slice(0, i)) 57 | } 58 | 59 | /* 60 | is-active: isActive, danger: hasError 61 | --> [_.isActive && 'is-active', _.hasError && 'danger'] 62 | */ 63 | export function parseClass(str) { 64 | return str.split(',').map(el => { 65 | const [name, expr] = el.trim().split(':').map(el => el.trim()) 66 | return setContextTo(expr) + ' && ' + (name[0] == "'" ? name : `'${name} '`) 67 | }) 68 | } 69 | 70 | /* { color: 'blue', } 71 | export function parseStyle(str) { 72 | return str.split(',').map(el => { 73 | const [name, expr] = el.trim().split(':').map(el => el.trim()) 74 | const exp = setContextTo(expr) 75 | return exp + ' && ' + `'${name}: ' + (${exp})` 76 | }) 77 | } 78 | */ 79 | 80 | 81 | function parseObjectKeys(str) { 82 | const i = str.indexOf('}') + 1 83 | if (!i) throw `Parse error: ${str}` 84 | const keys = parseKeys(str.slice(0, i)) 85 | const j = str.slice(i).indexOf(',') 86 | const index = j >= 0 ? str.slice(i + j + 1).trim() : undefined 87 | return { keys, index } 88 | } 89 | 90 | function parseKeys(str) { 91 | return str.trim().slice(1, -1).split(',').map(el => el.trim()) 92 | } 93 | 94 | export function parseFor(str) { 95 | let [prefix, _, expr ] = str.trim().split(/\s+(in|of)\s+/) 96 | prefix = prefix.replace('(', '').replace(')', '').trim() 97 | expr = setContextTo(expr) 98 | 99 | // Object.entries() 100 | if (prefix[0] == '[') { 101 | const keys = parseKeys(prefix) 102 | return [ keys.slice(0, 2), expr, keys[2] || '$index', true ] 103 | 104 | // Object deconstruction 105 | } else if (prefix[0] == '{') { 106 | const { keys, index } = parseObjectKeys(prefix) 107 | return [ keys, expr, index || '$index' ] 108 | 109 | // Normal loop variable 110 | } else { 111 | const [ key, index='$index' ] = prefix.split(/\s?,\s?/) 112 | return [ key, expr, index ] 113 | } 114 | } 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /ssr/fn.js: -------------------------------------------------------------------------------- 1 | 2 | import { parseDocument, DomUtils } from 'htmlparser2' 3 | 4 | // shared by render.js and compile.js 5 | 6 | export const STD = 'a abbr acronym address applet area article aside audio b base basefont bdi bdo big\ 7 | blockquote body br button canvas caption center circle cite clipPath code col colgroup data datalist\ 8 | dd defs del details dfn dialog dir div dl dt ellipse em embed fieldset figcaption figure font footer\ 9 | foreignObject form frame frameset g head header hgroup h1 h2 h3 h4 h5 h6 hr html i iframe image img\ 10 | input ins kbd keygen label legend li line link main map mark marker mask menu menuitem meta meter\ 11 | nav noframes noscript object ol optgroup option output p param path pattern picture polygon polyline\ 12 | pre progress q rect rp rt ruby s samp script section select small source span strike strong style sub\ 13 | summary sup svg switch symbol table tbody td template text textarea textPath tfoot th thead time\ 14 | title tr track tspan tt u ul use var video wbr'.split(' ') 15 | 16 | const SVG = 'animate animateMotion animateTransform circle clipPath defs desc ellipse\ 17 | feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting\ 18 | feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB feFuncG feFuncR\ 19 | feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting\ 20 | feSpotLight feTile feTurbulence filter foreignObject g hatch hatchpath image line linearGradient\ 21 | marker mask metadata mpath path pattern polygon polyline radialGradient rect set stop style svg\ 22 | switch symbol text textPath title tspan use view'.split(' ') 23 | 24 | STD.push(...SVG) 25 | 26 | const BOOLEAN = 'allowfullscreen async autofocus autoplay checked controls default\ 27 | defer disabled formnovalidate hidden ismap itemscope loop multiple muted nomodule\ 28 | novalidate open playsinline readonly required reversed selected truespeed'.split(/\s+/) 29 | 30 | export function isBoolean(key) { 31 | return BOOLEAN.includes(key) 32 | } 33 | 34 | export function getComponentName(root) { 35 | const { attribs } = root 36 | const name = attribs['@name'] || attribs['data-name'] || attribs.id 37 | delete attribs['@name'] 38 | return name 39 | } 40 | 41 | export function selfClose(str) { 42 | return str.replace(/\/>/g, function(match, i) { 43 | const tag = str.slice(str.lastIndexOf('<', i), i) 44 | const name = /<([\w-]+)/.exec(tag) 45 | return `>` 46 | }) 47 | } 48 | 49 | export function walk(node, fn) { 50 | fn(node) 51 | node = node.firstChild 52 | let next = null 53 | while (node) { 54 | next = node.nextSibling 55 | walk(node, fn) 56 | node = next 57 | } 58 | } 59 | 60 | export function objToString(obj, minify) { 61 | if (!obj) return null 62 | 63 | const prefix = minify ? '' : ' ' 64 | const keys = Object.keys(obj) 65 | const ret = ['{'] 66 | 67 | keys.forEach((key, i) => { 68 | const comma = i + 1 < keys.length ? ',' : '' 69 | const val = obj[key] 70 | if (val) ret.push(`${prefix}${key}: ${quote(val)}${comma}`) 71 | }) 72 | 73 | ret.push('}') 74 | return ret.join(minify ? '' : '\n') 75 | } 76 | 77 | function quote(val) { 78 | return val.endsWith('}') || val.endsWith(']') || 1 * val ? val : `'${val}'` 79 | } 80 | 81 | export function mkdom(src) { 82 | const dom = parseDocument(selfClose(src)) 83 | walk(dom, (el) => { if (el.type == 'comment') DomUtils.removeElement(el) }) // strip comments 84 | return dom 85 | } 86 | 87 | // render.js only 88 | const isJS = val => val?.constructor === Object || Array.isArray(val) || typeof val == 'function' 89 | 90 | 91 | // exec('`font-size:${_.size + "px"}`;', data) 92 | export function exec(expr, data={}) { 93 | const fn = new Function('_', 'return ' + expr) 94 | 95 | try { 96 | const val = fn(data) 97 | return val == null ? '' : isJS(val) ? val : '' + val 98 | 99 | } catch (e) { 100 | console.info('🔻 expr', expr, e) 101 | return '' 102 | } 103 | } 104 | 105 | 106 | function isStdAttr(name) { 107 | return ['style', 'class', 'id', 'hidden'].includes(name) || name.startsWith('data-') 108 | } 109 | 110 | export function mergeAttribs(to, from) { 111 | for (const name in from) { 112 | if (isStdAttr(name)) { 113 | let val = from[name] 114 | const toval = to[name] 115 | if (toval && ['class'].includes(name)) val += ' ' + toval 116 | to[name] = val 117 | } 118 | } 119 | } 120 | 121 | 122 | -------------------------------------------------------------------------------- /test/client/loops.nue: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 |

Conditional loops

14 |

Name: { name }

15 |

Else if

16 |

Else

17 |
18 | 19 |
20 |

Tabs test

21 | 22 |
23 | { name } 25 |
26 | 27 |

Index: { index }

28 | 29 | 37 | 38 |
39 | 40 | 41 |
42 |

Loop binding

43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 58 | 59 |
60 | 61 |
62 |

{ title }

63 |

{ desc }

64 |
65 | 66 |
67 | 68 |

Nested loop

69 | 70 |
71 |
{ $i }: { title } { star }
72 | { tag } ({ i }{ star }) 73 |
74 | 75 | 84 | 85 |
86 | 87 |
88 | 89 | 90 | 91 |
Loop items
92 | 93 | 94 | 98 | 99 |
100 | 101 |
102 |

{ item.title }

103 |

{ desc }

104 | 105 | 110 |
111 | 112 | 113 |
114 | 115 |

Object loop

116 | 117 |

118 | 119 |

120 | { i } 121 | 122 | { value } 123 |

124 | 125 | 138 |
139 | 140 |
141 |

Animation

142 |

143 | 144 | 145 | 146 | 147 |

148 | 149 | 150 |

151 | 152 | Remove 153 |

154 | 155 | 179 | 180 |
181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /test/client/img/pin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Nue logo 5 | 6 | 7 | [Documentation](//nuejs.org/docs/nuejs/) • 8 | [Examples](//nuejs.org/docs/nuejs/examples/) • 9 | [Getting started](//nuejs.org/docs/nuejs/getting-started.html) 10 | [Rethinking reactivity](//nuejs.org/blog/blog/rethinking-reactivity/) • 11 | 12 | 13 | # What is Nue JS? 14 | 15 | Nue JS is an exceptionally small (2.3kb minzipped) JavaScript library for building web interfaces. It is the core of the upcoming [Nue toolset](//nuejs.org/tools/). It’s like **Vue.js, React.js**, or **Svelte** but there are no hooks, effects, props, portals, watchers, provides, injects, suspension, or other unusual abstractions on your way. Learn the basics of HTML, CSS, and JavaScript and you are good to go. 16 | 17 | 18 | ## Build user interfaces with cleaner code 19 | With Nue your UI code is cleaner and usually smaller: 20 | 21 | ![The difference in coding style](https://nuejs.org/docs/img/react-listbox.jpg?1) 22 | 23 | It's not unusual to see [2x-10x differences](//nuejs.org/compare/component.html) in the amount of code you need to write. 24 | 25 | 26 | ## "It's just HTML" 27 | Nue uses an HTML-based template syntax: 28 | 29 | ``` html 30 |
31 | 32 | 37 |
38 | ``` 39 | 40 | While React and JSX claim to be "Just JavaScript", Nue can be thought of as "Just HTML". Nue is perfect for [UX developers][divide] focusing on interaction design, accessibility, and user experience. 41 | 42 | 43 | ## Built to scale 44 | Three reasons why Nue scales extremely well: 45 | 46 | 1. [Minimalism](//nuejs.org/why/#minimalism), a hundred lines of code is easier to scale than a thousand lines of code 47 | 48 | 1. [Separation of concerns](//nuejs.org//why/#soc), easy-to-understand code is easier to scale than "spaghetti code" 49 | 50 | 1. **Separation of talent**, when UX developers focus on the [front of the frontend][back] and JS/TS developers focus on the back of the frontend your team skills are optimally aligned: 51 | 52 | ![The best results are gained when UX developers and JavaScript developers work together without overlaps](https://nuejs.org/docs/img/ux-developer-big.png) 53 | 54 | 55 | 56 | ## Reactive, hybrid, and isomorphic 57 | Nue has a rich component model and it allows you to create all kinds of applications using different kinds of components: 58 | 59 | 1. [Server components](//nuejs.org/docs/nuejs/server-components.html) are rendered on the server. They help you build content-focused websites that load faster without JavaScript and are crawled by search engines. 60 | 61 | 2. [Reactive components](//nuejs.org/docs/nuejs/reactive-components.html) are rendered on the client. They help you build dynamic islands or single-page applications. 62 | 63 | 3. [Hybrid components](//nuejs.org/docs/nuejs/isomorphic-components.html#hybrid) are partly rendered on the server side, and partly on the client side. These components help you build reactive, SEO-friendly components like video tags or image galleries. 64 | 65 | 3. [Universal components](//nuejs.org/docs/nuejs/isomorphic-components.html) are used identically on both server- and client side. 66 | 67 | 68 | 69 | ## UI library files 70 | Nue allows you to define multiple components on a single file. This is a great way to group related components together and simplify dependency management. 71 | 72 | 73 | ``` html 74 | 75 | 78 | 79 | 80 |
81 | ... 82 |
83 | 84 | 85 |
86 | ... 87 |
88 | 89 | 90 | 93 | ``` 94 | 95 | With library files, your filesystem hierarchy looks cleaner and you need less boilerplate code to tie connected pieces together. They help in packaging libraries for others. 96 | 97 | 98 | ## Simpler tooling 99 | Nue JS comes with a simple `render` function for server-side rendering and a `compile` function to generate components for the browser. There is no need for toolchains like Webpack or Vite to hijack your natural workflow. Just import Nue to your project and you are good to go. 100 | 101 | You can of course use a bundler on the business model if your application becomes more complex with tons of dependencies. [Bun](//bun.sh) and [esbuild](//esbuild.github.io/) are great options. 102 | 103 | 104 | ## Use cases 105 | Nue JS is a versatile tool that supports both server- and client-side rendering and helps you build both content-focused websites and reactive single-page applications. 106 | 107 | 1. **UI library development** Create reusable components for reactive frontends or server-generated content. 108 | 109 | 2. **Progressive enhancement** Nue JS is a perfect micro library to enhance your content-focused website with dynamic components or "islands" 110 | 111 | 3. **Static website generators** Just import it into your project and you are ready to render. No bundlers are needed. 112 | 113 | 4. **Single-page applications** Build simpler and more scalable apps together with an upcoming *Nue MVC*- project. 114 | 115 | 5. **Templating** Nue is a generic tool to generate your websites and HTML emails. 116 | 117 | 118 | [fourteen]: https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work#tcp_slow_start_14kb_rule 119 | 120 | [divide]: https://css-tricks.com/the-great-divide/ 121 | 122 | [back]: https://bradfrost.com/blog/post/front-of-the-front-end-and-back-of-the-front-end-web-development/ 123 | 124 | 125 | -------------------------------------------------------------------------------- /ssr/compile.js: -------------------------------------------------------------------------------- 1 | 2 | import { mkdom, getComponentName, isBoolean, walk, objToString, STD } from './fn.js' 3 | import { parseExpr, parseFor, setContext, setContextTo } from './expr.js' 4 | import { promises as fs } from 'node:fs' 5 | import { DomUtils } from 'htmlparser2' 6 | import { dirname } from 'node:path' 7 | const { getOuterHTML, getInnerHTML, removeElement } = DomUtils 8 | 9 | function compileNode(root) { 10 | const expr = [] 11 | 12 | // push expression 13 | function push(fn, is_handler) { 14 | const len = expr.length 15 | expr.push({ fn, is_handler }) 16 | return '' + len 17 | } 18 | 19 | walk(root, function(node) { 20 | const { attribs={}, tagName } = node 21 | const content = node.data 22 | 23 | if (node.type == 'comment' || attribs.server || tagName == 'noscript') removeElement(node) 24 | 25 | // attributes 26 | for (let key in attribs) { 27 | const val = attribs[key] 28 | const has_expr = val.includes('{') 29 | 30 | // class="{}" --> :class="{}" 31 | if (key[0] != ':' && has_expr) { 32 | delete attribs[key] 33 | key = ':' + key 34 | } 35 | 36 | // after above clause 37 | const char = key[0] 38 | 39 | // :disabled -> $disabled 40 | if (char == ':' && isBoolean(key.slice(1))) { 41 | delete attribs[key] 42 | key = '$' + key.slice(1) 43 | attribs[key] = val 44 | } 45 | 46 | // event handler 47 | if (char == '@') { 48 | const { name, body } = getEventHandler(key, val) 49 | 50 | if (body) { 51 | delete attribs[key] 52 | attribs[name] = push(body, true) 53 | } 54 | 55 | // for expression 56 | } else if (key == ':for') { 57 | attribs[key] = push(compileLoop(val)) 58 | 59 | // attributes 60 | } else if (':$'.includes(char) && val && key != ':is') { 61 | const expr = has_expr ? arrwrap(parseExpr(val)) : setContext(val) 62 | attribs[key] = push(expr) 63 | } 64 | } 65 | 66 | // The { content } --> :1: 67 | const _attr = node.parentNode?.attribs || {} 68 | 69 | if (!_attr[':pre'] && content?.includes('{')) { 70 | const html = getHTML(content) 71 | const i = push(html ? setContext(html) : arrwrap(parseExpr(content))) 72 | if (html) _attr[':html'] = '' + i 73 | node.data = html ? '' : `:${i}:` 74 | } 75 | }) 76 | 77 | const fns = expr.map(el => { 78 | return el.is_handler ? `(_,e) => { ${el.fn} }` : `_ => ${el.fn}` 79 | }) 80 | 81 | return { tmpl: getOuterHTML(root).replace(/\s{2,}/g, ' '), fns } 82 | } 83 | 84 | const quote = str => `'${str}'` 85 | const arrwrap = str => '[' + str + ']' 86 | 87 | function getHTML(str) { 88 | str = str.trim() 89 | if (str.startsWith('{{') && str.endsWith('}}')) { 90 | return str.slice(2, -2) 91 | } 92 | } 93 | 94 | export function compileLoop(str) { 95 | const [key, expr, index, is_object] = parseFor(str) 96 | const keys = Array.isArray(key) ? '[' + key.map(quote) + ']' : quote(key) 97 | 98 | return '[' + [keys, expr, quote(index)].join(', ') + (is_object ? ', true' : '') + ']' 99 | } 100 | 101 | 102 | // event handlers 103 | const MODIFIERS = { 104 | stop: 'e.stopPropagation()', 105 | prevent: 'e.preventDefault()', 106 | self: 'if (e.target != this) return', 107 | } 108 | 109 | const KEY_ALIAS = { 110 | enter: ['return'], 111 | delete: ['backspace'], 112 | esc: ['escape'], 113 | space: [' ', 'spacebar', 'space bar'], 114 | up: ['arrowup'], 115 | down: ['arrowdown'], 116 | left: ['arrowleft'], 117 | right: ['arrowright'], 118 | } 119 | 120 | function getModifiers(name, mods) { 121 | let keycode = '' 122 | 123 | const ret = mods.map((key) => { 124 | const mod = MODIFIERS[key] 125 | if (!mod && key != 'once') keycode = key 126 | return mod 127 | 128 | }).filter(el => !!el) 129 | 130 | if (name.startsWith('@key') && keycode) { 131 | const code = keycode.replace('-', '') 132 | const codes = [`'${code}'`] 133 | const alias = KEY_ALIAS[code] 134 | if (alias) alias.forEach(code => codes.push(`'${code}'`)) 135 | ret.unshift(`if (![${codes.join(',')}].includes(e.key.toLowerCase())) return`) 136 | } 137 | 138 | return ret 139 | } 140 | 141 | // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values 142 | export function getEventHandler(key, val) { 143 | const [name, ...mods] = key.split('.') 144 | const is_expr = /\W/.exec(val) 145 | 146 | const handler = is_expr ? setContextTo(val) : `_.${val}.call(_, e)` 147 | const els = getModifiers(name, mods) 148 | 149 | els.push(handler) 150 | if (mods.includes('once')) els.push(`e.target.on${name.slice(1)} = null`) 151 | return { name, body: els[1] ? '{' + els.join(';') + '}' : els[0] } 152 | } 153 | 154 | /* 155 | function toKebabCase(str) { 156 | return str.replace(/([A-Z])/, (m, _, i) => '-' + m.toLowerCase()) 157 | } 158 | */ 159 | 160 | function getJS(nodes) { 161 | const scripts = nodes.filter(el => el.type == 'script') 162 | const js = scripts.map(getInnerHTML) 163 | scripts.forEach(removeElement) 164 | return js.join('\n') 165 | } 166 | 167 | 168 | function createComponent(node) { 169 | const name = getComponentName(node) 170 | 171 | if (STD.includes(name)) { 172 | throw `Invalid tag name: "${name}". Cannot use standard HTML5 tag names.` 173 | } 174 | 175 | const js = getJS(node.children) 176 | 177 | // must be after getJS() 178 | const { tmpl, fns } = compileNode(node) 179 | 180 | return objToString({ 181 | name, 182 | tagName: node.tagName, 183 | tmpl: tmpl.replace(/\n/g, ''), 184 | Impl: js && `class { ${js} }`, 185 | fns: fns[0] && `[\n ${fns.join(',\n ')}\n ]` 186 | }) 187 | } 188 | 189 | export function parse(src) { 190 | const { children } = mkdom(src) 191 | const components = children.filter(el => el.type == 'tag').map(el => createComponent(el)) 192 | const js = getJS(children) 193 | return { js, components } 194 | } 195 | 196 | export function compile(src) { 197 | const { js, components } = parse(src) 198 | return [ js, 199 | 'export const lib = [', components.join(',') + ']', 200 | 'export default lib[0]' 201 | 202 | ].join('\n') 203 | } 204 | 205 | // optional dest 206 | export async function compileFile(path, dest) { 207 | const template = await fs.readFile(path, 'utf-8') 208 | const js = compile(template) 209 | if (dest) { 210 | const destDir = dirname(dest) 211 | await fs.mkdir(destDir, { recursive: true }) 212 | await fs.writeFile(dest, js) 213 | } 214 | return js 215 | } 216 | -------------------------------------------------------------------------------- /test/client/img/glue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/render.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { parse, render } from '../ssr/render.js' 3 | 4 | 5 | // helper function to run multiple tests at once 6 | function runTests(tests, data) { 7 | for (const tmpl in tests) { 8 | const html = render(tmpl, data) 9 | const val = tests[tmpl] 10 | expect(html).toBe(val) 11 | } 12 | } 13 | 14 | function debug(tmpl, data) { 15 | console.info(render(tmpl, data)) 16 | } 17 | 18 | 19 | test('Expressions', () => { 20 | runTests({ 21 | 'Hey': "Hey", 22 | '': "", 23 | '': "", 24 | 'Hey': "Hey", 25 | '': ``, 26 | 27 | // skip event attributes 28 | '': '', 29 | 30 | '': '', 31 | 32 | // HTML 33 | '

{ title }

': '

Hey <em>!</em>

', 34 | '

{{ title }}

': '

Hey !

', 35 | '

': '

Hey !

', 36 | 37 | }, { 38 | type: 'bold', 39 | date: new Date('2000-01-01'), 40 | title: 'Hey !', 41 | }) 42 | }) 43 | 44 | test('Comments', () => { 45 | runTests({ 'World': 'World' }) 46 | runTests({ 'World': 'World' }) 47 | }) 48 | 49 | test('Conditionals', () => { 50 | 51 | runTests({ 52 | '{ val }': '', 53 | '': '', 54 | '': '', 55 | '
No

Yes

': '

Yes

', 56 | '{ val }': 'A', 57 | '
NoYesNo
': '
Yes
', 58 | '
Hey': '
', 59 | '
Yes': '
Yes
', 60 | }, { 61 | css: 'body { font-family: 100; }', 62 | am: 100, 63 | val: 'A', 64 | }) 65 | }) 66 | 67 | test('Methods and variables', () => { 68 | runTests({ 69 | '{ bg() }': '/bg', 70 | '{ bg }': '/bg', 71 | '{ foo }': '1', 72 | }) 73 | }) 74 | 75 | test('Class and style', () => { 76 | runTests({ 77 | '': 78 | '', 79 | 80 | '': 81 | '', 82 | 83 | }, { 84 | color: '#ccc', 85 | thing: 'thing', 86 | }) 87 | }) 88 | 89 | 90 | test('Loops', () => { 91 | 92 | runTests({ 93 | 94 | '

{ n }

': '

1

2

3

', 95 | 96 | '

{ i }: { key } = { value }

': 97 | '

0: name = Nick

1: email = nick@acme.org

2: age = 10

', 98 | 99 | '

{ i }. { name }

' : '

0. John

1. Alice

', 100 | 101 | // loop custom tag 102 | ' { value }' : 103 | 'JohnAlice', 104 | 105 | // loop slots 106 | '{ el.age }{name}: ' : 107 | 'John:22Alice:33', 108 | 109 | // successive loops 110 | '

{ x }

{ y }
': 111 | '

1

2

3

123
', 112 | 113 | }, { 114 | items: [ { name: 'John', age: 22 }, { name: 'Alice', age: 33 }], 115 | person: { name: 'Nick', email: 'nick@acme.org', age: 10 }, 116 | nums: [1, 2, 3], 117 | }) 118 | }) 119 | 120 | 121 | test('Custom tags', () => { 122 | const btn = '' 123 | 124 | runTests({ 125 | ' Test': 'Test', 126 | '