├── .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 | Set flag
30 | Unset
31 |
32 |
33 |
34 |
35 |
44 |
45 |
46 |
47 |
48 |
49 | ${ price }.00
50 |
51 | Add to bag
52 | 🛒 In the bag
53 |
54 |
55 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
74 |
75 |
76 |
77 |
91 |
92 |
93 | { label }
94 |
95 |
96 |
102 |
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 | ['{ el } ',
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 | ['Ignore ',
45 | ' ', 'class { s = 1\ng = 1 }'],
46 |
47 | // event handlers
48 | ['{ count } ', ':1: ', '_.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 `>${name[1]}>`
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 | Change values
118 |
119 |
120 | { i }
121 | { key }
122 | { value }
123 |
124 |
125 |
138 |
139 |
140 |
141 | Animation
142 |
143 | Push
144 | Unshift
145 | Reverse
146 | Reset
147 |
148 |
149 |
150 |
151 | { el.name }
152 | Remove
153 |
154 |
155 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
--------------------------------------------------------------------------------
/test/client/img/pin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 | 
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 |
33 | { title }
34 | { desc }
35 |
36 |
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 | 
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 |
91 | ...
92 |
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 | '{ date.getDay() } ': `6 `,
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 | '': '',
56 | '{ val } ': 'A ',
57 | 'No Yes No
': '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 | 'John Alice ',
104 |
105 | // loop slots
106 | '{ el.age } {name}: ' :
107 | 'John:22 Alice:33 ',
108 |
109 | // successive loops
110 | '':
111 | '',
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 = '{ label || "Press"} '
123 |
124 | runTests({
125 | ' Test ': 'Test ',
126 | ' ': ' ',
127 |
128 | // $attrs
129 | ' ':
130 | ' ',
131 | })
132 | })
133 |
134 |
135 | test('Advanced', () => {
136 |
137 | // return debug(' ', { page: 'Hello ' })
138 |
139 | runTests({
140 |
141 | // :attr (:bind works the same on server side)
142 | ' ': ' ',
143 |
144 | ' ': '\n \n ',
145 |
146 | // nue element
147 | // ' ':
148 | // '\n \n ',
149 |
150 | ' ': 'Hello ',
151 |
152 | // custom tag and slots
153 | '{{ am }}
{ person.name }
Parent ':
154 | '',
155 |
156 | }, {
157 | person: { name: 'Nick', age: 10 },
158 | page: 'Hello ',
159 | nums: [1, 2],
160 | val: 1
161 | })
162 |
163 | })
164 |
165 |
166 | const GLOBAL_SCRIPT = `
167 |
170 |
171 |
172 | { lower(title) }
173 |
178 |
`
179 |
180 | test('Global script', () => {
181 | const html = render(GLOBAL_SCRIPT, { title: 'Hey' })
182 | expect(html).toBe('hey
')
183 | })
184 |
185 |
186 | const IF_SIBLING = `
187 |
188 |
189 |
190 | { el.label }
191 |
192 |
193 | `
194 |
195 | test('If sibling', () => {
196 | const els = [{ label: 'First'}]
197 | const html = render(IF_SIBLING, { els })
198 | expect(html).toInclude('First')
199 | })
200 |
201 |
202 |
--------------------------------------------------------------------------------
/src/nue.js:
--------------------------------------------------------------------------------
1 |
2 | import For from './for.js'
3 | import If from './if.js'
4 |
5 | const CONTROL_FLOW = { ':if': If, ':for': For } // :if must be first
6 | const CORE_ATTR = ['class', 'style', 'id']
7 |
8 |
9 | /**
10 | * Creates a new application instance (aka. reactive component)
11 | *
12 | * https://nuejs.org/docs/nuejs/reactive-components.html
13 | *
14 | * @typedef {{ name: string, tagName: string, tmpl: string, ... }} Component
15 | * @param { Component } component - a (compiled) component instance to be mounted
16 | * @param { Object } [data = {}] - optional data or data model for the component
17 | * @param { Array } [deps = {}] - optional array of nested/dependant components
18 | * @param { Object } $parent - (for internal use only)
19 | */
20 | export default function createApp(component, data={}, deps=[], $parent={}) {
21 | const { Impl, tmpl, fns=[], dom, inner } = component
22 | const expr = []
23 |
24 | function walk(node) {
25 | const type = node.nodeType
26 |
27 | // text content
28 | if (type == 3) {
29 | const [_, i] = /:(\d+):/.exec(node.textContent.trim()) || []
30 | const fn = fns[i]
31 | if (fn) expr.push(_ => node.textContent = renderVal(fn(ctx)))
32 | }
33 |
34 | // element
35 | if (type == 1) {
36 |
37 | // loops & conditionals
38 | for (const key in CONTROL_FLOW) {
39 | const fn = fns[node.getAttribute(key)]
40 |
41 | // TODO: for + if work reactively on the same node
42 | if (key == ':if' && fn && node.getAttribute(':for')) {
43 |
44 | // if (true) -> quick continue
45 | if (fn(ctx)) continue
46 |
47 | // if (false) -> disable for loop
48 | else node.removeAttribute(':for')
49 | }
50 |
51 | if (fn) {
52 | node.removeAttribute(key)
53 | const ext = CONTROL_FLOW[key]({ root: node, fn, fns, deps, ctx, processAttrs })
54 | expr.push(ext.update)
55 | return ext
56 | }
57 | }
58 |
59 | const tagName = node.tagName.toLowerCase()
60 | const next = node.nextSibling
61 |
62 | // slot
63 | if (inner && tagName == 'slot') {
64 | inner.replace(node)
65 | return { next }
66 | }
67 |
68 | // custom child
69 | const child = deps.find(el => el.name == tagName)
70 |
71 | if (child) {
72 |
73 | // inner content
74 | if (node.firstChild) {
75 | const dom = document.createElement('_')
76 | dom.append(...node.childNodes)
77 | child.inner = createApp({ fns, dom }, ctx, deps)
78 | }
79 |
80 | const parent = createParent(node)
81 | const comp = createApp(child, data, deps, parent).mount(node)
82 |
83 | // Root node changes -> re-point to the new DOM element
84 | if (dom?.tagName.toLowerCase() == child.name) self.$el = comp.$el
85 |
86 | expr.push(_ => setAttrs(comp.$el, parent))
87 |
88 | // component refs
89 | self.$refs[node.getAttribute('ref') || tagName] = comp.impl
90 |
91 | return { next }
92 |
93 | } else {
94 | processAttrs(node)
95 | walkChildren(node, walk)
96 | }
97 | }
98 | }
99 |
100 | function processAttrs(node) {
101 | for (const el of [...node.attributes]) {
102 | processAttr(node, el.name, el.value)
103 | }
104 | }
105 |
106 | function setAttr(node, key, val) {
107 | const orig = node.getAttribute(key)
108 | if (orig !== val) node.setAttribute(key, val)
109 | }
110 |
111 | function processAttr(node, name, value) {
112 | if (name == 'ref' || name == 'name') self.$refs[value] = node
113 |
114 | const fn = fns[value]
115 | if (!fn) return
116 |
117 | const real = name.slice(1)
118 | const char = name[0]
119 |
120 | // remove special attributes
121 | if (':@$'.includes(char)) node.removeAttribute(name)
122 |
123 |
124 | // set all attributes from object
125 | if (real == 'attr') {
126 | return expr.push(_=> {
127 | for (const [name, val] of Object.entries(fn(ctx))) {
128 | setAttr(node, name, val === true ? '' : val)
129 | }
130 | })
131 | }
132 |
133 | if (char == ':') {
134 | if (real != 'bind') {
135 | // dynamic attributes
136 | expr.push(_ => {
137 | let val = fn(ctx)
138 | setAttr(node, real, renderVal(val))
139 | })
140 | }
141 | } else if (char == '@') {
142 | // event handler
143 | node[`on${real}`] = evt => {
144 | fn.call(ctx, ctx, evt)
145 | const up = $parent?.update || update
146 | up()
147 | }
148 | } else if (char == '$') {
149 | // boolean attribute
150 | expr.push(_ => {
151 | const flag = node[real] = !!fn(ctx)
152 | if (!flag) node.removeAttribute(real)
153 | })
154 | }
155 |
156 | // html
157 | if (real == 'html') expr.push(_=> node.innerHTML = fn(ctx))
158 |
159 | }
160 |
161 | function walkChildren(node, fn) {
162 | let child = node.firstChild
163 | while (child) {
164 | child = fn(child)?.next || child.nextSibling
165 | }
166 | }
167 |
168 | // node[key] --> dataset, node.title = '' -> undefined (to not override :bind)
169 | function getAttr(node, key) {
170 | const val = node.getAttribute(':' + key)
171 | const fn = fns[val]
172 | return fn ? fn(ctx) : ctx[val] || node.getAttribute(key) || node[key] || undefined
173 | }
174 |
175 | // non-core (id, class, style) attributes with primitive value
176 | function getAttrs(node) {
177 | const attr = {}
178 | for (const el of [...node.attributes]) {
179 | const name = el.name.replace(':', '')
180 | const val = getAttr(node, name)
181 | if (!CORE_ATTR.includes(name) && typeof(val) != 'object') {
182 | attr[name] = val == null ? true : val
183 | }
184 | }
185 | return attr
186 | }
187 |
188 | function createParent(node) {
189 | node.$attrs = getAttrs(node)
190 | return new Proxy(node, {
191 | get(__, key) {
192 | return getAttr(node, key)
193 | }
194 | })
195 | }
196 |
197 | function setAttrs(root, parent) {
198 | const arr = mergeVals(getAttr(root, 'class') || [], parent.class)
199 | if (arr[0]) root.className = renderVal(arr, ' ')
200 |
201 | const { id, style } = parent
202 | if (style && style.x != '') root.style = renderVal(style)
203 | if (id) root.id = renderVal(id)
204 | }
205 |
206 | function update(obj) {
207 | if (obj) Object.assign(impl, obj)
208 | expr.map(el => el())
209 | impl.updated?.call(ctx, ctx)
210 | return self
211 | }
212 |
213 |
214 | // context
215 | let impl = {}
216 |
217 | const self = {
218 | update,
219 |
220 | $el: dom,
221 |
222 | // root === $el
223 | get root() { return self.$el },
224 |
225 | $refs: {},
226 |
227 | $parent,
228 |
229 | impl,
230 |
231 |
232 | mount(wrap) {
233 | const root = dom || (self.$el = mkdom(tmpl))
234 |
235 | // Isomorphic JSON. Saved for later hot-reloading
236 | let script = wrap.querySelector('script')
237 | if (script) {
238 | Object.assign(data, JSON.parse(script.textContent))
239 | wrap.insertAdjacentElement('afterend', script)
240 | }
241 |
242 | // setup refs
243 |
244 | // constructor
245 | if (Impl) {
246 | impl = self.impl = new Impl(ctx)
247 |
248 | // for
249 | impl.$refs = self.$refs
250 | impl.update = update
251 | }
252 |
253 | walk(root)
254 |
255 | wrap.replaceWith(root)
256 |
257 | // copy root attributes
258 | for (const a of [...wrap.attributes]) setAttr(root, a.name, a.value)
259 |
260 | // callback: mounted()
261 | impl.mounted?.call(ctx, ctx)
262 |
263 | return update()
264 | },
265 |
266 | // used by slots
267 | replace(wrap) {
268 | walk(dom)
269 | wrap.replaceWith(...dom.children)
270 | update()
271 | },
272 |
273 | // used by loops and conditionals
274 | before(anchor) {
275 | if (dom) {
276 | self.$el = dom
277 |
278 | // TODO: more performant check?
279 | if (!document.body.contains(dom)) anchor.before(dom)
280 | if (!dom.walked) { walk(dom); dom.walked = 1 }
281 | return update()
282 | }
283 | },
284 |
285 | unmount() {
286 | try {
287 | self.root.remove()
288 | } catch (e) {}
289 | impl.unmounted?.call(ctx, ctx)
290 | update()
291 | }
292 |
293 | }
294 |
295 | const ctx = new Proxy({}, {
296 | get(__, key) {
297 |
298 | // keep this order
299 | for (const el of [self, impl, data, $parent, $parent.bind]) {
300 | const val = el && el[key]
301 | if (val != null) return val
302 | }
303 | },
304 |
305 | set(__, key, val) {
306 |
307 | // parent key? (loop items)
308 | if ($parent && $parent[key] !== undefined) {
309 | $parent[key] = val
310 | $parent.update()
311 |
312 | } else {
313 | self[key] = val
314 | }
315 | return true
316 | }
317 |
318 | })
319 |
320 | return self
321 |
322 | }
323 |
324 | // good for async import
325 | export { createApp }
326 |
327 |
328 | function mkdom(tmpl) {
329 | const el = document.createElement('_')
330 | el.innerHTML = tmpl.trim()
331 | return el.firstChild
332 | }
333 |
334 |
335 | // render expression return value
336 | function renderVal(val, separ='') {
337 | return val?.join ? val.filter(el => el || el === 0).join(separ).trim().replace(/\s+/g, ' ') : val || ''
338 | }
339 |
340 | // to merge the class attribute from original mount point
341 | function mergeVals(a, b) {
342 | if (a == b) return [a]
343 | if (!a.join) a = [a]
344 | if (b && !b.join) b = [b]
345 | return a.concat(b)
346 | }
347 |
348 |
--------------------------------------------------------------------------------
/ssr/render.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | import { mkdom, getComponentName, mergeAttribs, isBoolean, exec, STD, walk } from './fn.js'
4 | import { parseExpr, parseFor, setContext } from './expr.js'
5 | import { parseDocument, DomUtils as DOM } from 'htmlparser2'
6 | import { promises as fs } from 'node:fs'
7 |
8 | const { getInnerHTML, getOuterHTML, removeElement } = DOM
9 |
10 |
11 | // name == optional
12 | function renderExpr(str, data, is_class) {
13 | const arr = exec('[' + parseExpr(str) + ']', data)
14 | return arr.filter(el => is_class ? el : el != null).join('').trim()
15 | }
16 |
17 |
18 | function setContent(node, data) {
19 | // run once
20 | if (node.__is_set) return
21 |
22 | const str = node.data || ''
23 |
24 | if (str.includes('{')) {
25 | if (str.startsWith('{{')) {
26 | if (str.endsWith('}}')) {
27 | node.data = ''
28 | const expr = setContext(str.slice(2, -2))
29 | DOM.appendChild(node.parentNode, parseDocument(exec(expr, data)))
30 | }
31 |
32 | } else {
33 | node.data = renderExpr(str, data)
34 | node.__is_set = true
35 | }
36 | }
37 | }
38 |
39 | // attributes must be strings
40 | function toString(val) {
41 | return 1 * val ? '' + val : typeof val != 'string' ? val.toString() : val
42 | }
43 |
44 | function setAttribute(key, attribs, data) {
45 | let val = attribs[key]
46 |
47 |
48 | // TODO: check all non-strings here
49 | if (val.constructor === Object) return
50 |
51 | // attributes must be strings
52 | if (1 * val) val = attribs[key] = '' + val
53 |
54 | const has_expr = val.includes('{')
55 |
56 | // strip event handlers
57 | if (key[0] == '@') return delete attribs[key]
58 |
59 | // foo="{}" --> :foo="{}"
60 | if (key[0] != ':' && has_expr) {
61 | delete attribs[key]
62 | key = ':' + key
63 | }
64 |
65 | // expression
66 | if (key[0] != ':') return
67 |
68 | const name = key.slice(1)
69 | const value = has_expr ? renderExpr(val, data, name == 'class') : exec(setContext(val), data)
70 |
71 | // boolean attribute
72 | if (isBoolean(name)) {
73 | if (value != 'false') attribs[name] = ''
74 | else delete attribs[name]
75 | return delete attribs[key]
76 | }
77 |
78 | // other attribute
79 | if (value) attribs[name] = value
80 | else delete attribs[name]
81 | delete attribs[key]
82 | }
83 |
84 | function getIfBlocks(root, expr) {
85 | const arr = [{ root, expr }]
86 | while (root = DOM.nextElementSibling(root)) {
87 | const { attribs } = root
88 | const expr = getDel(':else-if', attribs) || getDel(':else', attribs) != null
89 | if (expr) arr.push({ root, expr })
90 | else break
91 | }
92 | return arr
93 | }
94 |
95 | function processIf(node, expr, data, deps) {
96 | const blocks = getIfBlocks(node, expr)
97 |
98 | const active = blocks.find(el => {
99 | const val = exec(setContext(el.expr), data)
100 | return val && val != 'false'
101 | })
102 |
103 | blocks.forEach(el => {
104 | const { root } = el
105 | if (el == active) processNode({ root, data, deps })
106 | else removeElement(root)
107 | })
108 | return active
109 | }
110 |
111 |
112 | // for
113 | function processFor(node, expr, data, deps) {
114 | const [ $keys, for_expr, $index, is_object_loop ] = parseFor(expr)
115 | const items = exec(for_expr, data) || []
116 |
117 | items.forEach((item, i) => {
118 |
119 | // proxy
120 | const proxy = new Proxy({}, {
121 | get(_, key) {
122 | if (is_object_loop) {
123 | const i = $keys.indexOf(key)
124 | if (i >= 0) return item[i]
125 | }
126 |
127 | return key === $keys ? item || data[key] :
128 | key == $index ? items.indexOf(item) :
129 | $keys.includes(key) ? item[key] :
130 | data[key]
131 | }
132 | })
133 |
134 | // clone
135 | const root = parseDocument(getOuterHTML(node))
136 |
137 | DOM.prepend(node, processNode({ root, data: proxy, deps, inner: node.children }))
138 | })
139 |
140 | // mark as dummy (removeElement(node) does not work here)
141 | node.attribs.__dummy = 'true'
142 | }
143 |
144 | // child component
145 | function processChild(comp, node, deps, data) {
146 | const { attribs } = node
147 |
148 | // merge attributes
149 | const child = comp.create({ ...data, ...attribs }, deps, node.children)
150 | if (child.children.length == 1) mergeAttribs(child.firstChild.attribs, attribs)
151 |
152 | DOM.replaceElement(node, child)
153 | }
154 |
155 | function getDel(key, attribs) {
156 | const val = attribs[key]
157 | delete attribs[key]
158 | return val
159 | }
160 |
161 |
162 | function processNode(opts) {
163 | const { root, data, deps, inner } = opts
164 |
165 | function walk(node) {
166 | const { name, type, attribs, nextSibling } = node
167 |
168 | // setup empty attributes (:date --> :date="date")
169 | for (let key in attribs) {
170 | if (key[0] == ':' && attribs[key] == '') attribs[key] = key.slice(1)
171 | }
172 |
173 | // root
174 | if (type == 'root') {
175 | walkChildren(node)
176 |
177 | // content
178 | } else if (type == 'text') {
179 | setContent(node, data)
180 |
181 | // element
182 | } else if (type == 'tag' || type == 'style' || type == 'script') {
183 |
184 | // if
185 | let expr = getDel(':if', attribs)
186 | if (expr && !processIf(node, expr, data, deps)) return nextSibling
187 |
188 | // for
189 | expr = getDel(':for', attribs)
190 | if (expr) return processFor(node, expr, data, deps)
191 |
192 | // html
193 | expr = getDel(':html', attribs)
194 | if (expr) {
195 | const html = exec(setContext(expr), data)
196 | DOM.appendChild(node, parseDocument(html))
197 | }
198 |
199 | walkChildren(node)
200 |
201 | // bind
202 | expr = getDel(':bind', attribs) || getDel(':attr', attribs)
203 | if (expr) {
204 | const attr = expr == '$attrs' ? data : exec(setContext(expr), data)
205 | Object.assign(attribs, attr)
206 | }
207 |
208 | // slots
209 | if (name == 'slot') {
210 | if (attribs.for) {
211 | const html = data[attribs.for]
212 | if (html) DOM.replaceElement(node, mkdom(html))
213 |
214 | } else if (inner) {
215 | while (inner[0]) DOM.prepend(node, inner[0])
216 | removeElement(node)
217 | }
218 | }
219 |
220 |
221 | // custom component
222 | const is_custom = !STD.includes(name)
223 | const component = deps.find(el => el.name == name)
224 |
225 | // client side component
226 | if (is_custom && !component) {
227 | setJSONData(node, data)
228 | node.attribs.island = name
229 | node.name = 'nue-island'
230 | return // must return
231 | }
232 |
233 | // after custom, but before SSR components (for all nodes)
234 | for (let key in attribs) setAttribute(key, attribs, data)
235 |
236 | // server side component
237 | if (component) processChild(component, node, deps, data)
238 |
239 | }
240 | }
241 |
242 | function walkChildren(node) {
243 | let child = node.firstChild
244 | while (child) {
245 | child = walk(child)?.next || child.nextSibling
246 | }
247 | }
248 |
249 | walk(root)
250 | return root
251 | }
252 |
253 | function getJS(nodes) {
254 | const scripts = nodes.filter(el => el.type == 'script' && !el.attribs.type)
255 | const js = scripts.map(getInnerHTML)
256 | scripts.forEach(removeElement)
257 | return js.join('\n')
258 | }
259 |
260 | function createComponent(node, global_js='') {
261 | const name = getComponentName(node)
262 |
263 | // javascript
264 | const js = getJS(node.children)
265 | const Impl = js[0] && exec(`class Impl { ${ js } }\n${global_js}`)
266 | const tmpl = getOuterHTML(node)
267 |
268 | function create(data, deps=[], inner) {
269 | if (Impl) data = Object.assign(new Impl(data), data) // ...spread won't work
270 | return processNode({ root: mkdom(tmpl), data, deps, inner })
271 | }
272 |
273 | return {
274 | name,
275 | tagName: node.tagName,
276 | create,
277 |
278 | render: function(data, deps) {
279 | const node = create(data, deps)
280 |
281 | // cleanup / remove dummy elements
282 | walk(node, el => { if (el.attribs?.__dummy) removeElement(el) })
283 |
284 | return getOuterHTML(node)
285 | }
286 | }
287 | }
288 |
289 | function appendData(node, data) {
290 | const script = `\n \n`
291 | DOM.appendChild(node.firstChild || node, parseDocument(script))
292 | }
293 |
294 | function setJSONData(node, ctx) {
295 | const { attribs } = node
296 | const json = {}
297 | for (const key in attribs) {
298 | if (key[0] == ':') {
299 | const expr = getDel(key, attribs)
300 | const val = exec(setContext(expr), ctx)
301 | const real = key.slice(1)
302 | if (['id', 'class'].includes(real) || real.startsWith('data-')) {
303 | if (val) attribs[real] = val
304 | } else {
305 | json[real] = val
306 | }
307 | }
308 | }
309 | if (Object.keys(json)[0]) appendData(node, json)
310 | }
311 |
312 |
313 | export function parse(template) {
314 | const { children } = mkdom(template)
315 | const nodes = children.filter(el => el.type == 'tag')
316 | const global_js = getJS(children)
317 | return nodes.map(node => createComponent(node, global_js))
318 | }
319 |
320 | export function render(template, data, deps) {
321 | const comps = parse(template)
322 | if (Array.isArray(deps)) comps.push(...deps)
323 | return comps[0] ? comps[0].render(data, comps) : ''
324 | }
325 |
326 | export async function parseFile(path) {
327 | const src = await fs.readFile(path, 'utf-8')
328 | return parse(src)
329 | }
330 |
331 | export async function renderFile(path, data, deps) {
332 | const src = await fs.readFile(path, 'utf-8')
333 | return render(src, data, deps)
334 | }
335 |
336 |
337 |
--------------------------------------------------------------------------------