├── .babelrc ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CNAME ├── LICENSE.txt ├── README.md ├── docs ├── CNAME ├── api.md ├── common.css ├── component-tree-with-jinkela.png ├── component-tree.png ├── docs.css ├── docs.html ├── docs.js ├── header.css ├── header.js ├── home.css ├── home.js ├── index.html ├── intro.md ├── md-view.css ├── md-view.js ├── nav.json └── willan.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── AttributesManager.ts ├── BasicBuilder.ts ├── HtmlBuilder.ts ├── IndexedArray.ts ├── PairSet.ts ├── StateManager.ts ├── StringBuilder.ts ├── debounce.ts ├── domListAssign.ts ├── index.ts ├── stdlib │ └── request.ts └── utils.ts ├── tests ├── BasicBuilder.test.ts ├── HtmlBuilder.test.ts ├── IndexedArray.test.ts ├── PairSet.test.ts ├── StateManager.test.ts ├── StringBuilder.test.ts ├── common.ts ├── domListAssign.test.ts ├── stdlib │ └── request.test.ts └── utils.test.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "presets": ["@babel/preset-env", "@babel/preset-typescript"], 4 | "targets": { 5 | "chrome": 49 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [v2] 9 | pull_request: 10 | branches: [v2] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | - uses: codecov/codecov-action@v2 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | flags: unittests 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .* 3 | *.map 4 | dist 5 | coverage 6 | !.travis.yml 7 | !.gitignore 8 | !.babelrc 9 | !.prettierignore 10 | !.prettierrc 11 | !.github 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | jinkelajs.org 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2016-present Rongyi Liu 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Jinkela](https://jinkelajs.org) · [![LICENSE](https://img.shields.io/npm/l/jinkela)](LICENSE.txt) [![codecov](https://img.shields.io/codecov/c/gh/jinkelajs/jinkela)](https://codecov.io/github/jinkelajs/jinkela?branch=v2) 2 | 3 | THINK OF SELF AS A FRONTEND FRAMEWORK 4 | 5 | ## Usage 6 | 7 | A typical usage example. 8 | 9 | ```javascript 10 | import { jkl, createState } from 'jinkela'; 11 | 12 | const list = createState([]); 13 | 14 | const click = () => { 15 | const remove = () => { 16 | const index = list.indexOf(li); 17 | if (index !== -1) list.splice(index, 1); 18 | }; 19 | const li = jkl` 20 |
  • 21 | ${new Date()} 22 | 23 |
  • `; 24 | list.push(li); 25 | }; 26 | 27 | const div = jkl` 28 | 29 | `; 30 | 31 | document.body.appendChild(div); 32 | ``` 33 | 34 | ## Docs 35 | 36 | SEE https://jinkelajs.org 37 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | jinkelajs.org -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # 模板解析器: **jkl** 2 | 3 | `jkl` 是一个模板字符串的标签函数,他会把模板字符串作为 HTML 解析,在其中适当的位置可以使用变量,返回一个文档片段。 4 | 5 | ```typescript 6 | import { jkl } from 'jinkela'; 7 | 8 | const div = jkl`
    Hello Jinkela
    `; 9 | 10 | document.body.appendChild(div); 11 | ``` 12 | 13 | ## 内容 14 | 15 | ### 变量绑定 16 | 17 | 内容区域可以是变量,并且支持数据动态绑定的。 18 | 19 | ```typescript 20 | import { jkl, createState } from 'jinkela'; 21 | 22 | const s = createState({ v: 1 }); 23 | 24 | setInterval(() => ++s.v, 16); 25 | 26 | const div = jkl`

    恭喜发财 × ${() => s.v}

    `; 27 | 28 | document.body.appendChild(div); 29 | ``` 30 | 31 | ### 数组绑定 32 | 33 | 内容区域支持数组,并且支持数据动态绑定的。 34 | 35 | ```typescript 36 | import { jkl, createState } from 'jinkela'; 37 | 38 | const list = createState([1, 2, 3, 4, 5].map((i) => jkl`
  • ${i}
  • `)); 39 | 40 | setInterval(() => { 41 | list.unshift(list.pop()); 42 | }, 300); 43 | 44 | const h1 = jkl``; 45 | 46 | document.body.appendChild(h1); 47 | ``` 48 | 49 | ## 属性 50 | 51 | ### 属性名 52 | 53 | 属性名支持变量。在下面这个例子中打开控制台把 s.v 改成其他值,input 就会恢复可编辑状态。 54 | 55 | ```typescript 56 | import { jkl, createState } from 'jinkela'; 57 | 58 | const s = createState({ v: 'disabled' }); 59 | const b = 'placeholder'; 60 | const input = jkl` s.v} ${b}="input" />`; 61 | 62 | document.body.appendChild(input); 63 | ``` 64 | 65 | ### 属性值 66 | 67 | 属性值支持动态绑定。下面这个例子是让一个元素的颜色不断变化。 68 | 69 | ```typescript 70 | import { jkl, createState } from 'jinkela'; 71 | 72 | const s = createState({ v: 1 }); 73 | 74 | const anime = () => { 75 | s.v += 2; 76 | requestAnimationFrame(anime); 77 | }; 78 | anime(); 79 | 80 | const input = jkl` 81 |

    Hello Jinkela

    `; 82 | 83 | document.body.appendChild(input); 84 | ``` 85 | 86 | ### 属性展开 87 | 88 | 可以将一个对象展开作为属性,并且支持动态绑定。 89 | 90 | ```typescript 91 | import { jkl, createState } from 'jinkela'; 92 | 93 | const attrs = createState({ 94 | style: 'font-size: 22px;', 95 | disabled: true, 96 | placeholder: 1, 97 | }); 98 | 99 | setInterval(() => ++attrs.placeholder, 16); 100 | 101 | const input = jkl``; 102 | 103 | document.body.appendChild(input); 104 | ``` 105 | 106 | 对于同名属性时遵循右覆盖左的规则,此规则对属性展开的用法同样适用。 107 | 108 | ```typescript 109 | import { jkl, createState } from 'jinkela'; 110 | 111 | const input = jkl` 112 |

    113 | Orange Jinkela 114 |

    `; 115 | 116 | document.body.appendChild(input); 117 | ``` 118 | 119 | ## 事件绑定 120 | 121 | ### 基础事件绑定 122 | 123 | 在属性名前面加 `@` 将处理为事件绑定。下面这个例子是在按钮上绑定点击事件,点击后往 ul 中增加一项。 124 | 125 | ```typescript 126 | import { jkl, createState } from 'jinkela'; 127 | 128 | const list = createState([]); 129 | 130 | const click = () => { 131 | list.push(jkl`
  • ${Date.now()}
  • `); 132 | }; 133 | 134 | const input = jkl` 135 | 136 |
    137 | 138 | `; 139 | 140 | document.body.appendChild(input); 141 | ``` 142 | 143 | ### 动态事件绑定 144 | 145 | 通过属性展开的写法可以让事件支持动态绑定。下面这个例子默认不会对按钮绑定事件,只有勾起 checkbox 之后才会绑定事件。 146 | 147 | ```typescript 148 | import { jkl, createState } from 'jinkela'; 149 | 150 | const attrs = createState({}); 151 | const list = createState([]); 152 | 153 | const change = (e) => { 154 | if (e.target.checked) { 155 | attrs.style = 'color: red;'; 156 | attrs['@click'] = () => { 157 | list.push(jkl`
  • ${Date.now()}
  • `); 158 | }; 159 | } else { 160 | delete attrs.style; 161 | delete attrs['@click']; 162 | } 163 | }; 164 | 165 | const input = jkl` 166 |
    167 | 168 | 169 |
    170 | 171 | `; 172 | 173 | document.body.appendChild(input); 174 | ``` 175 | 176 | # 周边函数 177 | 178 | ## **createState** 179 | 180 | ### 创建状态 181 | 182 | 使用 `createState` 可以给一个对象包一层 [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy),当对象属性变化时会通知所有订阅该属性变化的地方更新。 183 | 184 | ```typescript 185 | import { jkl, createState } from 'jinkela'; 186 | 187 | const s = createState({ a: 233 }); 188 | 189 | document.body.appendChild(jkl`
    ${() => s.a}
    `); 190 | ``` 191 | 192 | ### 创建数组状态 193 | 194 | 可以对一个数组创建状态,在修改数组元素时触发更新。包括 push、pop 之类的操作,本质上都是在操作数据元素,所以也能触发更新。 195 | 196 | ```typescript 197 | import { jkl, createState } from 'jinkela'; 198 | 199 | const list = createState([]); 200 | 201 | list.push(jkl`
    hehe
    `); 202 | 203 | document.body.appendChild(jkl`
    ${list}
    `); 204 | ``` 205 | 206 | # 内置工具库 207 | 208 | ## request 209 | 210 | 将一个异步任务转换成具有 { loading, data, error } 结构的状态。 211 | 212 | ```typescript 213 | import { jkl, request } from 'jinkela'; 214 | 215 | const sleep = (ms) => new Promise((f) => setTimeout(f, ms)); 216 | 217 | const s = request(async () => { 218 | await sleep(1000); 219 | return 'I am data'; 220 | }); 221 | 222 | document.body.appendChild(jkl` 223 |
    loading: ${() => s.loading}
    224 |
    data: ${() => s.data}
    225 |
    error: ${() => s.error}
    226 | `); 227 | ``` 228 | 229 |
    230 |
    231 |
    232 |
    233 |
    234 |
    235 |
    236 |
    237 |
    238 |
    239 |
    240 |
    241 |
    242 |
    243 | -------------------------------------------------------------------------------- /docs/common.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Helvetica Neue', 'Luxi Sans', 'DejaVu Sans', Tahoma, 'Hiragino Sans GB', STHeiti, 'Microsoft YaHei'; 3 | font-size: 15px; 4 | -webkit-font-smoothing: antialiased; 5 | margin: 0; 6 | } 7 | 8 | a { 9 | color: inherit; 10 | text-decoration: none; 11 | cursor: pointer; 12 | } 13 | -------------------------------------------------------------------------------- /docs/component-tree-with-jinkela.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinkelajs/jinkela/92b267300a532a3e2c8fb7f55e58737d4a624372/docs/component-tree-with-jinkela.png -------------------------------------------------------------------------------- /docs/component-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinkelajs/jinkela/92b267300a532a3e2c8fb7f55e58737d4a624372/docs/component-tree.png -------------------------------------------------------------------------------- /docs/docs.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --side-padding: 60px; 3 | } 4 | 5 | @media (max-width: 720px) { 6 | .docs { 7 | padding-top: 40px; 8 | } 9 | :root { 10 | --side-padding: 20px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jinkela 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/docs.js: -------------------------------------------------------------------------------- 1 | import { header } from './header.js'; 2 | import { mdView } from './md-view.js'; 3 | 4 | const { jkl } = Jinkela; 5 | 6 | const url = new URL(location.href); 7 | const d = url.searchParams.get('d'); 8 | 9 | const docs = { 10 | intro: ['./intro.md', '介绍'], 11 | api: ['./api.md', 'API'], 12 | }; 13 | 14 | if (!d) { 15 | url.searchParams.set('d', 'intro'); 16 | location.assign(url); 17 | } else { 18 | const mvArgs = docs[d]; 19 | const content = jkl` 20 |
    21 | ${header()} 22 | ${() => { 23 | if (mvArgs) return mdView(...mvArgs); 24 | return jkl`

    Document '${d}' not found.

    `; 25 | }} 26 |
    `; 27 | document.body.appendChild(content); 28 | } 29 | -------------------------------------------------------------------------------- /docs/header.css: -------------------------------------------------------------------------------- 1 | .header { 2 | line-height: 40px; 3 | box-sizing: border-box; 4 | background-color: #fff; 5 | box-shadow: 0 0 4px rgb(0 0 0 / 25%); 6 | padding: 25px var(--side-padding); 7 | position: relative; 8 | width: 100%; 9 | display: flex; 10 | z-index: 2; 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .header h1 { 16 | margin: 0 0 0 1ch; 17 | font-size: 1.5em; 18 | } 19 | 20 | .header .logo { 21 | width: 40px; 22 | height: 40px; 23 | line-height: 240px; 24 | font-size: 120px; 25 | border-radius: 100%; 26 | overflow: hidden; 27 | } 28 | 29 | .header .logo img { 30 | display: block; 31 | height: 100%; 32 | width: 100%; 33 | } 34 | 35 | .header .menu a { 36 | padding: 2px; 37 | margin-left: 20px; 38 | } 39 | 40 | .header .menu a.current { 41 | border-bottom: 3px solid #000; 42 | } 43 | 44 | .header .hamburger { 45 | display: none; 46 | } 47 | 48 | @media (max-width: 720px) { 49 | .header { 50 | position: fixed; 51 | top: 0; 52 | padding-top: 0; 53 | padding-bottom: 0; 54 | } 55 | .header .logo { 56 | width: 28px; 57 | height: 28px; 58 | } 59 | .header h1 { 60 | font-size: 1em; 61 | } 62 | .header .menu { 63 | display: none; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/header.js: -------------------------------------------------------------------------------- 1 | const { jkl, request } = Jinkela; 2 | 3 | const navState = request(() => { 4 | return fetch('./nav.json').then((r) => r.json()); 5 | }); 6 | 7 | export const header = () => { 8 | return jkl` 9 |
    10 | 13 |

    Jinkela v2

    14 |
    15 | 29 |
    30 | `; 31 | }; 32 | -------------------------------------------------------------------------------- /docs/home.css: -------------------------------------------------------------------------------- 1 | .home { 2 | display: flex; 3 | flex-direction: column; 4 | position: absolute; 5 | top: 50%; 6 | left: 0; 7 | right: 0; 8 | transform: translateY(-50%); 9 | text-align: center; 10 | } 11 | 12 | nav { 13 | height: 40px; 14 | line-height: 40px; 15 | text-align: center; 16 | } 17 | 18 | nav a { 19 | white-space: nowrap; 20 | text-decoration: none; 21 | color: #000; 22 | margin: 0 1em; 23 | position: relative; 24 | } 25 | 26 | nav a:hover::after { 27 | position: absolute; 28 | content: ''; 29 | background: #000; 30 | height: 3px; 31 | bottom: -5px; 32 | left: 0; 33 | right: 0; 34 | } 35 | 36 | .logo { 37 | width: 240px; 38 | height: 240px; 39 | line-height: 240px; 40 | font-size: 120px; 41 | margin: auto; 42 | margin-top: 50px; 43 | cursor: pointer; 44 | display: flex; 45 | justify-content: center; 46 | align-items: center; 47 | border-radius: 100%; 48 | overflow: hidden; 49 | } 50 | 51 | .logo img { 52 | position: relative; 53 | z-index: -1; 54 | display: block; 55 | max-width: 100%; 56 | max-height: 100%; 57 | } 58 | 59 | h1 { 60 | font-size: 48px; 61 | margin-top: 1em; 62 | } 63 | 64 | h1 a { 65 | text-decoration: none; 66 | color: inherit; 67 | } 68 | 69 | h2 { 70 | opacity: 0.5; 71 | margin-top: 1rem; 72 | color: #000; 73 | font-size: 1rem; 74 | font-weight: normal; 75 | } 76 | -------------------------------------------------------------------------------- /docs/home.js: -------------------------------------------------------------------------------- 1 | const { jkl, createState, request } = Jinkela; 2 | 3 | const navState = request(() => { 4 | return fetch('./nav.json').then((r) => r.json()); 5 | }); 6 | 7 | const title = 'Jinkela v2'; 8 | const description = '自认为是一个前端框架'; 9 | const content = jkl` 10 |
    11 | 20 | 23 |

    ${title}

    24 |

    ${description}

    25 |
    `; 26 | 27 | document.body.appendChild(content); 28 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jinkela 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Jinkela 是什么? 2 | 3 | Jinkela(金坷垃)是一种肥料添加剂 自认为是一个**前端框架**。 4 | 5 | 其核心思想是:**尽可能地使用规范内的特性,让代码无需构建就能在浏览器上跑起来**。 6 | 7 | 适合用来快速搭建一个小而轻的工具类页面,适合交互密集型的组件开发,在大型多人协作项目上可能没有优势。 8 | 9 | # 快速上手 10 | 11 | ## 1. 数据绑定 12 | 13 | Jinkela 的核心设计是将 JS 标准的字符串模板解析为组件。并且在字符串模板变量填充的位置实现了响应式的数据绑定机制。 14 | 15 | ```typescript 16 | import { jkl, createState } from 'jinkela'; 17 | 18 | const s = createState({ who: 'Jinkela' }); 19 | 20 | const div = jkl`

    Hello ${() => s.who}

    `; 21 | 22 | document.body.appendChild(div); 23 | ``` 24 | 25 | 看吧,是不是贼简单?Jinkela 会动态分析出 Hello 后面的文本节点所依赖的状态,一旦 `s.who` 的值变化,DOM 对应的文本节点数据也会随之变化。 26 | 27 | 你可以点击代码片段右上角的「Try」在新页面运行这段代码,同时可以在浏览器控制台修改 `s.who` 的值,观察页面的变化。 28 | 29 | 除了文本节点的数据绑定外,元素属性同样支持数据绑定,甚至一个元素属性可以部分是变量,比如下面是在 style 属性中加入变量: 30 | 31 | ```typescript 32 | import { jkl, createState } from 'jinkela'; 33 | 34 | const s = createState({ who: 'Jinkela', color: 'gold' }); 35 | 36 | const div = jkl` 37 |
    38 | Hello ${() => s.who} 39 |
    `; 40 | 41 | document.body.appendChild(div); 42 | ``` 43 | 44 | 当 `s.color` 改变时,页面显示的文字颜色也会随之变化。 45 | 46 | ## 2. 分支与循环 47 | 48 | 区别于一些现代前端框架通过私有元素属性(如 if、repeat)来组织页面,Jinkela 的写法更像 JSX,通过 js 原生的循环和判断来描述。比如下面这个例子,加载一份异步数据,在数据加载完成前,页面显示为加载中的状态,数据加载完成后,把数据以列表的形式渲染出来。 49 | 50 | ```typescript 51 | import { jkl, createState } from 'jinkela'; 52 | 53 | const s = createState({ loading: true, data: null }); 54 | 55 | setTimeout(() => { 56 | s.loading = false; 57 | s.data = [ 58 | { id: 1, name: 'Buildless' }, 59 | { id: 2, name: 'Lightweight' }, 60 | { id: 3, name: 'Responsive' }, 61 | ]; 62 | }, 1500); 63 | 64 | const div = jkl` 65 |
    66 |

    What are the benefits of Jinkela?

    67 | ${() => { 68 | if (s.loading) return jkl`Waiting...`; 69 | return jkl` 70 |
      71 | ${() => s.data.map((i) => jkl`
    • ${i.name}
    • `)} 72 |
    `; 73 | }} 74 |
    `; 75 | 76 | document.body.appendChild(div); 77 | ``` 78 | 79 | ## 3. 事件处理 80 | 81 | 给元素添加 `@` 开头的属性时候,Jinkela 会将其作为事件注册到元素上。比如下面这个例子就是给 div 里的 button 绑定了 click 事件。按钮点击之后往 `list` 里面增加一个 li 元素。每个 li 元素里面有一个 remove 按钮,点击后将从 `list` 中删除 li 自身。 82 | 83 | ```typescript 84 | import { jkl, createState } from 'jinkela'; 85 | 86 | const list = createState([]); 87 | 88 | const click = () => { 89 | const remove = () => { 90 | const index = list.indexOf(li); 91 | if (index !== -1) list.splice(index, 1); 92 | }; 93 | const li = jkl` 94 |
  • 95 | ${new Date()} 96 | 97 |
  • `; 98 | list.push(li); 99 | }; 100 | 101 | const div = jkl` 102 | 103 |
      ${list}
    `; 104 | 105 | document.body.appendChild(div); 106 | ``` 107 | 108 | ## x. 小结 109 | 110 | 1. 组件模板字符串 111 | 2. 分支循环写原生 112 | 3. 事件前面加 @ 113 | 114 | 你学废了吗?🎉🎉🎉 115 | 116 | # 获取与引用 117 | 118 | ## 1. 从 NPM 引入 119 | 120 | **Npm** 121 | 122 | ```shell,copy 123 | npm install jinkela --save 124 | ``` 125 | 126 | **Yarn** 127 | 128 | ```shell,copy 129 | yarn add jinkela 130 | ``` 131 | 132 | ## 2. 从 CDN 引入 133 | 134 | jsdelivr 135 | 136 | [iife](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) 方式引入: 137 | 138 | ```html,copy 139 | 140 | ``` 141 | 142 | [esm](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 方式引入: 143 | 144 | ```typescript,copy 145 | import { jkl } from 'https://cdn.jsdelivr.net/npm/jinkela@2.0.0-beta2/dist/index.esm.js'; 146 | ``` 147 | 148 |
    149 | UNPKG 150 |
    151 | 152 | [iife](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) 方式引入: 153 | 154 | ```html,copy 155 | 156 | ``` 157 | 158 | [esm](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 方式引入: 159 | 160 | ```typescript,copy 161 | import { jkl } from 'https://unpkg.com/jinkela@2.0.0-beta2/dist/index.esm.js'; 162 | ``` 163 | 164 | # 设计理念 165 | 166 | ## 1. 无构建 167 | 168 | Jinkela 第一版设计的出发点就是对当是的构建工具如 Grunt、Gulp、Webpack 之类的东西强烈不满,希望有一个无需构建用起来也不难受的框架。所以 Jinkela v2 也会不忘初心,依然坚持无构建可用。 169 | 170 | 这不是反潮流,我没有反对前端工程化。如果引入构建之后可以变得更好用那何乐不为呢?即便是基于 Jinkela 的项目,我有时也会用 Webpack 打包,用 TypeScript 来写。但有时候我希望 5 分钟做出一个简易的页面呢?当然是撸起袖子就是干啦,要是整个 Webpack 进来网络不好的话时间还不够 npm install。 171 | 172 | 有杠精可能会说,现在的前端框架全都是无构建可用的。这么说也对,但 Jinkela 是希望在无构建的时候也不难用。现在的前端框架,比如有些推荐使用 JSX,如果无构建使用,就得写一堆 createElement,能想象得多难用吗?Jinkela 永远不会把不能在浏览器原生跑起来的东西作为一种推荐用法。 173 | 174 | ## 2. 状态分离 175 | 176 | 绝大多数前端框架都将状态和视图一起包装成组件,「状态」一词潜移默化地变成了特指组件的状态。而在 Jinkela 的设计中,状态是可以单独存在的,视图与状态之间可以自由结合,是多对多的关系。 177 | 178 | 假如有两个无关的组件,他们都要展示当前时间,怎么写?一般的思路就是每个组件单独开计时器计算当前时间,带来的问题是一旦这样的组件用多了,整个页面就需要开启大量的定时器。优化一下的方案就是引入一个外部的状态管理器,两个组件共同订阅上面是时间数据,数据变化时去更新组件自己的状态。这个方案的思路是很清晰的,但有两个让人不舒服的点。一是要引入外部状态管理器,增加了外部依赖。二是要从外部的状态管理器将数据同步到组件的状态上,这个过程太绕了。 179 | 180 | 从外部状态管理器的普及程度来看,大家对「状态被限制在组件范围内」的前端框架是不满意的。既然要引入外部状态管理器,为什么不从框架设计层面就直接把这层屏障打开呢?这就好比是既然植物要吸收氮磷钾,为什么不直接让植物能够吸收地下两米的氮磷钾呢? 181 | 182 | 下面这张图是一个典型的页面对应的一棵组件树,从根组件开始,到一个个叶组件,每个组件都在维护自己的状态。 183 | 184 | ![](component-tree.png) 185 | 186 | 而 Jinkela 状态与视图分离的设计就可以让原本看似无关的组件复用同一个状态。这便是 Jinkela 的核心理念之 **状态属于 Model 而不是 View**。 187 | 188 | ![](component-tree-with-jinkela.png) 189 | 190 | 所以回到前面多组件展示当前时间的问题,Jinkela 的代码可以这么写。 191 | 192 | ```typescript 193 | import { jkl, createState } from 'jinkela'; 194 | 195 | const s = createState({ 196 | update() { 197 | const t = new Date(); 198 | this.hours = t.getHours(); 199 | this.minutes = t.getMinutes(); 200 | this.seconds = t.getSeconds(); 201 | setTimeout(() => this.update(), 100); 202 | }, 203 | }); 204 | 205 | s.update(); 206 | 207 | const c1 = jkl` 208 |

    209 | ${() => s.hours}:${() => s.minutes}:${() => s.seconds} 210 |

    `; 211 | 212 | const c2 = jkl` 213 |

    214 | Current Time: ${() => s.hours}:${() => s.minutes}:${() => s.seconds} 215 |

    `; 216 | 217 | document.body.appendChild(c1); 218 | document.body.appendChild(c2); 219 | ``` 220 | -------------------------------------------------------------------------------- /docs/md-view.css: -------------------------------------------------------------------------------- 1 | .md-view { 2 | padding: 0 var(--side-padding); 3 | position: relative; 4 | } 5 | 6 | .md-view aside { 7 | position: absolute; 8 | left: var(--side-padding); 9 | top: 0; 10 | width: 260px; 11 | } 12 | 13 | .md-view aside ul { 14 | line-height: 1.8em; 15 | list-style: none; 16 | padding: 0; 17 | color: #666; 18 | } 19 | 20 | .md-view aside ul ul { 21 | padding-left: 1em; 22 | font-size: 13px; 23 | } 24 | 25 | .md-view aside li.active { 26 | font-weight: bold; 27 | } 28 | 29 | .md-view aside li.visiting::after { 30 | content: '👁'; 31 | } 32 | 33 | .md-view article { 34 | padding-top: 2.2em; 35 | margin: 0 auto; 36 | max-width: 600px; 37 | line-height: 1.6em; 38 | color: #666; 39 | } 40 | 41 | .md-view article a { 42 | text-decoration: underline; 43 | } 44 | 45 | .md-view article h1 { 46 | margin: 1.5em 0 0.3em; 47 | padding: 0 0 1.2em; 48 | border-bottom: 1px solid #ddd; 49 | color: #333; 50 | } 51 | 52 | .md-view article h2 { 53 | margin: 2.5em 0 0.7em; 54 | padding: 0 0 0.5em; 55 | position: relative; 56 | color: #333; 57 | } 58 | 59 | .md-view article h3 { 60 | margin: 2.5em 0 0.7em; 61 | padding: 0 0 0.5em; 62 | position: relative; 63 | color: #333; 64 | } 65 | 66 | .md-view article [id]::before { 67 | content: ''; 68 | display: block; 69 | height: 10px; 70 | margin-top: -10px; 71 | } 72 | 73 | .md-view article img { 74 | margin: 1em 0; 75 | max-width: 100%; 76 | } 77 | 78 | .md-view article table { 79 | border-collapse: collapse; 80 | } 81 | 82 | .md-view article tr:nth-child(2n) { 83 | background-color: #f8f8f8; 84 | } 85 | 86 | .md-view article td, 87 | .md-view article th { 88 | padding: 6px 13px; 89 | border: 1px solid #ddd; 90 | } 91 | 92 | .md-view article pre { 93 | padding: 1em; 94 | position: relative; 95 | } 96 | 97 | .md-view article pre, 98 | .md-view article code { 99 | font-family: 'Roboto Mono', Monaco, courier, monospace; 100 | font-size: 12px; 101 | -webkit-font-smoothing: initial; 102 | } 103 | 104 | .md-view article pre { 105 | border-radius: 6px; 106 | } 107 | 108 | .md-view article .hljs { 109 | padding: 0; 110 | } 111 | 112 | .md-view article .hljs > div { 113 | padding: 1em; 114 | overflow: auto; 115 | } 116 | 117 | .md-view article code { 118 | color: #e96900; 119 | background-color: #f8f8f8; 120 | padding: 3px 5px; 121 | margin: 0 2px; 122 | border-radius: 2px; 123 | white-space: nowrap; 124 | } 125 | 126 | .md-view article strong { 127 | color: #333; 128 | font-weight: bold; 129 | } 130 | 131 | .md-view article h1:first-child { 132 | margin-top: 0; 133 | } 134 | 135 | .md-view pre > [role='button'] { 136 | font-size: 12px; 137 | padding: 5px 10px; 138 | line-height: 1.25; 139 | position: absolute; 140 | right: 0; 141 | top: 0; 142 | text-align: center; 143 | background: rgba(255, 255, 255, 0.2); 144 | border-radius: 0 0 0 7px; 145 | cursor: pointer; 146 | border: 0; 147 | color: inherit; 148 | transition: background-color 200ms; 149 | text-decoration: none; 150 | } 151 | 152 | .md-view pre > [role='button']:hover { 153 | background: rgba(255, 255, 255, 0.4); 154 | } 155 | 156 | .md-view__tip-on-mouse { 157 | color: green; 158 | font-weight: bold; 159 | font-family: Arial, Helvetica, sans-serif; 160 | position: fixed; 161 | z-index: 10; 162 | animation: tip-on-mouse 0.6s forwards linear; 163 | transform: translate(-50%, -100%); 164 | } 165 | 166 | @keyframes tip-on-mouse { 167 | 80% { 168 | opacity: 1; 169 | } 170 | to { 171 | transform: translateY(-30px) translate(-50%, -100%); 172 | opacity: 0; 173 | } 174 | } 175 | 176 | @media (max-width: 1280px) { 177 | .md-view article { 178 | margin-left: calc(260px + 1em); 179 | margin-right: 0; 180 | } 181 | } 182 | 183 | @media (max-width: 720px) { 184 | .md-view .hamburger { 185 | position: fixed; 186 | top: 13px; 187 | right: var(--side-padding); 188 | width: 18px; 189 | height: 15px; 190 | z-index: 3; 191 | background: #000; 192 | display: block; 193 | overflow: hidden; 194 | cursor: pointer; 195 | } 196 | .md-view .hamburger::before, 197 | .md-view .hamburger::after { 198 | content: ''; 199 | display: block; 200 | height: 3px; 201 | background: #fff; 202 | margin-top: 3px; 203 | } 204 | .md-view aside { 205 | transform: translateX(-200px); 206 | position: fixed !important; 207 | background: #f9f9f9; 208 | top: 40px; 209 | padding-left: var(--side-padding); 210 | box-sizing: border-box; 211 | height: 100%; 212 | z-index: 1; 213 | left: 0; 214 | width: 200px; 215 | box-shadow: 0 0 4px rgb(0 0 0 / 25%); 216 | overflow: auto; 217 | transition: transform 200ms ease; 218 | } 219 | .md-view.active aside { 220 | transform: translateX(0); 221 | } 222 | .md-view article { 223 | max-width: initial; 224 | margin: 0; 225 | } 226 | .md-view article h1:first-child { 227 | margin-top: 0; 228 | } 229 | .md-view main { 230 | padding: 1em; 231 | } 232 | .md-view article [id]::before { 233 | content: ''; 234 | display: block; 235 | height: 60px; 236 | margin-top: -60px; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /docs/md-view.js: -------------------------------------------------------------------------------- 1 | const { jkl, createState, request } = Jinkela; 2 | 3 | const tipOnMouse = (e) => { 4 | const ae = (e) => e.target.remove(); 5 | const tip = jkl` 6 |
    7 | Copied! 8 |
    `; 9 | document.body.appendChild(tip); 10 | }; 11 | 12 | const makeCodePreview = () => { 13 | const src = 'https://cdn.jsdelivr.net/npm/jinkela@2.0.0-beta2/dist/index.iife.js'; 14 | Array.from(document.querySelectorAll('pre.hljs'), (pre) => { 15 | if (pre.dataset.ext === 'copy') { 16 | const click = (e) => { 17 | const selection = getSelection(); 18 | const { rangeCount } = selection; 19 | const ranges = Array.from({ length: rangeCount }, (_, i) => selection.getRangeAt(i)); 20 | getSelection().selectAllChildren(pre.firstElementChild); 21 | document.execCommand('copy'); 22 | selection.removeAllRanges(); 23 | ranges.forEach((i) => selection.addRange(i)); 24 | tipOnMouse(e); 25 | }; 26 | pre.appendChild(jkl`Copy`); 27 | } else { 28 | const code = pre.textContent.replace(/^import (.*) from 'jinkela';$/gm, `const $1 = Jinkela;`); 29 | const href = URL.createObjectURL( 30 | new Blob( 31 | [ 32 | [ 33 | '', 34 | '', 35 | '', 36 | '', 37 | '', 38 | `', 42 | '', 43 | '', 44 | ].join('\n'), 45 | ], 46 | { type: 'text/html' }, 47 | ), 48 | ); 49 | pre.appendChild(jkl`Try`); 50 | } 51 | }); 52 | }; 53 | 54 | const de = document.documentElement; 55 | const pageState = createState({ menuPos: 'absolute', hash: location.hash }); 56 | addEventListener('scroll', () => (pageState.menuPos = de.scrollTop > 90 ? 'fixed' : 'absolute')); 57 | addEventListener('hashchange', () => (pageState.hash = location.hash)); 58 | 59 | const renderer = new marked.Renderer(); 60 | renderer.code = (code, rLang) => { 61 | const [lang, ext = ''] = rLang.split(/,/g); 62 | const language = hljs.getLanguage(lang) ? lang : 'text'; 63 | const { value } = hljs.highlight(code, { language }); 64 | return `
    ${value}
    `; 65 | }; 66 | renderer.link = (href, title, text) => { 67 | return `${text}`; 68 | }; 69 | marked.setOptions({ renderer }); 70 | 71 | /** 72 | * @param {string} src 73 | * @param {string} title 74 | * @returns {Node} 75 | */ 76 | export const mdView = (src, title) => { 77 | const md = request(() => 78 | fetch(`${src}?_=${Date.now()}`) 79 | .then((r) => { 80 | if (r.ok) return r.text(); 81 | throw new Error(`HTTP ${r.status}`); 82 | }) 83 | .then((text) => { 84 | const html = marked.parse(text); 85 | const node = jkl({ raw: [html] }); 86 | return node; 87 | }), 88 | ); 89 | 90 | const s = createState({}); 91 | 92 | addEventListener( 93 | 'scroll', 94 | () => { 95 | const hx = document.querySelectorAll('article [id]'); 96 | let c = null; 97 | for (let i of hx) { 98 | const { top } = i.getBoundingClientRect(); 99 | if (top - 1 > 0) break; 100 | c = i; 101 | } 102 | s.view = c ? c.id : null; 103 | }, 104 | { passive: true }, 105 | ); 106 | 107 | addEventListener('click', (e) => { 108 | if (e.fromAside) return; 109 | delete s.active; 110 | }); 111 | 112 | const hamburgerClick = (e) => { 113 | if (s.active) return; 114 | s.active = 'active'; 115 | e.stopPropagation(); 116 | }; 117 | const asideClick = (e) => { 118 | e.fromAside = true; 119 | }; 120 | 121 | return jkl` 122 |
    123 |
    124 | ${() => { 125 | if (md.loading) return jkl`

    Loading...

    `; 126 | if (md.error) return jkl`

    ${md.error}

    `; 127 | return jkl` 128 | 148 |
    149 | ${() => { 150 | if (!md.data) return null; 151 | setTimeout(() => { 152 | const id = decodeURIComponent(location.hash.slice(1)); 153 | document.getElementById(id)?.scrollIntoView(true); 154 | makeCodePreview(); 155 | }); 156 | return md.data.cloneNode(true); 157 | }} 158 |
    159 | `; 160 | }} 161 |
    `; 162 | }; 163 | -------------------------------------------------------------------------------- /docs/nav.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "href": "docs.html?d=intro", 4 | "text": "介绍" 5 | }, 6 | { 7 | "href": "docs.html?d=api", 8 | "text": "API" 9 | }, 10 | { 11 | "href": "https://github.com/jinkelajs/jinkela/tree/v2", 12 | "text": "GitHub", 13 | "target": "_blank" 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /docs/willan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | coverageDirectory: './coverage/', 6 | collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jinkela", 3 | "license": "MIT", 4 | "version": "2.0.0-beta2", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "start": "rollup -c -w", 10 | "build": "rollup -c -m", 11 | "test": "jest --coverage", 12 | "prettier": "prettier --write \"**/*.{js,ts,md,json,css,html}\"" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "devDependencies": { 18 | "@rollup/plugin-node-resolve": "^13.1.3", 19 | "@types/jest": "^27.4.0", 20 | "jest": "^27.4.7", 21 | "prettier": "^2.5.1", 22 | "rollup": "^2.66.0", 23 | "rollup-plugin-typescript2": "^0.31.1", 24 | "rollup-plugin-uglify": "^5.0.2", 25 | "ts-jest": "^27.1.3", 26 | "tslib": "^2.3.1", 27 | "typescript": "^4.5.5" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/jinkelajs/jinkela" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import { uglify } from 'rollup-plugin-uglify'; 4 | 5 | const resolve = (...args) => { 6 | return path.resolve(__dirname, ...args); 7 | }; 8 | 9 | const jinkela = { 10 | input: resolve('./src/index.ts'), 11 | output: [ 12 | { 13 | file: resolve('./dist/index.iife.js'), 14 | name: 'Jinkela', 15 | format: 'iife', 16 | }, 17 | { 18 | file: resolve('./dist/index.esm.js'), 19 | format: 'esm', 20 | }, 21 | { 22 | file: resolve('./dist/index.cjs.js'), 23 | format: 'cjs', 24 | }, 25 | ], 26 | plugins: [ 27 | typescript({ 28 | tsconfigOverride: { 29 | include: ['src', 'types.d.ts'], 30 | }, 31 | }), 32 | uglify(), 33 | ], 34 | }; 35 | 36 | module.exports = [jinkela]; 37 | -------------------------------------------------------------------------------- /src/AttributesManager.ts: -------------------------------------------------------------------------------- 1 | import { assertDefined, isValueType, uDiff, v2v, vl2s } from './utils'; 2 | import { live, touch } from './StateManager'; 3 | 4 | interface IPair { 5 | readonly type: 'pair'; 6 | name: any[]; 7 | value: any[]; 8 | } 9 | 10 | interface ISpread { 11 | readonly type: 'spread'; 12 | value: any; 13 | } 14 | 15 | type IAttrs = Record; 16 | type IEvents = Record void)[]>; 17 | 18 | export class AttributesManager { 19 | private list = [] as (IPair | ISpread)[]; 20 | private attrs: IAttrs = {}; 21 | private events: IEvents = {}; 22 | private cancelMap = new WeakMap void>(); 23 | private element?: Element; 24 | 25 | private restructure() { 26 | const attrs = {} as IAttrs; 27 | const events = {} as IEvents; 28 | const { list } = this; 29 | // Classify list items into attributes or events. 30 | // Left value will be override by right with same name. 31 | for (let i = 0; i < list.length; i++) { 32 | const item = list[i]; 33 | if (item.type === 'pair') { 34 | const { name, value } = item; 35 | const sName = vl2s(name); 36 | if (sName[0] == '@') { 37 | // Convert to a function array (remove all non-function items). 38 | events[sName.slice(1)] = value.filter((i) => typeof i === 'function'); 39 | } else { 40 | if (sName) attrs[sName] = value; 41 | } 42 | } 43 | // It's a spread type 44 | else { 45 | const { value } = item; 46 | const res = v2v(value); 47 | if (isValueType(res)) { 48 | attrs[String(res)] = ''; 49 | } else { 50 | const dict = Object(res); 51 | touch(dict); 52 | const keys = Object.keys(dict); 53 | for (let j = 0; j < keys.length; j++) { 54 | const name = keys[j]; 55 | if (name[0] == '@') { 56 | // Convert to a function array (remove all non-function items). 57 | events[name.slice(1)] = [].concat(dict[name]).filter((i) => typeof i === 'function'); 58 | } else if (dict[name] === null || dict[name] === undefined) { 59 | attrs[name] = null; 60 | } else { 61 | attrs[name] = String(dict[name]); 62 | } 63 | } 64 | } 65 | } 66 | } 67 | return { attrs, events }; 68 | } 69 | 70 | private updateEvents(events: IEvents) { 71 | const { element, events: prev } = this; 72 | assertDefined(element); 73 | uDiff(Object.keys(prev), Object.keys(events), { 74 | // New event, add all. 75 | add(name) { 76 | for (let i = 0; i < events[name].length; i++) { 77 | element.addEventListener(name, events[name][i]); 78 | } 79 | }, 80 | // Remove event, remove all listeners. 81 | delete(name) { 82 | for (let i = 0; i < prev[name].length; i++) { 83 | element.removeEventListener(name, prev[name][i]); 84 | } 85 | }, 86 | // Update event listener list. 87 | modify(name) { 88 | uDiff(prev[name], events[name], { 89 | add: (func) => element.addEventListener(name, func), 90 | delete: (func) => element.removeEventListener(name, func), 91 | modify() { 92 | // Event handler has set, nothing to do. 93 | }, 94 | }); 95 | }, 96 | }); 97 | this.events = events; 98 | } 99 | 100 | private setBindingAttr(name: string, fn: () => string) { 101 | const { element, cancelMap } = this; 102 | assertDefined(element); 103 | const oan = element.attributes.getNamedItem(name); 104 | // Has old attribute node, update it. 105 | if (oan) { 106 | cancelMap.get(oan)?.(); 107 | const cancel = live(fn, (v) => (oan.value = String(v))); 108 | cancelMap.set(oan, cancel); 109 | } 110 | // Has no attribute node, create new one. 111 | else { 112 | let an: Attr; 113 | if (name === 'xmlns') { 114 | // 'xmlns' is a special attribute, can only create by `createAttribute`; 115 | an = document.createAttribute(name); 116 | } else { 117 | // The `createAttribute` will change name to lower-case, 118 | // so use `createAttributeNS` to instead. 119 | an = document.createAttributeNS(null, name); 120 | } 121 | const cancel = live(fn, (v) => (an.value = String(v))); 122 | cancelMap.set(an, cancel); 123 | element.setAttributeNode(an); 124 | } 125 | } 126 | 127 | private unsetBindingAttr(name: string) { 128 | const { element, cancelMap } = this; 129 | assertDefined(element); 130 | const an = element.attributes.getNamedItem(name); 131 | if (!an) return; 132 | element.removeAttributeNode(an); 133 | const cancel = cancelMap.get(an); 134 | assertDefined(cancel); 135 | cancel(); 136 | cancelMap.delete(an); 137 | } 138 | 139 | private updateAttrs(attrs: IAttrs) { 140 | const { element, attrs: prev } = this; 141 | assertDefined(element); 142 | const update = (name: string) => { 143 | const value = attrs[name]; 144 | if (value instanceof Array) { 145 | this.setBindingAttr(name, () => vl2s(value)); 146 | } else if (value === null) { 147 | this.unsetBindingAttr(name); 148 | } else { 149 | this.setBindingAttr(name, () => value); 150 | } 151 | }; 152 | uDiff(Object.keys(prev), Object.keys(attrs), { 153 | add: update, 154 | delete: (name) => this.unsetBindingAttr(name), 155 | modify: update, 156 | }); 157 | this.attrs = attrs; 158 | } 159 | 160 | get(name: string) { 161 | const { list } = this; 162 | for (let i = list.length - 1; i >= 0; i--) { 163 | const n = list[i]; 164 | if (n.type === 'pair') { 165 | if (vl2s(n.name) === name) return vl2s(n.value); 166 | } else if (n.type === 'spread') { 167 | const obj = v2v(n.value); 168 | if (obj instanceof Object && name in obj) { 169 | return obj[name]; 170 | } 171 | } 172 | } 173 | return null; 174 | } 175 | 176 | addPair(name: any[], value: any[]) { 177 | this.list.push({ type: 'pair', name, value }); 178 | } 179 | 180 | addSpread(value: any) { 181 | this.list.push({ type: 'spread', value }); 182 | } 183 | 184 | bind(element: Element) { 185 | this.element = element; 186 | // Watch attributes list structure change (spread attributes may add or remove some attributes), 187 | // and update events and attributes. 188 | live( 189 | () => this.restructure(), 190 | ({ attrs, events }) => { 191 | this.updateEvents(events); 192 | this.updateAttrs(attrs); 193 | }, 194 | ); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/BasicBuilder.ts: -------------------------------------------------------------------------------- 1 | import { StringBuilder } from './StringBuilder'; 2 | import { SlotVar } from './utils'; 3 | 4 | export class BasicBuilder { 5 | protected index = 0; 6 | protected position = 0; 7 | 8 | /** 9 | * Read head chars, may be undefined in gap of template fragmetns. 10 | */ 11 | protected read(length = 1) { 12 | const c = this.look(length); 13 | this.position += length; 14 | return c; 15 | } 16 | 17 | /** 18 | * Get head chars, may be undefined in gap of template fragmetns. 19 | */ 20 | protected look(length = 1) { 21 | if (this.done) throw new Error('EOF'); 22 | const { frags } = this; 23 | // CASE 1: Read char from frag, if position > length of current frag. 24 | // CASE 2: Read from variables by index, if position === length of current frag. 25 | // CASE 3: Move to next index, if position > length of current frag. 26 | if (this.index < frags.length && this.position > frags[this.index].length) { 27 | this.index++; 28 | this.position = 0; 29 | } 30 | return this.frags[this.index].slice(this.position, this.position + length) || undefined; 31 | } 32 | 33 | get done() { 34 | const { frags } = this; 35 | let { index, position } = this; 36 | for (;;) { 37 | // Is't done, if index out of frags range. 38 | if (index >= frags.length) return true; 39 | // Try to read variable, but index has been the last frag, no any variable can be read. 40 | if (position === frags[index].length && index === frags.length - 1) return true; 41 | // It's not done, char or variable can be read. 42 | if (position <= frags[index].length) return false; 43 | // Position has fulled, reset position and move to next index. 44 | index++; 45 | position = 0; 46 | } 47 | } 48 | 49 | protected getVariable() { 50 | return this.vars[this.index]; 51 | } 52 | 53 | protected readUntil(pattern: string | ((c: string) => boolean), noVariables = false) { 54 | const list = []; 55 | const sb = new StringBuilder((v) => list.push(v)); 56 | const t = typeof pattern === 'string' ? (c: string) => pattern.indexOf(c) !== -1 : pattern; 57 | for (;;) { 58 | if (this.done) break; 59 | const c = this.look(); 60 | if (c) { 61 | if (t(c)) break; 62 | this.read(); 63 | sb.append(c); 64 | } else { 65 | if (noVariables) break; 66 | sb.commit(); 67 | this.read(); 68 | list.push(this.getVariable()); 69 | } 70 | } 71 | sb.commit(); 72 | return list; 73 | } 74 | 75 | constructor(protected frags: string[] | ArrayLike, protected vars: SlotVar[]) {} 76 | } 77 | -------------------------------------------------------------------------------- /src/HtmlBuilder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertDefined, 3 | assertInstanceOf, 4 | assertNotNull, 5 | assertToken, 6 | isSelfClosingTag, 7 | isValueType, 8 | v2v, 9 | vl2s, 10 | } from './utils'; 11 | import type { SlotVar } from './utils'; 12 | import { live, touch } from './StateManager'; 13 | import { StringBuilder } from './StringBuilder'; 14 | import { BasicBuilder } from './BasicBuilder'; 15 | import { domListAssign } from './domListAssign'; 16 | import { IndexedArray } from './IndexedArray'; 17 | import { AttributesManager } from './AttributesManager'; 18 | 19 | const isNotAlpha = RegExp.prototype.test.bind(/[^a-zA-Z0-9]/); 20 | 21 | const rawTextTagNames = new Set(['STYLE', 'XMP', 'IFRAME', 'NOEMBED', 'NOFRAMES', 'SCRIPT', 'NOSCRIPT']); 22 | 23 | const rcDataTagNames = new Set(['TITLE', 'TEXTAREA']); 24 | 25 | interface ReadAttributes { 26 | /** 27 | * 0: EOF 28 | * 1: Normal Element 29 | * 2: Self-Closing Element 30 | */ 31 | state: 0 | 1 | 2; 32 | attrsManager: AttributesManager; 33 | } 34 | 35 | export class HtmlBuilder extends BasicBuilder { 36 | public root = document.createDocumentFragment(); 37 | private current: Node = this.root; 38 | 39 | private readContent() { 40 | const sb = new StringBuilder((s: string) => { 41 | this.current.appendChild(document.createTextNode(s)); 42 | }); 43 | for (;;) { 44 | if (this.done) { 45 | sb.commit(); 46 | break; 47 | } 48 | const c = this.look(); 49 | // Tag or Comment. 50 | if (c === '<') { 51 | const h3 = this.look(3); 52 | assertDefined(h3); 53 | // It's a comment, such as , or , or . 54 | if (h3[1] === '!' || h3[1] === '?') { 55 | sb.commit(); 56 | this.readComment(); 57 | } else { 58 | const hasSlash = h3[1] === '/'; 59 | const leading = hasSlash ? h3[2] : h3[1]; 60 | // It's probably a variable tag, if no leading character found, such as <${x}>. 61 | // It's a tag, if starts with ASCII alpha characters, such as
    . 62 | if (!leading || /[a-zA-Z]/.test(leading)) { 63 | sb.commit(); 64 | this.readTag(); 65 | } 66 | // It's a comment, if starts with slash, such as . 67 | else if (hasSlash) { 68 | sb.commit(); 69 | this.readComment(); 70 | } 71 | // It's a text, such as <123>. 72 | else { 73 | this.read(); 74 | sb.append(c); 75 | } 76 | } 77 | } 78 | // HTML Entity. 79 | else if (c === '&') { 80 | sb.append(this.readEntity()); 81 | } 82 | // Variable. 83 | else if (!c) { 84 | sb.commit(); 85 | this.read(); 86 | // Create a comment node as placeholder. 87 | const cmt = document.createComment(` slot ${this.index} `); 88 | this.current.appendChild(cmt); 89 | // The list must not be empty in any time. 90 | let list = new IndexedArray([cmt]); 91 | const variable = this.getVariable(); 92 | live( 93 | () => { 94 | const value = v2v(variable); 95 | touch(value); 96 | return value; 97 | }, 98 | (value) => (list = domListAssign(list, value)), 99 | ); 100 | } 101 | // Normal char. 102 | else { 103 | this.read(); 104 | sb.append(c); 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state 111 | * https://html.spec.whatwg.org/multipage/parsing.html#rawtext-state 112 | */ 113 | private readRawData(parseEntity = false) { 114 | assertInstanceOf(this.current, HTMLElement); 115 | const list: SlotVar[] = []; 116 | const { tagName } = this.current; 117 | for (;;) { 118 | list.push(...this.readUntil('<&')); 119 | if (this.done) break; 120 | // Find closing tag. 121 | if (this.look(2) === ' vl2s(list), 139 | (text) => { 140 | if (node) { 141 | node.data = text; 142 | } else { 143 | node = document.createTextNode(text); 144 | this.current.appendChild(node); 145 | const { parentNode } = this.current; 146 | assertNotNull(parentNode); 147 | this.current = parentNode; 148 | } 149 | }, 150 | ); 151 | } 152 | 153 | private readEntity() { 154 | assertToken(this.read(), '&'); 155 | const sharp = !this.done && this.look() === '#' ? '#' : ''; 156 | if (sharp) this.read(); 157 | const name = this.readUntil(isNotAlpha).join(''); 158 | const colon = !this.done && this.look() === ';' ? ';' : ''; 159 | if (colon) this.read(); 160 | // Create an element to parse html entity. 161 | const div = document.createElement('div'); 162 | div.innerHTML = '&' + sharp + name + colon; 163 | const { textContent } = div; 164 | assertNotNull(textContent); 165 | return textContent; 166 | } 167 | 168 | private readTagName() { 169 | const h2 = this.look(2); 170 | assertDefined(h2); 171 | assertToken(h2[0], '<'); 172 | 173 | const isClosing = h2[1] === '/' ? '/' : ''; 174 | this.read(isClosing ? 2 : 1); 175 | 176 | // https://html.spec.whatwg.org/multipage/parsing.html#tag-name-state 177 | const tagName = this.readUntil('\t\n\f />\x00').join(''); 178 | 179 | return { tagName, isClosing }; 180 | } 181 | 182 | private readTag() { 183 | const { tagName, isClosing } = this.readTagName(); 184 | 185 | if (this.done) { 186 | if (!tagName) this.current.appendChild(document.createTextNode('<' + isClosing)); 187 | return; 188 | } 189 | 190 | const { state, attrsManager } = this.readAttributes(); 191 | if (state === 0) return; 192 | 193 | const isSelfClosing = state === 2; 194 | 195 | if (isClosing) { 196 | // Find nearest matched element and close it. 197 | for (let i: Node | null = this.current; i instanceof Element; i = i.parentNode) { 198 | if (i.tagName.toUpperCase() == tagName.toUpperCase()) { 199 | const { parentNode } = i; 200 | assertNotNull(parentNode); 201 | this.current = parentNode; 202 | break; 203 | } 204 | } 205 | } else { 206 | // Create element. 207 | const xmlns = attrsManager.get('xmlns'); 208 | let element; 209 | if (xmlns) { 210 | element = document.createElementNS(xmlns, tagName); 211 | } else { 212 | if (this.current instanceof Element) { 213 | element = document.createElementNS(this.current.namespaceURI, tagName); 214 | } else { 215 | element = document.createElement(tagName); 216 | } 217 | } 218 | 219 | attrsManager.bind(element); 220 | 221 | this.current.appendChild(element); 222 | this.current = element; 223 | 224 | // Handle self-closing tag 225 | if ( 226 | (!(this.current instanceof HTMLElement) && isSelfClosing) || 227 | (this.current instanceof HTMLElement && isSelfClosingTag(this.current.tagName)) 228 | ) { 229 | const { parentNode } = this.current; 230 | assertNotNull(parentNode); 231 | this.current = parentNode; 232 | } else if (this.current instanceof HTMLElement) { 233 | // https://html.spec.whatwg.org/multipage/parsing.html#parsing-html-fragments 234 | if (rawTextTagNames.has(this.current.tagName)) { 235 | this.readRawData(); 236 | } else if (rcDataTagNames.has(this.current.tagName)) { 237 | this.readRawData(true); 238 | } 239 | } 240 | } 241 | } 242 | 243 | private readComment() { 244 | const h2 = this.read(2); 245 | assertDefined(h2); 246 | assertToken(h2[0], '<'); 247 | 248 | const type = h2[1]; 249 | assertToken(type, '!', '?', '/'); 250 | 251 | // Detect it's a standard html comment or not. 252 | const isStandard = type === '!' && this.look(2) === '--'; 253 | if (isStandard) this.read(2); 254 | 255 | let list: SlotVar[] = []; 256 | 257 | if (isStandard) { 258 | list = this.readUntil((c) => { 259 | return c === '-' && this.look(3) === '-->'; 260 | }); 261 | this.read(3); 262 | } else { 263 | list = this.readUntil('>'); 264 | this.read(); 265 | if (type === '?') list.unshift('?'); 266 | } 267 | 268 | let comment: Comment; 269 | live( 270 | () => vl2s(list), 271 | (text) => { 272 | if (comment) { 273 | comment.data = text; 274 | } else { 275 | comment = document.createComment(text); 276 | this.current.appendChild(comment); 277 | } 278 | }, 279 | ); 280 | } 281 | 282 | static isNotAttrSpace = (c: string) => '\x09\x0a\x0c\x20'.indexOf(c) === -1; 283 | private readAttrSpace() { 284 | return this.readUntil(HtmlBuilder.isNotAttrSpace, true).join(''); 285 | } 286 | 287 | /** 288 | * 289 | */ 290 | private readAttributes(): ReadAttributes { 291 | const attrsManager = new AttributesManager(); 292 | for (let i = 0; i < 1000; i++) { 293 | const what = this.readAttrNameOrSpread(); 294 | 295 | // EOF found. 296 | if (what === false) return { state: 0, attrsManager }; 297 | 298 | // It's a spread attributes. 299 | if (what.type === 'spread') { 300 | attrsManager.addSpread(what.value); 301 | continue; 302 | } 303 | 304 | // It's a attribute name. 305 | const name = what.value; 306 | 307 | // There may be spaces after attribute name. 308 | this.readAttrSpace(); 309 | if (this.done) return { state: 0, attrsManager }; 310 | 311 | const c = this.look(); 312 | 313 | // If it's a name=value pair. 314 | if (c === '=') { 315 | this.read(); 316 | this.readAttrSpace(); 317 | if (this.done) return { state: 0, attrsManager }; 318 | const value = this.readAttrValue(); 319 | if (name.length) attrsManager.addPair(name, value); 320 | continue; 321 | } 322 | 323 | // It's name only. 324 | if (name.length) attrsManager.addPair(name, ['']); 325 | 326 | // It's the end of the tag, bind attributes to element and return. 327 | if (c === '>') { 328 | this.read(); 329 | return { state: 1, attrsManager }; 330 | } else if (c === '/' && this.look(2) === '/>') { 331 | this.read(2); 332 | return { state: 2, attrsManager }; 333 | } 334 | } 335 | 336 | throw RangeError('Too many attributes.'); 337 | } 338 | 339 | private readAttrNameOrSpread() { 340 | this.readAttrSpace(); 341 | if (this.done) return false; 342 | let name: any[] = []; 343 | 344 | // Read leading variable. 345 | // It may be a variable attribute name such as , 346 | // or spread attributes such as . 347 | if (!this.look()) { 348 | this.read(); 349 | let lv = this.getVariable(); 350 | if (lv === null || lv === undefined) { 351 | // Nothing to do. 352 | } 353 | // It's an attribute name. 354 | else if (isValueType(lv)) { 355 | name.push(lv); 356 | } 357 | // Need to check following characters. 358 | else { 359 | const space = this.readAttrSpace(); 360 | if (this.done) return false; 361 | const following = this.look(); 362 | // It's spread attributes. , and , and 363 | // are spread attributes, but is not. 364 | if (following === '>' || following === '/' || (space && following !== '=')) { 365 | return { type: 'spread', value: lv } as const; 366 | } 367 | // It's an attribute name. 368 | else { 369 | name.push(lv); 370 | } 371 | } 372 | } 373 | 374 | // Read attribute name 375 | // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state 376 | const list = this.readUntil('\t\n\f >/='); 377 | name = name.concat(list); 378 | 379 | return { type: 'name', value: name } as const; 380 | } 381 | 382 | private readAttrValue() { 383 | const value = []; 384 | const h = this.look(); 385 | // It can only be one of single quote, or double quote, or empty string. 386 | const boundary = h === '"' || h === "'" ? h : ''; 387 | if (boundary) this.read(); 388 | 389 | if (boundary) { 390 | const list = this.readUntil(boundary); 391 | this.read(); 392 | return list; 393 | } else { 394 | return this.readUntil('\t\r\n\f >'); 395 | } 396 | } 397 | 398 | constructor(frags: string[] | ArrayLike, vars: SlotVar[]) { 399 | super(frags, vars); 400 | this.readContent(); 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/IndexedArray.ts: -------------------------------------------------------------------------------- 1 | export class IndexedArray { 2 | private list: T[] = []; 3 | private map = new Map(); 4 | constructor(array?: T[]) { 5 | if (array) { 6 | for (let i = 0; i < array.length; i++) this.push(array[i]); 7 | } 8 | } 9 | get length() { 10 | return this.list.length; 11 | } 12 | set(index: number, value: T) { 13 | this.list[index] = value; 14 | } 15 | get(index: number) { 16 | if (index >= this.length) return null; 17 | return this.list[index]; 18 | } 19 | indexOf(value: T) { 20 | return this.map.get(value) ?? -1; 21 | } 22 | includes(value: T) { 23 | return this.map.has(value); 24 | } 25 | push(value: T) { 26 | const length = this.list.push(value); 27 | this.map.set(value, length - 1); 28 | return length; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/PairSet.ts: -------------------------------------------------------------------------------- 1 | export class PairSet { 2 | private storage = new Map>(); 3 | add(a: A, b: B) { 4 | const s = this.storage.get(a); 5 | if (s) { 6 | s.add(b); 7 | } else { 8 | this.storage.set(a, new Set([b])); 9 | } 10 | } 11 | delete(a: A, b: B) { 12 | const s = this.storage.get(a); 13 | if (s) return s.delete(b); 14 | return false; 15 | } 16 | forEach(cb: (a: A, b: B) => void) { 17 | this.storage.forEach((s, a) => { 18 | s.forEach((b) => cb(a, b)); 19 | }); 20 | } 21 | clear() { 22 | this.storage.forEach((s) => s.clear()); 23 | this.storage.clear(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/StateManager.ts: -------------------------------------------------------------------------------- 1 | import { debounce, removeDebounce } from './debounce'; 2 | import { PairSet } from './PairSet'; 3 | import { assertDefined, Fn } from './utils'; 4 | 5 | type Recording = undefined | PairSet; 6 | let recording: Recording = undefined; 7 | const recordingStack: Recording[] = [recording]; 8 | 9 | const handlerMap = new WeakMap<() => void, PairSet>(); 10 | const stateEtMap = new WeakMap(); 11 | 12 | const executeAndRecordDeps = any>(fn: T, onUpdate: Fn): ReturnType => { 13 | try { 14 | // Setup a new recording stack head first, and execute `fn`. 15 | recordingStack.push(recording); 16 | recording = new PairSet(); 17 | return fn(); 18 | } finally { 19 | assertDefined(recording); 20 | const debouncedOnUpdate = debounce(onUpdate); 21 | const old = handlerMap.get(onUpdate); 22 | // Add event listeners for new dependencies. 23 | recording.forEach((et, name) => { 24 | if (old?.delete(et, name)) return; 25 | et.addEventListener(name, debouncedOnUpdate); 26 | }); 27 | // Remove unused event listeners. 28 | old?.forEach((et, name) => { 29 | et.removeEventListener(name, debouncedOnUpdate); 30 | }); 31 | // Save and recover recording stack head. 32 | handlerMap.set(onUpdate, recording); 33 | recording = recordingStack.pop() as Recording; 34 | } 35 | }; 36 | 37 | const cleanUpListeners = (onUpdate: Fn) => { 38 | const recording = handlerMap.get(onUpdate); 39 | if (!recording) return; 40 | const debouncedOnUpdate = debounce(onUpdate); 41 | recording.forEach((et, name) => et.removeEventListener(name, debouncedOnUpdate)); 42 | removeDebounce(onUpdate); 43 | recording.clear(); 44 | handlerMap.delete(onUpdate); 45 | }; 46 | 47 | export const touch = (obj: unknown) => { 48 | assertDefined(recording); 49 | const et = stateEtMap.get(obj); 50 | if (!et) return; 51 | recording.add(et, '*'); 52 | }; 53 | 54 | /** 55 | * Get and watch value, handler function will be called immediately first time. 56 | * @returns A 'cancel' function to stop watch. 57 | */ 58 | export const live = any>(fn: T, handler: (value: ReturnType) => void) => { 59 | // Execute function with `executeAndRecordDeps`. 60 | // The `update` function will be called while any dependencies change. 61 | const update = () => { 62 | // The return value of function will be passed to handler. 63 | handler(executeAndRecordDeps(fn, update)); 64 | }; 65 | update(); 66 | // Return a 'cancel' function to stop watch. 67 | return () => cleanUpListeners(update); 68 | }; 69 | 70 | /** 71 | * Create a proxied state object, any property values change could be watched. 72 | */ 73 | export const createState = >(value: T) => { 74 | const isArray = value instanceof Array; 75 | const et = new EventTarget(); 76 | const dispatch = (key: string | symbol) => { 77 | if (isArray) { 78 | et.dispatchEvent(new CustomEvent('*')); 79 | } else if (typeof key === 'string') { 80 | et.dispatchEvent(new CustomEvent('*')); 81 | et.dispatchEvent(new CustomEvent(key)); 82 | } 83 | }; 84 | const state = new Proxy(value, { 85 | set(target, key, value) { 86 | const oValue = target[key]; 87 | target[key as keyof typeof target] = value; 88 | // Dispatch event only when value changed actually. 89 | if (oValue !== value) dispatch(key); 90 | return true; 91 | }, 92 | deleteProperty(target, key) { 93 | // Dispatch event only when value changed actually. 94 | if (key in target) dispatch(key); 95 | return delete target[key]; 96 | }, 97 | get(target, key) { 98 | // Record the property as a dependency, if recording currently. 99 | if (recording) { 100 | if (isArray) { 101 | recording.add(et, '*'); 102 | } else if (typeof key === 'string') { 103 | recording.add(et, key); 104 | } 105 | } 106 | return target[key]; 107 | }, 108 | }); 109 | stateEtMap.set(state, et); 110 | return state; 111 | }; 112 | -------------------------------------------------------------------------------- /src/StringBuilder.ts: -------------------------------------------------------------------------------- 1 | export class StringBuilder { 2 | private str = ''; 3 | constructor(private action?: (s: string) => void) {} 4 | commit() { 5 | if (!this.str) return; 6 | const { str } = this; 7 | this.action?.(str); 8 | this.str = ''; 9 | return str; 10 | } 11 | append(part: string) { 12 | this.str += part; 13 | } 14 | valueOf() { 15 | return this.str; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/debounce.ts: -------------------------------------------------------------------------------- 1 | import { Fn } from './utils'; 2 | 3 | const cache = new WeakMap(); 4 | const waitingSet = new Set(); 5 | 6 | export const debounce = (fn: Fn) => { 7 | // Try to get from cache first. 8 | let dfn = cache.get(fn); 9 | if (dfn) return dfn; 10 | // Create `dfn` 11 | dfn = () => { 12 | // Has been called, don't performs it duplicately. 13 | if (waitingSet.has(fn)) return; 14 | // Put it to waitingSet and waiting for next tick. 15 | waitingSet.add(fn); 16 | Promise.resolve().then(() => { 17 | // Has removed from waitingSet, that may be `digestImmediately` or `removeDebounce`, nothing to do. 18 | if (!waitingSet.has(fn)) return; 19 | // Remove it from waitingSet and execute `fn`. 20 | waitingSet.delete(fn); 21 | fn(); 22 | }); 23 | }; 24 | // Cache it, don't create `dfn` duplicately. 25 | // The sets {fn} and {dfb} must be a bijective mapping. 26 | cache.set(fn, dfn); 27 | return dfn; 28 | }; 29 | 30 | export const removeDebounce = (fn: Fn) => { 31 | cache.delete(fn); 32 | waitingSet.delete(fn); 33 | }; 34 | 35 | export const digestImmediately = () => { 36 | waitingSet.forEach((fn) => { 37 | // Remove it from waitingSet and execute `fn`. 38 | waitingSet.delete(fn); 39 | fn(); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/domListAssign.ts: -------------------------------------------------------------------------------- 1 | import { IndexedArray } from './IndexedArray'; 2 | import { assertNotNull } from './utils'; 3 | 4 | const fragNodesMap = new WeakMap(); 5 | 6 | const getChildNodesFromDF = (frag: DocumentFragment) => { 7 | const list = fragNodesMap.get(frag); 8 | if (list) return list; 9 | const newList = Array.from(frag.childNodes); 10 | fragNodesMap.set(frag, newList); 11 | return newList; 12 | }; 13 | 14 | const toNodeList = (value: any) => { 15 | const array = new IndexedArray(); 16 | const collect = (item: any) => { 17 | if (item instanceof DocumentFragment) { 18 | const childNodes = getChildNodesFromDF(item); 19 | for (let i = 0; i < childNodes.length; i++) { 20 | array.push(childNodes[i]); 21 | } 22 | } else if (item instanceof Node) array.push(item); 23 | else if (item === null || item === undefined) array.push(document.createComment(` ${item} `)); 24 | else array.push(document.createTextNode(String(item))); 25 | }; 26 | if (value instanceof Array) { 27 | for (let i = 0; i < value.length; i++) collect(value[i]); 28 | } else collect(value); 29 | // Put a comment node as placeholder to prevent array empty. 30 | if (array.length === 0) array.push(document.createComment(` empty list `)); 31 | return array; 32 | }; 33 | 34 | export const domListAssign = (oldList: IndexedArray, newValue: any) => { 35 | // Convert to Node[]. 36 | const newList = toNodeList(newValue); 37 | 38 | const firstItem = oldList.get(0); 39 | assertNotNull(firstItem); 40 | 41 | const { parentNode } = firstItem; 42 | assertNotNull(parentNode); 43 | 44 | // Classfiy 45 | const intersection = [] as Node[]; 46 | for (let i = 0; i < newList.length; i++) { 47 | const n = newList.get(i); 48 | assertNotNull(n); 49 | if (oldList.includes(n)) { 50 | intersection.push(n); 51 | } 52 | } 53 | let anchor: Node | null = null; 54 | const removing: Node[] = []; 55 | for (let i = 0; i < oldList.length; i++) { 56 | const o = oldList.get(i); 57 | assertNotNull(o); 58 | if (newList.includes(o)) { 59 | if (!anchor) anchor = o; 60 | } else { 61 | removing.push(o); 62 | } 63 | } 64 | 65 | // Sort intersection. 66 | for (let i = 0; i < intersection.length; i++) { 67 | const n = intersection[i]; 68 | assertNotNull(n); 69 | if (n === anchor) { 70 | anchor = n.nextSibling; 71 | // Move to next sibling if anchor in removing set, it's used for performance. 72 | while (anchor && oldList.includes(anchor) && !newList.includes(anchor)) { 73 | anchor = anchor.nextSibling; 74 | } 75 | } else { 76 | parentNode.insertBefore(n, anchor); 77 | } 78 | } 79 | 80 | // Insert addition. 81 | { 82 | let cursor = 0; 83 | let anchor: Node | null = null; 84 | for (let i = 0; i < intersection.length; i++) { 85 | anchor = intersection[i]; 86 | assertNotNull(anchor); 87 | const index = newList.indexOf(anchor); 88 | while (cursor < index) { 89 | const a = newList.get(cursor); 90 | assertNotNull(a); 91 | parentNode.insertBefore(a, anchor); 92 | cursor++; 93 | } 94 | cursor++; 95 | } 96 | if (anchor) { 97 | anchor = anchor.nextSibling; 98 | } else { 99 | anchor = oldList.get(0); 100 | } 101 | while (cursor < newList.length) { 102 | const a = newList.get(cursor); 103 | assertNotNull(a); 104 | parentNode.insertBefore(a, anchor); 105 | cursor++; 106 | } 107 | } 108 | 109 | // Delete 110 | for (let i = 0; i < removing.length; i++) { 111 | parentNode.removeChild(removing[i]); 112 | } 113 | 114 | return newList; 115 | }; 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { HtmlBuilder } from './HtmlBuilder'; 2 | import { SlotVar } from './utils'; 3 | import { createState } from './StateManager'; 4 | import { request } from './stdlib/request'; 5 | 6 | export { createState, request }; 7 | 8 | export const jkl = ({ raw }: { raw: ArrayLike }, ...vars: SlotVar[]) => { 9 | const { root } = new HtmlBuilder(raw, vars); 10 | return root; 11 | }; 12 | -------------------------------------------------------------------------------- /src/stdlib/request.ts: -------------------------------------------------------------------------------- 1 | import { createState } from '../StateManager'; 2 | 3 | export const request = (asyncFunction: () => Promise) => { 4 | const state = createState<{ loading: boolean; data: null | T; error: any }>({ 5 | loading: true, 6 | data: null, 7 | error: null, 8 | }); 9 | asyncFunction().then( 10 | (data) => { 11 | state.loading = false; 12 | state.data = data; 13 | }, 14 | (error) => { 15 | state.loading = false; 16 | state.error = error; 17 | }, 18 | ); 19 | return state; 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const SC_TAGS = new Set([ 2 | 'AREA', 3 | 'BASE', 4 | 'BR', 5 | 'COL', 6 | 'EMBED', 7 | 'HR', 8 | 'IMG', 9 | 'INPUT', 10 | 'LINK', 11 | 'META', 12 | 'PARAM', 13 | 'SOURCE', 14 | 'TRACK', 15 | 'WBR', 16 | ] as const); 17 | 18 | export const isSelfClosingTag = (tagName: string): tagName is typeof SC_TAGS extends Set ? U : never => { 19 | return SC_TAGS.has(tagName as any); 20 | }; 21 | 22 | export function assertInstanceOf any>( 23 | what: T, 24 | clz: C, 25 | ): asserts what is InstanceType { 26 | if (!(what instanceof clz)) throw new TypeError('Assert InstanceOf'); 27 | } 28 | 29 | export function assertDefined(what: T): asserts what is Exclude { 30 | if (what === undefined) throw new TypeError('Assert Defined'); 31 | } 32 | 33 | export function assertNotNull(what: T): asserts what is Exclude { 34 | if (what === null) throw new TypeError('Assert Not Null'); 35 | } 36 | 37 | export function assertToken(what: unknown, ...tokens: T[]): asserts what is T { 38 | if (tokens.indexOf(what as any) === -1) throw new TypeError(`Assert Token ${tokens.map((i) => `'${i}'`).join(', ')}`); 39 | } 40 | 41 | export function isValueType(what: unknown): what is string | number | boolean { 42 | return typeof what === 'string' || typeof what === 'number' || typeof what === 'boolean'; 43 | } 44 | 45 | export type Var = number | string | boolean | null | undefined | Node; 46 | 47 | export type VarOrList = Var | Var[]; 48 | 49 | export type SlotVar = VarOrList | (() => VarOrList) | ((e?: Event) => void); 50 | 51 | export type Fn = () => void; 52 | 53 | export const uDiff = (o: T[], n: T[], a: Record<'add' | 'delete' | 'modify', (i: T) => void>) => { 54 | const s = new Set(n); 55 | for (let i = 0; i < o.length; i++) { 56 | if (s.delete(o[i])) { 57 | a.modify(o[i]); 58 | } else { 59 | a.delete(o[i]); 60 | } 61 | } 62 | s.forEach((i) => a.add(i)); 63 | }; 64 | 65 | /** 66 | * Convert Variable to Value. 67 | */ 68 | export const v2v = (w: unknown) => (typeof w === 'function' ? w() : w); 69 | 70 | /** 71 | * Convert Variable List to String. 72 | */ 73 | export const vl2s = (a: any[]) => a.map(v2v).join(''); 74 | -------------------------------------------------------------------------------- /tests/BasicBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { BasicBuilder } from '../src/BasicBuilder'; 2 | 3 | const r = 4 | (Builder: T) => 5 | ({ raw }: any, ...vars: any[]) => 6 | new Builder(raw, vars) as InstanceType; 7 | 8 | test('Empty', () => { 9 | r( 10 | class extends BasicBuilder { 11 | __test() { 12 | expect(this.done).toBe(true); 13 | } 14 | }, 15 | )``.__test(); 16 | }); 17 | 18 | test('Only 1 Variable', () => { 19 | r( 20 | class extends BasicBuilder { 21 | __test() { 22 | expect(this.done).toBe(false); 23 | expect(this.read()).toBeUndefined(); 24 | expect(this.getVariable()).toBe(123); 25 | expect(this.done).toBe(true); 26 | } 27 | }, 28 | )`${123}`.__test(); 29 | }); 30 | 31 | test('.look() and .read()', () => { 32 | r( 33 | class extends BasicBuilder { 34 | __test() { 35 | expect(this.look()).toBe('<'); 36 | expect(this.look(6)).toBe(''); 37 | expect(this.read(6)).toBe(''); 38 | expect(this.look(7)).toBe(''); 39 | expect(this.read(7)).toBe(''); 40 | expect(this.done).toBe(true); 41 | } 42 | }, 43 | )``.__test(); 44 | }); 45 | 46 | test('.look() and .read() with Variables', () => { 47 | r( 48 | class extends BasicBuilder { 49 | __test() { 50 | expect(this.read(6)).toBe(''); 51 | expect(this.look()).toBe(undefined); 52 | expect(this.read()).toBe(undefined); 53 | expect(this.getVariable()).toBe(1); 54 | expect(this.look()).toBe('-'); 55 | expect(this.read()).toBe('-'); 56 | expect(this.look(100)).toBe(undefined); 57 | expect(this.read(100)).toBe(undefined); 58 | expect(this.look()).toBe('-'); 59 | expect(this.read()).toBe('-'); 60 | expect(this.look(1)).toBe(undefined); 61 | expect(this.read(1)).toBe(undefined); 62 | expect(this.read(7)).toBe(''); 63 | expect(this.done).toBe(true); 64 | } 65 | }, 66 | )`${1}-${2}-${3}`.__test(); 67 | }); 68 | 69 | test('.look() and .read() with Boundary Variables', () => { 70 | r( 71 | class extends BasicBuilder { 72 | __test() { 73 | expect(this.done).toBe(false); 74 | expect(this.look()).toBe(undefined); 75 | expect(this.read()).toBe(undefined); 76 | expect(this.getVariable()).toBe(1); 77 | expect(this.read(6)).toBe(''); 78 | expect(this.look()).toBe(undefined); 79 | expect(this.read()).toBe(undefined); 80 | expect(this.getVariable()).toBe(2); 81 | expect(this.read()).toBe('a'); 82 | expect(this.look()).toBe(undefined); 83 | expect(this.read()).toBe(undefined); 84 | expect(this.done).toBe(false); 85 | expect(this.getVariable()).toBe(3); 86 | expect(this.read(7)).toBe(''); 87 | expect(this.look()).toBe(undefined); 88 | expect(this.done).toBe(false); 89 | expect(this.read()).toBe(undefined); 90 | expect(this.done).toBe(true); 91 | } 92 | }, 93 | )`${1}${2}a${3}${4}`.__test(); 94 | }); 95 | 96 | test('.look() EOF', () => { 97 | r( 98 | class extends BasicBuilder { 99 | __test() { 100 | this.read(3); 101 | expect(() => { 102 | this.look(); 103 | }).toThrowError('EOF'); 104 | } 105 | }, 106 | )`abc`.__test(); 107 | }); 108 | 109 | test('Bad Index', () => { 110 | r( 111 | class extends BasicBuilder { 112 | __test() { 113 | this.index = 100; 114 | expect(this.done).toBeTruthy(); 115 | } 116 | }, 117 | )`abc`.__test(); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/HtmlBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { createState } from '../src/StateManager'; 2 | import { digestImmediately } from '../src/debounce'; 3 | import { HtmlBuilder } from '../src/HtmlBuilder'; 4 | import { assertText, assertComment, assertElement } from './common'; 5 | import { assertInstanceOf } from '../src/utils'; 6 | 7 | const r = ({ raw }: any, ...vars: any[]) => new HtmlBuilder(raw, vars).root; 8 | 9 | test('Content with Variable', () => { 10 | const state = createState({ v: 1 }); 11 | const strong = document.createElement('strong'); 12 | strong.textContent = 'strong'; 13 | const root = r` 14 |
    15 |
    ${1}
    16 |
    ${() => 2}
    17 | ${() => state.v} 18 |
    name: ${'hehe'}
    19 |
    age: ${new Text('20')}
    20 | ${strong} 21 |
    22 | `; 23 | expect(root).toBeInstanceOf(DocumentFragment); 24 | const div = root.firstElementChild; 25 | expect(div).toBeInstanceOf(HTMLDivElement); 26 | const { children = [] } = div || {}; 27 | assertElement(children[0], 'DIV', '1'); 28 | assertElement(children[1], 'DIV', '2'); 29 | assertElement(children[2], 'SPAN', '1'); 30 | assertElement(children[3], 'DIV', 'name: hehe'); 31 | assertElement(children[4], 'DIV', 'age: 20'); 32 | assertElement(children[5], 'STRONG', 'strong'); 33 | state.v++; 34 | digestImmediately(); 35 | assertElement(children[2], 'SPAN', '2'); 36 | }); 37 | 38 | test('TagName with Variable', () => { 39 | const root = r` 40 |
    41 | h1 42 | <${'strong'}>strong 43 |
    44 | `; 45 | expect(root).toBeInstanceOf(DocumentFragment); 46 | const div = root.firstElementChild; 47 | expect(div).toBeInstanceOf(HTMLDivElement); 48 | expect(div?.firstElementChild?.tagName).toBe('H1'); 49 | expect(div?.firstElementChild?.textContent).toBe('h1'); 50 | expect(div?.lastElementChild?.tagName).toBe('STRONG'); 51 | expect(div?.lastElementChild?.textContent).toBe('strong'); 52 | }); 53 | 54 | describe('Attributes', () => { 55 | test('Variables', () => { 56 | const state = createState({ v: 1 }); 57 | const root = r` 58 |
    state.v}="hehe" 65 | ${{ toString: () => 'data-' }}hehe="123" 66 | ${() => 'disabled'} 67 | > 68 |
    69 | `; 70 | expect(root).toBeInstanceOf(DocumentFragment); 71 | const div = root.firstElementChild; 72 | expect(div?.getAttribute('data-a')).toBe('1'); 73 | expect(div?.getAttribute('title')).toBe('title'); 74 | expect(div?.getAttribute('style')).toBe('color: red; font-size: 12px;'); 75 | expect(div?.getAttribute('class')).toBe('nothing'); 76 | expect(div?.getAttribute('data-hehe')).toBe('123'); 77 | expect(div?.getAttribute('v1')).toBe('1'); 78 | expect(div?.getAttribute('a1')).toBe('hehe'); 79 | expect(div?.getAttribute('a2')).toBe(null); 80 | expect(div?.getAttribute('disabled')).toBe(''); 81 | state.v++; 82 | digestImmediately(); 83 | expect(div?.getAttribute('a1')).toBe(null); 84 | expect(div?.getAttribute('a2')).toBe('hehe'); 85 | expect(div?.getAttribute('v1')).toBe('2'); 86 | }); 87 | 88 | test('without Value', () => { 89 | const root = r` 90 | 91 |
    92 | 93 | 94 | 95 | 'dis' }}abled /> 96 | 97 | `; 98 | const { children = [] } = root; 99 | expect(children[0]).toBeInstanceOf(HTMLElement); 100 | expect(children[0].getAttribute('a')).toBe(''); 101 | expect(children[1]).toBeInstanceOf(HTMLHRElement); 102 | expect(children[1].getAttribute('a')).toBe(''); 103 | expect(children[2]).toBeInstanceOf(HTMLInputElement); 104 | expect(children[2].getAttribute('disabled')).toBe(''); 105 | expect(children[3]).toBeInstanceOf(HTMLInputElement); 106 | expect(children[3].getAttribute('disabled')).toBe(''); 107 | expect(children[4]).toBeInstanceOf(HTMLInputElement); 108 | expect(children[4].getAttribute('disabled')).toBe(''); 109 | expect(children[5]).toBeInstanceOf(HTMLInputElement); 110 | expect(children[5].getAttribute('disabled')).toBe(''); 111 | expect(children[6]).toBeInstanceOf(HTMLInputElement); 112 | expect(children[6].attributes.length).toBe(0); 113 | }); 114 | 115 | test('with Name-Value Pairs', () => { 116 | const e1 = r`
    `.firstElementChild; 117 | expect(e1?.getAttribute('a')).toBe('1'); 118 | expect(e1?.getAttribute('b')).toBe('2'); 119 | 120 | const e2 = r`
    `.firstElementChild; 121 | expect(e2?.getAttribute('a')).toBe('1'); 122 | expect(e2?.getAttribute('b')).toBe('2'); 123 | 124 | const e3 = r``.firstElementChild; 125 | expect(e3?.getAttribute('a')).toBe('1'); 126 | expect(e3?.getAttribute('b')).toBe('2'); 127 | 128 | const e4 = r` 'ok' }}ey />`.firstElementChild; 129 | expect(e4?.getAttribute('a')).toBe(null); 130 | expect(e4?.getAttribute('okey')).toBe(''); 131 | 132 | const e5 = r` 'ok' }} />`.firstElementChild; 133 | expect(e5?.getAttribute('a')).toBe(null); 134 | expect(e5?.getAttribute('no-ok')).toBe(''); 135 | 136 | const e6 = r``.firstElementChild; 137 | expect(e6?.getAttribute('a')).toBe('1'); 138 | expect(e6?.getAttribute('b')).toBe(null); 139 | expect(e6?.getAttribute('c')).toBe(null); 140 | }); 141 | 142 | test('with Spread', () => { 143 | const e1 = r`
    1 }}>
    `.firstElementChild; 144 | expect(e1?.getAttribute('a')).toBe('1'); 145 | expect(e1?.getAttribute('b')).toBe('() => 1'); 146 | }); 147 | 148 | test('with Spread Variable', () => { 149 | const attrs = createState({ a: 1 } as any); 150 | const e1 = r`
    `.firstElementChild; 151 | expect(e1?.getAttribute('a')).toBe('1'); 152 | expect(e1?.getAttribute('b')).toBe(null); 153 | attrs.b = 2; 154 | digestImmediately(); 155 | expect(e1?.getAttribute('a')).toBe('1'); 156 | expect(e1?.getAttribute('b')).toBe('2'); 157 | delete attrs.a; 158 | digestImmediately(); 159 | expect(e1?.getAttribute('a')).toBe(null); 160 | expect(e1?.getAttribute('b')).toBe('2'); 161 | }); 162 | 163 | describe('Override Rules', () => { 164 | test('Right Spread Attributes Override Left Normal Attributes', () => { 165 | const e1 = r`
    `.firstElementChild; 166 | expect(e1?.getAttribute('a')).toBe('2'); 167 | expect(e1?.getAttribute('b')).toBe('2'); 168 | }); 169 | 170 | test('Right Normal Attributes Override Left Spread Attributes', () => { 171 | const e2 = r`
    `.firstElementChild; 172 | expect(e2?.getAttribute('a')).toBe('1'); 173 | expect(e2?.getAttribute('b')).toBe('2'); 174 | }); 175 | 176 | test('Variable Update Cannot Break Override Rules', () => { 177 | const s3 = createState({ v: { a: 2, b: 2 }, x: 1 as any }); 178 | const e3 = r`
    s3.v} a="${() => s3.x}">
    `.firstElementChild; 179 | expect(e3?.getAttribute('a')).toBe('1'); 180 | expect(e3?.getAttribute('b')).toBe('2'); 181 | s3.v = { a: 3, b: 3 }; 182 | digestImmediately(); 183 | expect(e3?.getAttribute('a')).toBe('1'); 184 | expect(e3?.getAttribute('b')).toBe('3'); 185 | s3.x = 4; 186 | digestImmediately(); 187 | expect(e3?.getAttribute('a')).toBe('4'); 188 | expect(e3?.getAttribute('b')).toBe('3'); 189 | }); 190 | 191 | test('Spread Attributes can be Null', () => { 192 | const s3 = createState({ v: { a: 2, b: 2 } as any, x: 1 as any }); 193 | const e3 = r`
    s3.v}>
    `.firstElementChild; 194 | expect(e3?.getAttribute('a')).toBe('2'); 195 | expect(e3?.getAttribute('b')).toBe('2'); 196 | s3.v = { b: 2 }; 197 | digestImmediately(); 198 | expect(e3?.getAttribute('a')).toBe('1'); 199 | expect(e3?.getAttribute('b')).toBe('2'); 200 | s3.v = null; 201 | s3.x = 4; 202 | digestImmediately(); 203 | expect(e3?.getAttribute('a')).toBe('4'); 204 | expect(e3?.getAttribute('b')).toBe(null); 205 | }); 206 | }); 207 | 208 | test('Too many attributes', () => { 209 | expect(() => { 210 | const attrs = Array.from({ length: 1001 }, (_, i) => `a${i}`).join(' '); 211 | new HtmlBuilder([``], []); 212 | }).toThrowError(); 213 | }); 214 | }); 215 | 216 | describe('Event Handler', () => { 217 | test('Button Click', () => { 218 | const click = jest.fn(); 219 | const root = r``; 220 | const button = root.firstElementChild; 221 | button?.dispatchEvent(new MouseEvent('click')); 222 | expect(click).toBeCalled(); 223 | }); 224 | 225 | test('Bind 2 Hanlders on One Attribute', () => { 226 | const click1 = jest.fn(); 227 | const click2 = jest.fn(); 228 | const root = r``; 229 | const button = root.firstElementChild; 230 | button?.dispatchEvent(new MouseEvent('click')); 231 | expect(click1).toBeCalled(); 232 | expect(click2).toBeCalled(); 233 | }); 234 | 235 | test('with Spread Attributes', () => { 236 | const click0 = jest.fn(); 237 | const click1 = jest.fn(); 238 | const s = createState({ 239 | attrs: { '@click': click1 } as any, 240 | }); 241 | const button = r``.firstElementChild; 242 | button?.dispatchEvent(new MouseEvent('click')); 243 | expect(click0).toHaveBeenCalledTimes(0); 244 | expect(click1).toHaveBeenCalledTimes(1); 245 | 246 | // Change listener. 247 | const click2 = jest.fn(); 248 | s.attrs = { '@click': click2 }; 249 | digestImmediately(); 250 | button?.dispatchEvent(new MouseEvent('click')); 251 | expect(click0).toHaveBeenCalledTimes(0); 252 | expect(click1).toHaveBeenCalledTimes(1); 253 | expect(click2).toHaveBeenCalledTimes(1); 254 | 255 | // Listener should be removed. 256 | s.attrs = null; 257 | digestImmediately(); 258 | button?.dispatchEvent(new MouseEvent('click')); 259 | expect(click0).toHaveBeenCalledTimes(1); 260 | expect(click1).toHaveBeenCalledTimes(1); 261 | expect(click2).toHaveBeenCalledTimes(1); 262 | }); 263 | 264 | test('with Listener List Change', () => { 265 | const click1 = jest.fn(); 266 | const s = createState({ 267 | attrs: { '@click': click1 } as any, 268 | }); 269 | const button = r``.firstElementChild; 270 | button?.dispatchEvent(new MouseEvent('click')); 271 | expect(click1).toHaveBeenCalledTimes(1); 272 | 273 | // Add click2 to listener list. 274 | const click2 = jest.fn(); 275 | s.attrs = { '@click': [click1, click2] }; 276 | digestImmediately(); 277 | button?.dispatchEvent(new MouseEvent('click')); 278 | expect(click1).toHaveBeenCalledTimes(2); 279 | expect(click2).toHaveBeenCalledTimes(1); 280 | 281 | // Remove click1 from listener list. 282 | s.attrs = { '@click': click2 }; 283 | digestImmediately(); 284 | button?.dispatchEvent(new MouseEvent('click')); 285 | expect(click1).toHaveBeenCalledTimes(2); 286 | expect(click2).toHaveBeenCalledTimes(2); 287 | 288 | // Remove all listeners. 289 | s.attrs = {}; 290 | digestImmediately(); 291 | button?.dispatchEvent(new MouseEvent('click')); 292 | expect(click1).toHaveBeenCalledTimes(2); 293 | expect(click2).toHaveBeenCalledTimes(2); 294 | }); 295 | }); 296 | 297 | test('Self-Closing Tags', () => { 298 | const root = r` 299 |
    300 |
    301 |
    302 | `; 303 | const { children = [] } = root || {}; 304 | expect(children[0]).toBeInstanceOf(HTMLHRElement); 305 | expect(children[1]).toBeInstanceOf(HTMLHRElement); 306 | expect(children[2]).toBeInstanceOf(HTMLHRElement); 307 | }); 308 | 309 | test('Close Parent Element', () => { 310 | const root = r` 311 |
    312 | link 313 | haha 314 |
    315 | `; 316 | const div = root.firstElementChild; 317 | const { children = [] } = div || {}; 318 | assertElement(children[0], 'A', 'link'); 319 | assertElement(children[1], 'STRONG', 'haha'); 320 | }); 321 | 322 | test('Malformed Tags', () => { 323 | const root = r` 324 |
    325 | <123> 326 | custom 327 | xxx 328 | <恭喜发财>恭喜发财 329 | 330 |
    331 | `; 332 | const div = root.firstElementChild; 333 | const { childNodes = [] } = div || {}; 334 | assertText(childNodes[0], '<123>'); 335 | assertElement(childNodes[1], 'CUSTOM-TAG', 'custom'); 336 | assertText(childNodes[2], ''); 337 | assertElement(childNodes[3], 'X恭喜发财', 'xxx'); 338 | assertText(childNodes[4], '<恭喜发财>恭喜发财'); 339 | assertComment(childNodes[5], '恭喜发财'); 340 | assertText(childNodes[6], ''); 341 | assertText(childNodes[7], ''); 342 | expect(childNodes[8]).toBeUndefined(); 343 | }); 344 | 345 | test('Malformed Attributes', () => { 346 | const root = r` 347 |
    'obj' }} = ${123} 364 | ${'v'}1 = ${124}px 365 | x${'v'}2 = [[${125} 366 | end = [[>${126} 367 | >
    368 | `; 369 | const div = root.firstElementChild; 370 | if (!div) throw new Error('wtf'); 371 | expect(div.getAttribute('a')).toBe('1'); 372 | expect(div.getAttribute('b')).toBe('2'); 373 | expect(div.getAttribute('c')).toBe('3'); 374 | expect(div.getAttribute('d')).toBe('4'); 375 | expect(div.getAttribute('e')).toBe('5'); 376 | expect(div.getAttribute('f')).toBe(' 6'); 377 | expect(div.getAttribute('g')).toBe(' 7 '); 378 | expect(div.getAttribute('')).toBe(null); // NO_NAME_WILL_BE_IGNORED 379 | expect(div.getAttribute('x')).toBe('y'); 380 | expect(div.getAttribute('z')).toBe(''); 381 | expect(div.getAttribute('o')).toBe('m/n'); 382 | expect(div.getAttribute('p')).toBe('m\'"n'); 383 | expect(div.getAttribute('q')).toBe('>'); 384 | expect(div.getAttribute('恭喜')).toBe('发财'); 385 | expect(div.getAttribute('obj')).toBe('123'); 386 | expect(div.getAttribute('v')).toBe('123'); 387 | expect(div.getAttribute('v1')).toBe('124px'); 388 | expect(div.getAttribute('xv2')).toBe('[[125'); 389 | expect(div.getAttribute('end')).toBe('[['); 390 | expect(div.textContent?.replace(/\s/g, '')).toBe('126>'); 391 | }); 392 | 393 | test('Comment', () => { 394 | const state = createState({ v: 1 }); 395 | const root = r` 396 |
    397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 |
    405 | `; 406 | const div = root.firstElementChild; 407 | const { childNodes = [] } = div || {}; 408 | assertText(childNodes[0], ''); 409 | assertComment(childNodes[1], ' hehe '); 410 | assertText(childNodes[2], ''); 411 | assertComment(childNodes[3], 'haha'); 412 | assertText(childNodes[4], ''); 413 | assertComment(childNodes[5], ' 123 '); 414 | assertText(childNodes[6], ''); 415 | assertComment(childNodes[7], ' 456 '); 416 | 417 | assertText(childNodes[8], ''); 418 | assertComment(childNodes[9], ' 1 '); 419 | state.v++; 420 | digestImmediately(); 421 | assertComment(childNodes[9], ' 2 '); 422 | 423 | assertText(childNodes[10], ''); 424 | assertComment(childNodes[11], '注释'); 425 | assertText(childNodes[12], ''); 426 | assertComment(childNodes[13], '? php ?'); 427 | assertText(childNodes[14], ''); 428 | expect(childNodes[15]).toBeUndefined(); 429 | }); 430 | 431 | test('HTML Entity', () => { 432 | const root = r` 433 |
    434 | 😂 435 | 😂abc 436 | © 437 | ©nimei 438 |
    439 | `; 440 | const div = root.firstElementChild; 441 | const { children = [] } = div || {}; 442 | expect(children[0]).toBeInstanceOf(HTMLSpanElement); 443 | expect(children[0].textContent).toBe('😂'); 444 | expect(children[1]).toBeInstanceOf(HTMLSpanElement); 445 | expect(children[1].textContent).toBe('😂abc'); 446 | expect(children[2]).toBeInstanceOf(HTMLSpanElement); 447 | expect(children[2].textContent).toBe('©'); 448 | expect(children[3]).toBeInstanceOf(HTMLSpanElement); 449 | expect(children[3].textContent).toBe('©nimei'); 450 | }); 451 | 452 | test('Svg', () => { 453 | const { firstElementChild: svg } = r` 454 | 455 | 456 | 457 | 458 | 459 | `; 460 | expect(svg).toBeInstanceOf(SVGElement); 461 | const { children = [] } = svg || {}; 462 | expect(children[0].tagName).toBe('rect'); 463 | expect(children[1].tagName).toBe('g'); 464 | expect(children[1].firstElementChild?.tagName).toBe('rect'); 465 | 466 | const xmlns = 'http://www.w3.org/2000/svg'; 467 | const { firstElementChild: svg2 } = r``; 468 | expect(svg2).toBeInstanceOf(SVGElement); 469 | assertInstanceOf(svg2, SVGElement); 470 | expect(svg2.namespaceURI).toBe(xmlns); 471 | expect(svg2.getAttribute('width')).toBe('200'); 472 | }); 473 | 474 | test('Style', () => { 475 | const s = createState({ v: 'wtf' }); 476 | const { firstElementChild: style } = r` 477 | `; 481 | expect(style).toBeInstanceOf(HTMLStyleElement); 482 | expect(style?.textContent?.replace(/\s+/g, ' ')).toBe(' '); 483 | s.v = 'hehe'; 484 | digestImmediately(); 485 | expect(style?.textContent?.replace(/\s+/g, ' ')).toBe(' '); 486 | }); 487 | 488 | test('Textarea', () => { 489 | const s = createState({ v: 'wtf' }); 490 | const { firstElementChild: textarea } = r` 491 | `; 495 | expect(textarea).toBeInstanceOf(HTMLTextAreaElement); 496 | if (!(textarea instanceof HTMLTextAreaElement)) throw new Error('??'); 497 | expect(textarea?.value?.replace(/\s+/g, ' ')).toBe(' '); 498 | s.v = 'hehe'; 499 | digestImmediately(); 500 | expect(textarea?.textContent?.replace(/\s+/g, ' ')).toBe(' '); 501 | }); 502 | 503 | test('EOF', () => { 504 | assertElement(r`
    ccccccccc<`.firstChild, 'DIV', 'ccc<'); 507 | assertElement(r`
    ccc`.firstChild, 'DIV', 'ccc'); 508 | assertText(r`<`.firstElementChild?.textContent).toBe('<'); 528 | expect(r`