`;
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 |
`;
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 |

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 | 
185 |
186 | 而 Jinkela 状态与视图分离的设计就可以让原本看似无关的组件复用同一个状态。这便是 Jinkela 的核心理念之 **状态属于 Model 而不是 View**。
187 |
188 | 
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 |
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