├── .eslintignore ├── .editorconfig ├── .gitignore ├── .npmignore ├── LICENSE ├── rollup.esbuild.config.mjs ├── package.json ├── README.md ├── .eslintrc └── src └── main.mjs /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | test/*.js 4 | **/*.json 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | bin/ 3 | gen/ 4 | 5 | # Log Files 6 | *.log 7 | *.log.* 8 | 9 | # Temp Files 10 | *~ 11 | *.*~ 12 | .fuse_* 13 | yarn.lock 14 | cache/ 15 | temp/ 16 | 17 | # Project files 18 | dist/ 19 | dev/ 20 | 21 | # node modules 22 | node_modules/ 23 | 24 | .DS_Store 25 | /dist.zip 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | bin/ 3 | gen/ 4 | 5 | # Log Files 6 | *.log 7 | *.log.* 8 | 9 | # Temp Files 10 | *~ 11 | *.*~ 12 | .fuse_* 13 | yarn.lock 14 | cache/ 15 | temp/ 16 | 17 | # Project files 18 | build/ 19 | config/ 20 | test/ 21 | 22 | # Hidden files 23 | .* 24 | 25 | # node modules 26 | node_modules/ 27 | 28 | # local tools 29 | publish 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yukino Song 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild, {minify} from 'rollup-plugin-esbuild' 2 | 3 | const exportName = 'singui' 4 | 5 | const minifyPlugin = minify() 6 | 7 | export default { 8 | input: 'src/main.mjs', 9 | output: [{ 10 | file: 'dist/main.umd.js', 11 | name: exportName, 12 | format: 'umd', 13 | sourcemap: true 14 | }, { 15 | file: 'dist/main.umd.min.js', 16 | name: exportName, 17 | format: 'umd', 18 | plugins: [minifyPlugin] 19 | }, { 20 | file: 'dist/main.iife.js', 21 | name: exportName, 22 | format: 'iife', 23 | sourcemap: true 24 | }, { 25 | file: 'dist/main.iife.min.js', 26 | name: exportName, 27 | format: 'iife', 28 | plugins: [minifyPlugin] 29 | }, { 30 | file: 'dist/main.cjs', 31 | name: exportName, 32 | format: 'cjs', 33 | sourcemap: true 34 | }, { 35 | file: 'dist/main.min.cjs', 36 | name: exportName, 37 | format: 'cjs', 38 | plugins: [minifyPlugin] 39 | }, { 40 | file: 'dist/main.js', 41 | name: exportName, 42 | format: 'esm', 43 | sourcemap: true 44 | }, { 45 | file: 'dist/main.min.js', 46 | name: exportName, 47 | format: 'esm', 48 | plugins: [minifyPlugin] 49 | }], 50 | plugins: [ 51 | esbuild() 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "singui", 3 | "version": "0.3.7", 4 | "description": "The next-gen, no compile/transpile needed, self-contained JS UI library", 5 | "main": "dist/main.min.js", 6 | "module": "src/main.mjs", 7 | "unpkg": "dist/main.umd.min.js", 8 | "scripts": { 9 | "build": "rollup -c ./rollup.esbuild.config.mjs" 10 | }, 11 | "exports": { 12 | "./package.json": "./package.json", 13 | ".": { 14 | "module": "src/main.mjs", 15 | "script": "dist/main.umd.min.js", 16 | "require": "dist/main.min.cjs", 17 | "node": "src/main.mjs", 18 | "default": "dist/main.min.js" 19 | } 20 | }, 21 | "files": [ 22 | "src/**/*", 23 | "dist/**/*" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/ClassicOldSong/SingUI.git" 28 | }, 29 | "keywords": [ 30 | "singui" 31 | ], 32 | "author": "Yukino Song", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/ClassicOldSong/SingUI/issues" 36 | }, 37 | "homepage": "https://github.com/ClassicOldSong/SingUI", 38 | "devDependencies": { 39 | "esbuild": "^0.15.16", 40 | "rollup": "^3.5.0", 41 | "rollup-plugin-esbuild": "^5.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SINGUI 2 | 3 | The next-gen, no compile/transpile needed, self-contained JS UI library 4 | 5 | [Try it out](https://stackblitz.com/edit/singui-demo?file=index.js) 6 | 7 | ## Usage 8 | 9 | ### Browser 10 | 11 | ```html 12 | 13 | 16 | ``` 17 | 18 | or 19 | 20 | ```javascript 21 | import {browser, tags, text, attr, prop, setGlobalCtx} from 'singui' 22 | ``` 23 | 24 | or 25 | 26 | ```javascript 27 | const {browser, tags, text, attr, prop, setGlobalCtx} = require('singui') 28 | ``` 29 | 30 | then 31 | 32 | ```javascript 33 | setGlobalCtx(browser()) 34 | 35 | const app = (target) => build(({attach}) => { 36 | const {h1, center, p} = tags 37 | 38 | center(() => { 39 | h1(() => { 40 | attr.style = 'font-weight: 300' 41 | text('Hello World!') 42 | }) 43 | }) 44 | 45 | p(() => { 46 | const style = prop.style 47 | style.color = 'green' 48 | style.textAlign = 'center' 49 | text('Welcome to SingUI') 50 | }) 51 | 52 | attach(target) 53 | }) 54 | 55 | app(document.body) 56 | ``` 57 | More details please see [Try it out](https://stackblitz.com/edit/singui-demo?file=index.js) 58 | 59 | ## License 60 | 61 | MIT 62 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "ENV": true, 9 | "process": true, 10 | "__dirname": true, 11 | "__filename": true 12 | }, 13 | "extends": [ 14 | "eslint:recommended" 15 | ], 16 | "parserOptions": { 17 | "ecmaVersion": 8, 18 | "sourceType": "module" 19 | }, 20 | "settings": {}, 21 | "rules": { 22 | "accessor-pairs": "error", 23 | "array-bracket-spacing": [ 24 | "error", 25 | "never" 26 | ], 27 | "array-callback-return": "error", 28 | "arrow-body-style": "error", 29 | "arrow-parens": [ 30 | "error", 31 | "as-needed", 32 | { 33 | "requireForBlockBody": true 34 | } 35 | ], 36 | "arrow-spacing": [ 37 | "error", 38 | { 39 | "after": true, 40 | "before": true 41 | } 42 | ], 43 | "block-scoped-var": "error", 44 | "block-spacing": "error", 45 | "brace-style": [ 46 | "error", 47 | "1tbs" 48 | ], 49 | "callback-return": "error", 50 | "camelcase": "warn", 51 | "class-methods-use-this": "error", 52 | "comma-dangle": [ 53 | "error", 54 | "only-multiline" 55 | ], 56 | "comma-spacing": "off", 57 | "comma-style": [ 58 | "error", 59 | "last" 60 | ], 61 | "complexity": ["warn", "max": 25], 62 | "computed-property-spacing": [ 63 | "error", 64 | "never" 65 | ], 66 | "consistent-return": "off", 67 | "consistent-this": "error", 68 | "curly": "off", 69 | "default-case": "error", 70 | "dot-location": "off", 71 | "dot-notation": "error", 72 | "eol-last": "off", 73 | "eqeqeq": "error", 74 | "func-call-spacing": "error", 75 | "func-names": [ 76 | "error", 77 | "never" 78 | ], 79 | "func-style": [ 80 | "off", 81 | "expression" 82 | ], 83 | "generator-star-spacing": "error", 84 | "global-require": "error", 85 | "guard-for-in": "off", 86 | "handle-callback-err": "error", 87 | "id-blacklist": "error", 88 | "id-length": "off", 89 | "id-match": "error", 90 | "indent": "off", 91 | "init-declarations": "error", 92 | "jsx-quotes": "off", 93 | "key-spacing": "error", 94 | "keyword-spacing": [ 95 | "error", 96 | { 97 | "after": true, 98 | "before": true 99 | } 100 | ], 101 | "line-comment-position": "off", 102 | "linebreak-style": [ 103 | "off" 104 | ], 105 | "lines-around-comment": "error", 106 | "lines-around-directive": "off", 107 | "max-depth": "off", 108 | "max-len": "off", 109 | "max-lines": "off", 110 | "max-nested-callbacks": "error", 111 | "max-params": "error", 112 | "max-statements": "off", 113 | "max-statements-per-line": "error", 114 | "multiline-ternary": "off", 115 | "new-parens": "error", 116 | "newline-after-var": "off", 117 | "newline-before-return": "off", 118 | "newline-per-chained-call": "error", 119 | "no-alert": "error", 120 | "no-array-constructor": "error", 121 | "no-bitwise": "error", 122 | "no-caller": "error", 123 | "no-catch-shadow": "error", 124 | "no-confusing-arrow": "error", 125 | "no-console": "off", 126 | "no-continue": "error", 127 | "no-div-regex": "error", 128 | "no-duplicate-imports": "error", 129 | "no-else-return": "off", 130 | "no-empty-function": "error", 131 | "no-eq-null": "error", 132 | "no-eval": "error", 133 | "no-extend-native": "error", 134 | "no-extra-bind": "error", 135 | "no-extra-label": "error", 136 | "no-extra-parens": "off", 137 | "no-floating-decimal": "error", 138 | "no-global-assign": "error", 139 | "no-implicit-globals": "error", 140 | "no-implied-eval": "error", 141 | "no-inline-comments": "off", 142 | "no-invalid-this": "off", 143 | "no-iterator": "error", 144 | "no-label-var": "error", 145 | "no-labels": "error", 146 | "no-lone-blocks": "error", 147 | "no-lonely-if": "error", 148 | "no-loop-func": "error", 149 | "no-magic-numbers": "off", 150 | "no-mixed-operators": "off", 151 | "no-mixed-requires": "error", 152 | "no-multi-spaces": "error", 153 | "no-multi-str": "error", 154 | "no-multiple-empty-lines": "error", 155 | "no-negated-condition": "error", 156 | "no-nested-ternary": "error", 157 | "no-new": "error", 158 | "no-new-func": "error", 159 | "no-new-object": "error", 160 | "no-new-require": "error", 161 | "no-new-wrappers": "error", 162 | "no-octal-escape": "error", 163 | "no-param-reassign": "off", 164 | "no-path-concat": "error", 165 | "no-plusplus": [ 166 | "error", 167 | { 168 | "allowForLoopAfterthoughts": true 169 | } 170 | ], 171 | "no-process-env": "off", 172 | "no-process-exit": "error", 173 | "no-proto": "error", 174 | "no-prototype-builtins": "error", 175 | "no-restricted-globals": "error", 176 | "no-restricted-imports": "error", 177 | "no-restricted-modules": "error", 178 | "no-restricted-properties": "error", 179 | "no-restricted-syntax": "error", 180 | "no-return-assign": "error", 181 | "no-script-url": "error", 182 | "no-self-compare": "error", 183 | "no-sequences": "error", 184 | "no-shadow": "off", 185 | "no-shadow-restricted-names": "error", 186 | "no-spaced-func": "error", 187 | "no-sync": "error", 188 | "no-tabs": "off", 189 | "no-template-curly-in-string": "error", 190 | "no-ternary": "off", 191 | "no-throw-literal": "error", 192 | "no-trailing-spaces": "error", 193 | "no-undef-init": "error", 194 | "no-undefined": "error", 195 | "no-underscore-dangle": "off", 196 | "no-unmodified-loop-condition": "error", 197 | "no-unneeded-ternary": "error", 198 | "no-unsafe-negation": "error", 199 | "no-unused-expressions": "error", 200 | "no-use-before-define": "error", 201 | "no-useless-call": "error", 202 | "no-useless-computed-key": "error", 203 | "no-useless-concat": "error", 204 | "no-useless-constructor": "error", 205 | "no-useless-escape": "error", 206 | "no-useless-rename": "error", 207 | "no-var": "error", 208 | "no-void": "error", 209 | "no-warning-comments": "error", 210 | "no-whitespace-before-property": "error", 211 | "no-with": "error", 212 | "object-curly-newline": "off", 213 | "object-curly-spacing": [ 214 | "off", 215 | "never" 216 | ], 217 | "object-property-newline": "off", 218 | "object-shorthand": "off", 219 | "one-var": "off", 220 | "one-var-declaration-per-line": "error", 221 | "operator-assignment": "error", 222 | "operator-linebreak": "error", 223 | "padded-blocks": "off", 224 | "prefer-arrow-callback": "error", 225 | "prefer-const": "off", 226 | "prefer-numeric-literals": "error", 227 | "prefer-reflect": "off", 228 | "prefer-rest-params": "error", 229 | "prefer-spread": "error", 230 | "prefer-template": "error", 231 | "quote-props": "off", 232 | "quotes": "off", 233 | "radix": "error", 234 | "require-jsdoc": "off", 235 | "rest-spread-spacing": [ 236 | "error", 237 | "never" 238 | ], 239 | "semi": [ 240 | "warn", 241 | "never" 242 | ], 243 | "semi-spacing": [ 244 | "error", 245 | { 246 | "after": true, 247 | "before": false 248 | } 249 | ], 250 | "sort-imports": "off", 251 | "sort-keys": "off", 252 | "sort-vars": "off", 253 | "space-before-blocks": "error", 254 | "space-before-function-paren": "off", 255 | "space-in-parens": [ 256 | "error", 257 | "never" 258 | ], 259 | "space-infix-ops": "error", 260 | "space-unary-ops": [ 261 | "error", 262 | { 263 | "nonwords": false, 264 | "words": false 265 | } 266 | ], 267 | "spaced-comment": [ 268 | "error", 269 | "always" 270 | ], 271 | "strict": "off", 272 | "symbol-description": "error", 273 | "template-curly-spacing": [ 274 | "error", 275 | "never" 276 | ], 277 | "unicode-bom": [ 278 | "error", 279 | "never" 280 | ], 281 | "valid-jsdoc": "error", 282 | "vars-on-top": "error", 283 | "wrap-iife": "error", 284 | "wrap-regex": "error", 285 | "yield-star-spacing": "error", 286 | "yoda": [ 287 | "error", 288 | "never" 289 | ], 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/main.mjs: -------------------------------------------------------------------------------- 1 | const TARGET_SYMBOL = Symbol('TARGET') 2 | const emptyObj = Object.create(null) 3 | const proxyMap = new WeakMap() 4 | const R = Reflect 5 | 6 | const prepareHooks = () => { 7 | let hooks = new Set() 8 | const runHooks = (...args) => hooks.forEach(hook => hook(...args)) 9 | 10 | const addHooks = (...newHooks) => { 11 | for (let i of newHooks) hooks.add(i) 12 | 13 | let disconnected = false 14 | 15 | return () => { 16 | if (disconnected) return 17 | for (let i of newHooks) hooks.delete(i) 18 | disconnected = true 19 | } 20 | } 21 | 22 | return [runHooks, addHooks] 23 | } 24 | 25 | const useSignal = (initVal) => { 26 | let val = initVal 27 | 28 | const [runHooks, addHooks] = prepareHooks() 29 | 30 | const connect = (...handlers) => { 31 | if (handlers.length === 0) return val 32 | for (let i of handlers) i(val) 33 | return addHooks(...handlers) 34 | } 35 | 36 | const setVal = (newVal) => { 37 | if (val === newVal) return 38 | if (typeof newVal === 'function') newVal = newVal(val) 39 | const oldVal = val 40 | val = newVal 41 | runHooks(newVal, oldVal) 42 | } 43 | 44 | const signal = (newVal) => { 45 | // eslint-disable-next-line no-undefined 46 | if (newVal === undefined) return val 47 | return setVal(newVal) 48 | } 49 | 50 | signal.connect = connect 51 | 52 | return signal 53 | } 54 | 55 | const mux = (...args) => { 56 | const staticStrs = args.shift() 57 | const valList = new Array(staticStrs.length + args.length) 58 | 59 | let batchDepth = 0 60 | let handlerCount = 0 61 | let disconnectList = null 62 | let evalList = [] 63 | 64 | for (let i in staticStrs) { 65 | valList[i * 2] = staticStrs[i] 66 | } 67 | 68 | const strMux = useSignal() 69 | 70 | const flush = () => { 71 | if (batchDepth <= 0) { 72 | for (let i of evalList) i() 73 | strMux(''.concat(...valList)) 74 | batchDepth = 0 75 | } 76 | } 77 | 78 | const pause = () => { 79 | batchDepth += 1 80 | } 81 | 82 | const resume = () => { 83 | batchDepth -= 1 84 | if (batchDepth <= 0) flush() 85 | } 86 | 87 | const batch = (handler) => { 88 | pause() 89 | handler() 90 | resume() 91 | } 92 | 93 | const init = () => { 94 | if (disconnectList) return 95 | pause() 96 | disconnectList = args.map((signal, index) => { 97 | index = index * 2 + 1 98 | if (typeof signal === 'function') { 99 | evalList.push(() => { 100 | valList[index] = signal() 101 | }) 102 | if (typeof signal.connect === 'function') return signal.connect(flush) 103 | return null 104 | } 105 | 106 | valList[index] = signal 107 | return null 108 | }) 109 | resume() 110 | } 111 | 112 | const destroy = () => { 113 | if (!disconnectList) return 114 | for (let i of disconnectList) { 115 | if (i) i() 116 | } 117 | disconnectList = null 118 | evalList.length = 0 119 | handlerCount = 0 120 | } 121 | 122 | const cleanup = () => { 123 | handlerCount -= 1 124 | if (handlerCount <= 0) destroy() 125 | } 126 | 127 | const connect = (handler) => { 128 | if (!handler) return strMux() 129 | 130 | if (!disconnectList) init() 131 | 132 | handlerCount += 1 133 | 134 | const disconnectHandler = strMux.connect(handler) 135 | 136 | return () => { 137 | if (disconnectHandler()) { 138 | cleanup() 139 | return true 140 | } 141 | 142 | return false 143 | } 144 | } 145 | 146 | const disconnect = (handler) => { 147 | if (strMux.disconnect(handler)) cleanup() 148 | } 149 | 150 | let muxedSignal = null 151 | 152 | const watch = (...signals) => { 153 | if (!disconnectList) init() 154 | 155 | for (let i of signals) { 156 | disconnectList.push(i.connect(flush)) 157 | } 158 | 159 | return muxedSignal 160 | } 161 | 162 | muxedSignal = (...args) => { 163 | if (!args.length) return strMux() 164 | return watch(...args) 165 | } 166 | 167 | muxedSignal.connect = connect 168 | muxedSignal.disconnect = disconnect 169 | muxedSignal.pause = pause 170 | muxedSignal.resume = resume 171 | muxedSignal.batch = batch 172 | muxedSignal.flush = flush 173 | muxedSignal.watch = watch 174 | 175 | return muxedSignal 176 | } 177 | 178 | const getCachedProxy = (target, lifeCycle) => { 179 | if (proxyMap.has(target)) { 180 | const lifeCycleMap = proxyMap.get(target) 181 | if (lifeCycleMap.has(lifeCycle)) return lifeCycleMap.get(lifeCycle) 182 | } 183 | 184 | return null 185 | } 186 | 187 | const proxify = (handler, target, lifeCycle) => { 188 | if (target) { 189 | let lifeCycleMap = null 190 | if (proxyMap.has(target)) { 191 | lifeCycleMap = proxyMap.get(target) 192 | } else { 193 | lifeCycleMap = new WeakMap() 194 | proxyMap.set(target, lifeCycleMap) 195 | } 196 | 197 | const proxied = new Proxy(target, handler) 198 | lifeCycleMap.set(lifeCycle, proxied) 199 | 200 | return proxied 201 | } 202 | 203 | return new Proxy(emptyObj, handler) 204 | } 205 | 206 | const unwrap = proxiedObj => R.get(proxiedObj, TARGET_SYMBOL) || proxiedObj 207 | 208 | const wrapObj = (target, lifeCycle) => { 209 | const cachedProxy = getCachedProxy(target, lifeCycle) 210 | if (cachedProxy) return cachedProxy 211 | 212 | const targetObj = Object(target) 213 | if (targetObj !== target) return target 214 | 215 | const signalMap = {} 216 | 217 | const propProxy = proxify({ 218 | get(_, propName) { 219 | if (propName === TARGET_SYMBOL) return target 220 | if (propName[0] === '$') { 221 | const realPropName = propName.substring(1) 222 | return (handler) => { 223 | if (handler) return val => R.set(propProxy, realPropName, handler(val, R.get(targetObj, realPropName), realPropName)) 224 | return val => R.set(propProxy, realPropName, val) 225 | } 226 | } 227 | 228 | if (propName[0] === '_') { 229 | const realPropName = propName.substring(1) 230 | return (handler) => { 231 | if (handler) return () => handler(realPropName, propProxy) 232 | return () => wrapObj(R.get(target, realPropName), lifeCycle) 233 | } 234 | } 235 | 236 | return wrapObj(R.get(target, propName), lifeCycle) 237 | }, 238 | set(_, propName, val) { 239 | if (propName[0] === '$' || propName[0] === '_') propName = propName.substring(1) 240 | 241 | if (signalMap[propName]) { 242 | if (val === signalMap[propName].signal) return true 243 | 244 | signalMap[propName].disconnect() 245 | delete signalMap[propName] 246 | } 247 | 248 | if (typeof val === 'function' && typeof val.connect === 'function') { 249 | 250 | let settedUp = false 251 | 252 | const setup = () => { 253 | if (settedUp) return 254 | 255 | const disconnect = val.connect(newVal => R.set(target, propName, newVal)) 256 | const disconnectSelf = lifeCycle.onAfterDetatch(() => { 257 | disconnect() 258 | disconnectSelf() 259 | settedUp = false 260 | }) 261 | 262 | settedUp = true 263 | } 264 | 265 | const disconnect = lifeCycle.onBeforeAttach(setup) 266 | 267 | signalMap[propName] = { 268 | signal: val, 269 | setup, 270 | disconnect 271 | } 272 | 273 | setTimeout(setup, 0) 274 | 275 | return true 276 | } 277 | 278 | return R.set(target, propName, val) 279 | }, 280 | apply(_, thisArg, argList) { 281 | R.apply(target, unwrap(thisArg), argList) 282 | } 283 | }, targetObj, lifeCycle) 284 | 285 | return propProxy 286 | } 287 | 288 | const camelToKebab = str => [...str].map((i) => { 289 | const lowerCaseLetter = i.toLowerCase() 290 | if (i === lowerCaseLetter) return i 291 | return `-${lowerCaseLetter}` 292 | }).join('') 293 | 294 | const env = ({ 295 | createElement, 296 | createTextNode, 297 | createComment, 298 | createDocumentFragment, 299 | cloneElement, 300 | appendChild, 301 | appendBefore, 302 | appendAfter, 303 | getNextSibling, 304 | getAttr, 305 | setAttr, 306 | removeAttr, 307 | addEventListener, 308 | removeEventListener 309 | }, { 310 | tags = null, 311 | build = null, 312 | currentNode = null, 313 | currentNamespace = null, 314 | // hydrating = null, 315 | lifeCycleHooks = new WeakMap() 316 | } = {}) => { 317 | 318 | const prevNodes = [] 319 | const pushCurrentNode = (node) => { 320 | prevNodes.push(currentNode) 321 | currentNode = node 322 | } 323 | const popCurrentNode = () => { 324 | currentNode = prevNodes.pop() 325 | } 326 | 327 | const prevNamespaces = [] 328 | const pushCurrentNamespace = (namespace) => { 329 | prevNamespaces.push(namespace) 330 | currentNamespace = namespace 331 | } 332 | const popCurrentNamespace = () => { 333 | currentNamespace = prevNamespaces.pop() 334 | } 335 | 336 | const scoped = (builder, node = currentNode) => { 337 | if (node === null) return builder 338 | return (...args) => { 339 | pushCurrentNode(node) 340 | const ret = builder(...args) 341 | popCurrentNode() 342 | return ret 343 | } 344 | } 345 | 346 | const namespaced = (builder, namespace = currentNamespace) => { 347 | if (namespace === null) return builder 348 | return (...args) => { 349 | pushCurrentNamespace(namespace) 350 | const ret = builder(...args) 351 | popCurrentNamespace() 352 | return ret 353 | } 354 | } 355 | 356 | const clearScope = builder => (...args) => { 357 | pushCurrentNode() 358 | const ret = builder(...args) 359 | popCurrentNode() 360 | return ret 361 | } 362 | 363 | const clearNamespace = builder => (...args) => { 364 | pushCurrentNamespace() 365 | const ret = builder(...args) 366 | popCurrentNamespace() 367 | return ret 368 | } 369 | 370 | const on = (...args) => addEventListener(currentNode, ...args) 371 | const off = (...args) => removeEventListener(currentNode, ...args) 372 | 373 | const useElement = () => currentNode 374 | const useTags = (toKebab = true, namespace = null) => { 375 | const getTag = namespaced(tagName => R.get(tags, tagName), namespace) 376 | return proxify({ 377 | get(_, tagName) { 378 | if (toKebab) tagName = camelToKebab(tagName) 379 | return getTag(tagName) 380 | } 381 | }) 382 | } 383 | 384 | let currentLifeCycleNode = null 385 | 386 | const useLifeCycle = (target) => { 387 | if (!target && currentLifeCycleNode) return useLifeCycle(currentLifeCycleNode) 388 | 389 | target = unwrap(target || currentNode) 390 | let hooks = lifeCycleHooks.get(target) 391 | if (hooks) return hooks 392 | 393 | const [beforeAttach, onBeforeAttach] = prepareHooks() 394 | const [afterAttach, onAfterAttach] = prepareHooks() 395 | const [beforeDetatch, onBeforeDetatch] = prepareHooks() 396 | const [afterDetatch, onAfterDetatch] = prepareHooks() 397 | 398 | hooks = { 399 | beforeAttach, 400 | afterAttach, 401 | beforeDetatch, 402 | afterDetatch, 403 | onBeforeAttach, 404 | onAfterAttach, 405 | onBeforeDetatch, 406 | onAfterDetatch 407 | } 408 | 409 | lifeCycleHooks.set(target, hooks) 410 | 411 | return hooks 412 | } 413 | 414 | const withLifeCycle = (handler, target = currentNode) => { 415 | const prevNode = currentLifeCycleNode 416 | currentLifeCycleNode = target 417 | 418 | const ret = handler() 419 | 420 | currentLifeCycleNode = prevNode 421 | return ret 422 | } 423 | 424 | const wrap = (target, lifeCycle = useLifeCycle()) => wrapObj(target, lifeCycle) 425 | 426 | const attrProxyMap = new WeakMap() 427 | 428 | const toAttr = (target) => { 429 | if (attrProxyMap.has(target)) return attrProxyMap.get(target) 430 | 431 | const attrProxy = new Proxy(target, { 432 | get(_, attrName) { 433 | return getAttr(target, attrName, currentNamespace) 434 | }, 435 | set(_, attrName, val) { 436 | if (val === null) removeAttr(target, attrName, currentNamespace) 437 | setAttr(target, attrName, val, currentNamespace) 438 | return true 439 | } 440 | }) 441 | 442 | attrProxyMap.set(target, attrProxy) 443 | 444 | return attrProxy 445 | } 446 | 447 | const attr = proxify({ 448 | get(_, attrName) { 449 | return R.get(wrap(toAttr(currentNode)), attrName) 450 | }, 451 | set(_, attrName, val) { 452 | return R.set(wrap(toAttr(currentNode)), attrName, val) 453 | } 454 | }) 455 | const useAttr = (capture = true, toKebab = true, namespace = null) => { 456 | const scope = capture && currentNode || null 457 | const getAttribute = scoped(namespaced(attrName => R.get(attr, attrName), namespace), scope) 458 | const setAttribute = scoped(namespaced((attrName, val) => R.set(attr, attrName, val), namespace), scope) 459 | 460 | return proxify({ 461 | get(_, attrName) { 462 | if (toKebab) attrName = camelToKebab(attrName) 463 | return getAttribute(attrName) 464 | }, 465 | set(_, attrName, val) { 466 | if (toKebab) attrName = camelToKebab(attrName) 467 | return setAttribute(attrName, val) 468 | } 469 | }) 470 | } 471 | 472 | const prop = proxify({ 473 | get(_, propName) { 474 | return R.get(wrap(currentNode), propName) 475 | }, 476 | set(_, propName, val) { 477 | return R.set(wrap(currentNode), propName, val) 478 | } 479 | }) 480 | const useProp = () => { 481 | const getProp = scoped(propName => R.get(prop, propName)) 482 | const setProp = scoped((propName, val) => R.set(prop, propName, val)) 483 | 484 | return proxify({ 485 | get(_, propName) { 486 | return getProp(propName) 487 | }, 488 | set(_, propName, val) { 489 | return setProp(propName, val) 490 | } 491 | }) 492 | } 493 | 494 | const text = (initVal) => { 495 | const textNode = createTextNode('') 496 | pushCurrentNode(textNode) 497 | const wrappedNode = wrap(textNode) 498 | if (initVal) wrappedNode.textContent = initVal 499 | popCurrentNode() 500 | if (currentNode) appendChild(currentNode, textNode) 501 | return wrappedNode 502 | } 503 | 504 | const comment = (initVal) => { 505 | const commentNode = createComment('') 506 | pushCurrentNode(commentNode) 507 | const wrappedNode = wrap(commentNode) 508 | if (initVal) wrappedNode.textContent = initVal 509 | popCurrentNode() 510 | if (currentNode) appendChild(currentNode, commentNode) 511 | return wrappedNode 512 | } 513 | 514 | const fragment = (builder, append = true) => { 515 | const ret = {} 516 | 517 | build(({attach, detatch, before, after, startAnchor, endAnchor}) => { 518 | ret.attach = attach 519 | ret.detatch = detatch 520 | ret.before = before 521 | ret.after = after 522 | ret.empty = () => { 523 | const tempStore = createDocumentFragment() 524 | 525 | let currentElement = getNextSibling(startAnchor) 526 | while (currentElement !== endAnchor) { 527 | const nextElement = getNextSibling(currentElement) 528 | appendChild(tempStore, currentElement) 529 | currentElement = nextElement 530 | } 531 | } 532 | ret.append = (builder) => { 533 | const tempStore = createDocumentFragment() 534 | const ret = scoped(build, tempStore)(builder) 535 | appendBefore(endAnchor, tempStore) 536 | return ret 537 | } 538 | ret.set = (builder) => { 539 | ret.empty() 540 | return ret.append(builder) 541 | } 542 | }, append) 543 | 544 | if (builder) ret.append(builder) 545 | 546 | return ret 547 | } 548 | 549 | const adopt = (rawElement, clone) => (builder, append = true) => { 550 | if (!rawElement) return 551 | 552 | const element = clone ? cloneElement(rawElement) : rawElement 553 | const elementStore = createDocumentFragment() 554 | 555 | const {beforeAttach, afterAttach, beforeDetatch, afterDetatch} = useLifeCycle(element) 556 | 557 | const attach = (target) => { 558 | if (!target) target = currentNode 559 | if (!target) return 560 | beforeAttach(target) 561 | appendChild(target, element) 562 | afterAttach(target) 563 | } 564 | const detatch = () => { 565 | beforeDetatch() 566 | appendChild(elementStore, element) 567 | afterDetatch() 568 | } 569 | const before = (builder) => { 570 | const tempStore = createDocumentFragment() 571 | const ret = scoped(build, tempStore)(builder) 572 | appendBefore(element, tempStore) 573 | return ret 574 | } 575 | const after = (builder) => { 576 | const tempStore = createDocumentFragment() 577 | const ret = scoped(build, tempStore)(builder) 578 | appendAfter(element, tempStore) 579 | return ret 580 | } 581 | 582 | // eslint-disable-next-line init-declarations 583 | let ret 584 | 585 | if (builder) { 586 | pushCurrentNode(element) 587 | ret = clearNamespace(builder)({ 588 | build, 589 | adopt, 590 | text, 591 | comment, 592 | fragment, 593 | scoped, 594 | namespaced, 595 | clearScope, 596 | clearNamespace, 597 | element, 598 | on, 599 | off, 600 | mux, 601 | useSignal, 602 | useTags, 603 | useElement, 604 | useAttr, 605 | useProp, 606 | useLifeCycle, 607 | withLifeCycle, 608 | tags, 609 | attr, 610 | prop, 611 | attach, 612 | detatch, 613 | before, 614 | after 615 | }) 616 | popCurrentNode() 617 | } 618 | 619 | if (append && currentNode) attach(currentNode) 620 | else attach(elementStore) 621 | 622 | if (!clone) rawElement = null 623 | 624 | return {element, ret, attach, detatch, before, after} 625 | } 626 | 627 | if (!tags) { 628 | tags = proxify({ 629 | get(_, tagName) { 630 | const namespace = currentNamespace 631 | return (builder, append) => { 632 | const element = createElement(tagName, namespace) 633 | return adopt(element, false)(builder, append) 634 | } 635 | } 636 | }) 637 | } 638 | 639 | if (!build) { 640 | build = (builder, append = true) => { 641 | const elementStore = createDocumentFragment() 642 | const startAnchor = createTextNode('') 643 | const endAnchor = createTextNode('') 644 | 645 | builder = clearNamespace(builder) 646 | 647 | appendChild(elementStore, startAnchor) 648 | appendChild(elementStore, endAnchor) 649 | 650 | pushCurrentNode(elementStore) 651 | 652 | const {beforeAttach, afterAttach, beforeDetatch, afterDetatch} = useLifeCycle(elementStore) 653 | 654 | const detatch = () => { 655 | beforeDetatch() 656 | 657 | let currentElement = startAnchor 658 | while (currentElement !== endAnchor) { 659 | const nextElement = getNextSibling(currentElement) 660 | appendChild(elementStore, currentElement) 661 | currentElement = nextElement 662 | } 663 | appendChild(elementStore, endAnchor) 664 | 665 | afterDetatch() 666 | } 667 | const attach = (target) => { 668 | if (!target) target = currentNode 669 | if (!target) return 670 | 671 | detatch() 672 | 673 | beforeAttach(target) 674 | 675 | appendChild(target, startAnchor) 676 | appendChild(target, elementStore) 677 | appendChild(target, endAnchor) 678 | 679 | afterAttach(target) 680 | } 681 | const before = (builder) => { 682 | const tempStore = createDocumentFragment() 683 | const ret = scoped(build, tempStore)(builder) 684 | appendBefore(startAnchor, tempStore) 685 | return ret 686 | } 687 | const after = (builder) => { 688 | const tempStore = createDocumentFragment() 689 | const ret = scoped(build, tempStore)(builder) 690 | appendAfter(endAnchor, tempStore) 691 | return ret 692 | } 693 | 694 | const ret = builder({ 695 | build, 696 | adopt, 697 | text, 698 | comment, 699 | fragment, 700 | scoped, 701 | namespaced, 702 | clearScope, 703 | clearNamespace, 704 | on, 705 | off, 706 | mux, 707 | useSignal, 708 | useTags, 709 | useElement, 710 | useAttr, 711 | useProp, 712 | useLifeCycle, 713 | withLifeCycle, 714 | tags, 715 | attr, 716 | prop, 717 | attach, 718 | detatch, 719 | before, 720 | after, 721 | startAnchor, 722 | endAnchor 723 | }) 724 | 725 | popCurrentNode() 726 | if (currentNode && append) attach(currentNode) 727 | 728 | return ret 729 | } 730 | } 731 | 732 | return { 733 | wrap, 734 | build, 735 | adopt, 736 | text, 737 | comment, 738 | fragment, 739 | scoped, 740 | namespaced, 741 | clearScope, 742 | clearNamespace, 743 | on, 744 | off, 745 | mux, 746 | useSignal, 747 | useTags, 748 | useElement, 749 | useAttr, 750 | useProp, 751 | useLifeCycle, 752 | withLifeCycle, 753 | tags: useTags(), 754 | attr: useAttr(false), 755 | prop 756 | } 757 | } 758 | 759 | const browser = (doc = document, userNamespaceMap = {}) => { 760 | const namespaceURIMap = Object.assign({ 761 | xml: 'http://www.w3.org/XML/1998/namespace', 762 | html: 'http://www.w3.org/1999/xhtml', 763 | svg: 'http://www.w3.org/2000/svg', 764 | math: 'http://www.w3.org/1998/Math/MathML', 765 | xlink: 'http://www.w3.org/1999/xlink' 766 | }, userNamespaceMap) 767 | 768 | return env({ 769 | createElement(tag, namespace) { 770 | if (namespace) { 771 | const namespaceURI = Reflect.get(namespaceURIMap, namespace) || namespace 772 | return doc.createElementNS(namespaceURI, tag) 773 | } 774 | return doc.createElement(tag) 775 | }, 776 | createTextNode(text) { 777 | return doc.createTextNode(text) 778 | }, 779 | createComment(text) { 780 | return doc.createComment(text) 781 | }, 782 | createDocumentFragment() { 783 | return doc.createDocumentFragment() 784 | }, 785 | cloneElement(element) { 786 | return element.cloneNode(true) 787 | }, 788 | appendChild(parent, child) { 789 | return parent.appendChild(child) 790 | }, 791 | appendBefore(node, element) { 792 | return node.parentNode.insertBefore(element, node) 793 | }, 794 | appendAfter(node, element) { 795 | return node.parentNode.insertBefore(element, node.nextSibling) 796 | }, 797 | getNextSibling(node) { 798 | return node.nextSibling 799 | }, 800 | getAttr(node, attrName, namespace) { 801 | if (namespace) { 802 | const namespaceURI = Reflect.get(namespaceURIMap, namespace) || namespace 803 | return node.getAttributeNS(namespaceURI, attrName) 804 | } 805 | return node.getAttribute(attrName) 806 | }, 807 | // eslint-disable-next-line max-params 808 | setAttr(node, attrName, val, namespace) { 809 | if (namespace) { 810 | const namespaceURI = Reflect.get(namespaceURIMap, namespace) || namespace 811 | return node.setAttributeNS(namespaceURI, attrName, val) 812 | } 813 | return node.setAttribute(attrName, val) 814 | }, 815 | removeAttr(node, attrName, namespace) { 816 | if (namespace) { 817 | const namespaceURI = Reflect.get(namespaceURIMap, namespace) || namespace 818 | return node.removeAttributeNS(namespaceURI, attrName) 819 | } 820 | return node.removeAttribute(attrName) 821 | }, 822 | addEventListener(node, ...args) { 823 | return node.addEventListener(...args) 824 | }, 825 | removeEventListener(node, ...args) { 826 | return node.removeEventListener(...args) 827 | } 828 | }) 829 | } 830 | 831 | let globalCtx = null 832 | 833 | const wrap = (...args) => globalCtx.wrap(...args) 834 | const build = (...args) => globalCtx.build(...args) 835 | const adopt = (...args) => globalCtx.adopt(...args) 836 | const text = (...args) => globalCtx.text(...args) 837 | const comment = (...args) => globalCtx.comment(...args) 838 | const fragment = (...args) => globalCtx.fragment(...args) 839 | const scoped = (...args) => globalCtx.scoped(...args) 840 | const namespaced = (...args) => globalCtx.namespaced(...args) 841 | const clearScope = (...args) => globalCtx.clearScope(...args) 842 | const clearNamespace = (...args) => globalCtx.clearNamespace(...args) 843 | const on = (...args) => globalCtx.on(...args) 844 | const off = (...args) => globalCtx.off(...args) 845 | const useTags = (...args) => globalCtx.useTags(...args) 846 | const useElement = (...args) => globalCtx.useElement(...args) 847 | const useAttr = (...args) => globalCtx.useAttr(...args) 848 | const useProp = (...args) => globalCtx.useProp(...args) 849 | const useLifeCycle = (...args) => globalCtx.useLifeCycle(...args) 850 | const withLifeCycle = (...args) => globalCtx.withLifeCycle(...args) 851 | const tags = proxify({ 852 | get(_, tagName) { 853 | return (...args) => R.get(globalCtx.tags, tagName)(...args) 854 | } 855 | }) 856 | const attr = proxify({ 857 | get(_, attrName) { 858 | return R.get(globalCtx.attr, attrName) 859 | }, 860 | set(_, attrName, val) { 861 | return R.set(globalCtx.attr, attrName, val) 862 | } 863 | }) 864 | const prop = proxify({ 865 | get(_, propName) { 866 | return R.get(globalCtx.prop, propName) 867 | }, 868 | set(_, propName, val) { 869 | return R.set(globalCtx.prop, propName, val) 870 | } 871 | }) 872 | 873 | const setGlobalCtx = (ctx) => { 874 | globalCtx = ctx 875 | } 876 | 877 | const getGlobalCtx = () => globalCtx 878 | 879 | export { 880 | env, 881 | browser, 882 | wrap, 883 | unwrap, 884 | build, 885 | adopt, 886 | text, 887 | comment, 888 | fragment, 889 | scoped, 890 | namespaced, 891 | clearScope, 892 | clearNamespace, 893 | on, 894 | off, 895 | mux, 896 | useSignal, 897 | useElement, 898 | useTags, 899 | useAttr, 900 | useProp, 901 | useLifeCycle, 902 | withLifeCycle, 903 | tags, 904 | attr, 905 | prop, 906 | setGlobalCtx, 907 | getGlobalCtx 908 | } 909 | --------------------------------------------------------------------------------