├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── dist └── twinspark.min.js ├── gostatic.conf ├── headless-tests.js ├── package.json ├── public └── CNAME ├── site.tmpl ├── twinspark.js └── www ├── api.md ├── api ├── ts-action.md ├── ts-data.md ├── ts-json.md ├── ts-req-after.md ├── ts-req-batch.md ├── ts-req-before.md ├── ts-req-history.md ├── ts-req-method.md ├── ts-req-selector.md ├── ts-req-strategy.md ├── ts-req.md ├── ts-swap-push.md ├── ts-swap.md ├── ts-target.md └── ts-trigger.md ├── examples.html ├── examples ├── 010-fragment.html ├── 015-partial-response.html ├── 020-trigger.html ├── 030-before.html ├── 040-data.html ├── 045-form-data.html ├── 050-target.html ├── 060-parent.html ├── 070-target-target.html ├── 080-history.html ├── 090-indicator.html ├── 100-actions.html ├── 105-sortable.html ├── 120-visible.html ├── 130-outside.html ├── 140-children.html ├── 150-push.html ├── 155-redirect-from-server.html ├── 160-batch.html ├── 170-validation.html ├── 180-remove-event.html ├── 190-return.html ├── 200-script.html ├── 210-autocomplete.html ├── 220-progressbar.html ├── 230-filters.html ├── 240-pagination.html ├── 250-video.html └── 260-json.html ├── index.md ├── static ├── custom.css ├── examples.js ├── favicon.png ├── twinspark-dark.svg ├── twinspark-icon.svg ├── twinspark-logo.svg └── twinspark.js ├── test └── morph │ ├── bootstrap.js │ ├── core.js │ ├── fidelity.js │ ├── index.html │ ├── perf.js │ ├── perf │ ├── checkboxes.end │ ├── checkboxes.start │ ├── perf1.end │ ├── perf1.start │ ├── table.end │ └── table.start │ └── test-utilities.js ├── usecases.md └── vendor ├── highlight-foundation.min.css ├── highlight.min.js ├── spectre.min.css ├── tinytest.js └── xhr-mock.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: TwinSpark CI 2 | on: [push, pull_request] 3 | jobs: 4 | 5 | types: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - name: render 10 | run: make deps render 11 | - uses: actions/setup-java@v3 12 | with: 13 | distribution: 'zulu' # https://github.com/marketplace/actions/setup-java-jdk#supported-distributions 14 | java-version: '21' 15 | - run: npm i -g google-closure-compiler 16 | - run: make adv 17 | 18 | tests: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: render 23 | run: make deps render 24 | - uses: browser-actions/setup-chrome@v1 25 | - run: npm i 26 | - run: CHROMIUM_BIN=$(which chrome) ./headless-tests.js public 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | workflow_run: 4 | workflows: ['TwinSpark CI'] 5 | branches: ['main'] 6 | types: 7 | - completed 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: render 14 | run: make deps render 15 | - uses: actions/upload-pages-artifact@v1 16 | with: 17 | path: public 18 | 19 | deploy: 20 | needs: build 21 | if: github.ref == 'refs/heads/main' 22 | runs-on: ubuntu-latest 23 | permissions: 24 | pages: write 25 | id-token: write 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | steps: 30 | - name: Deploy to GitHub Pages 31 | id: deployment 32 | uses: actions/deploy-pages@v1 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/*.adv.js 2 | /node_modules/ 3 | /public/ 4 | /gostatic -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexander Solovyov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CCOPTS := --language_out ECMASCRIPT_2017 2 | 3 | min: dist/twinspark.min.js 4 | adv: dist/twinspark.adv.js 5 | type: CCOPTS := $(CCOPTS) --jscomp_warning=reportUnknownTypes 6 | type: dist/twinspark.adv.js 7 | 8 | dist/%.min.js: %.js 9 | @mkdir -p $(@D) 10 | google-closure-compiler $(CCOPTS) $^ > $@ 11 | 12 | dist/%.adv.js: %.js 13 | @mkdir -p $(@D) 14 | google-closure-compiler -O ADVANCED $(CCOPTS) $^ > $@ 15 | 16 | serve: 17 | darkhttpd . --port 3000 --addr 127.0.0.1 18 | 19 | w: 20 | gostatic -w gostatic.conf 21 | 22 | test: 23 | CHROMIUM_BIN=$(which chrome) ./headless-tests.js public 24 | 25 | # Deploy 26 | 27 | deps: 28 | curl -Lso gostatic https://github.com/piranha/gostatic/releases/download/2.36/gostatic-64-linux 29 | chmod +x gostatic 30 | 31 | render: 32 | ./gostatic gostatic.conf 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![TwinSpark](https://raw.githubusercontent.com/piranha/twinspark-js/main/www/static/twinspark-logo.svg) 2 | 3 | Declarative enhancement for HTML: simple, composable, lean. It's main goal is to 4 | eliminate most of the logic from JS, while allowing to make a good interactive 5 | site. 6 | 7 | It engages this problem from a few angles: 8 | 9 | - partial page updates facilitated via HTML attributes (no JS needed) 10 | - actions - incredibly simple promise-enabled language for (limited) client-side scripting 11 | - morphing - a strategy to update HTML graduallly, without breaking state and focus 12 | 13 | **Simple**: it's only a handful core attributes (like `ts-req`, `ts-action`, 14 | `ts-trigger`), with no surprising behavior (whatever is declared on top of your 15 | DOM tree will not affect your code). 16 | 17 | **Composable**: there are enough extension points to compose with whatever your 18 | needs are. You can add more directives like `ts-req`, you can add more actions, 19 | you can customize requests being sent out. Whatever you need. 20 | 21 | **Lean**: source is a full 2000 lines of code and only [8KB .min.gz][min]. We believe 22 | in less is more. 23 | 24 | [min]: https://github.com/piranha/twinspark-js/raw/main/dist/twinspark.min.js 25 | 26 | It's a battle-tested technology used on websites with 100k+ daily active users. 27 | 28 | Read more in [docs](https://twinspark.js.org/). 29 | -------------------------------------------------------------------------------- /gostatic.conf: -------------------------------------------------------------------------------- 1 | # -*- mode: makefile -*- 2 | 3 | TEMPLATES = site.tmpl 4 | SOURCE = www 5 | OUTPUT = public 6 | TITLE = TwinSpark 7 | URL = https://twinspark.js.org 8 | 9 | *.md: 10 | config 11 | ext .html 12 | directorify 13 | inner-template 14 | markdown 15 | template page 16 | 17 | *.html: 18 | config 19 | directorify 20 | inner-template 21 | template page 22 | 23 | api/*.md: examples/* 24 | config 25 | ext .html 26 | directorify 27 | inner-template 28 | markdown 29 | template page 30 | 31 | usecases.md: examples/* 32 | config 33 | ext .html 34 | directorify 35 | inner-template 36 | markdown 37 | template page 38 | 39 | examples/*.html: 40 | config 41 | directorify 42 | inner-template 43 | template example 44 | template page 45 | 46 | examples.html: examples/* 47 | config 48 | directorify 49 | inner-template 50 | template page 51 | 52 | static/twinspark.js: 53 | :cat # this overrides it being a symlink 54 | -------------------------------------------------------------------------------- /headless-tests.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const pup = require('puppeteer-core'); 4 | const http = require('http'); 5 | const fs = require('fs'); 6 | const url = require('url'); 7 | 8 | 9 | function describe(jsHandle) { 10 | return jsHandle.executionContext().evaluate((obj) => { 11 | return obj; 12 | // serialize |obj| however you want 13 | return `OBJ: ${typeof obj}, ${obj}`; 14 | }, jsHandle); 15 | } 16 | 17 | 18 | function delay(t, value) { 19 | return new Promise(resolve => setTimeout(resolve, t || 0, value)); 20 | } 21 | 22 | 23 | async function timeout(promise, t) { 24 | var ret = Promise.race([promise, delay(t || 1000, 'timeout')]); 25 | if (ret == 'timeout') 26 | throw new Error(ret); 27 | return ret; 28 | } 29 | 30 | 31 | const RED = '\x1b[31m%s\x1b[0m'; 32 | const GREEN = '\x1b[32m%s\x1b[0m'; 33 | const YELLOW = '\x1b[33m%s\x1b[0m'; 34 | 35 | function indicator(success) { 36 | return success ? '🟢' : '🔴'; 37 | } 38 | 39 | 40 | async function runTests(browser, base, url, needsTrigger) { 41 | console.log(YELLOW, `🔄 Tests at ${url}`); 42 | let page = await browser.newPage(); 43 | page.on('console', async msg => { 44 | if (msg.type() == 'debug') 45 | return; 46 | var args = await Promise.all(msg.args().map(describe)); 47 | console.log(msg.type() == 'error' ? RED : '%s', 48 | args.join(' ')) 49 | }); 50 | page.on('pageerror', e => console.log(e.toString())); 51 | 52 | await page.goto(base + url); 53 | await page.setViewport({width: 1080, height: 1024}); 54 | 55 | var res = new Promise(async resolve => { 56 | var timeout = setTimeout(async () => { 57 | console.log('TIMEOUT WAITING FOR TESTS', url); 58 | await page.close(); 59 | resolve(false); 60 | }, 10000); 61 | 62 | await page.exposeFunction('headlessRunnerDone', async (success) => { 63 | clearTimeout(timeout); 64 | // wait for all logs to resolve 65 | await new Promise(r => setTimeout(r, 10)); 66 | await page.close(); 67 | resolve(success); 68 | }); 69 | 70 | await page.evaluate(() => { 71 | document.querySelector('#tinytest').addEventListener('tt-done', (e) => { 72 | console.log('Event:', e.type, 'Success:', e.detail.success); 73 | setTimeout(() => window.headlessRunnerDone(e.detail.success)); 74 | }); 75 | }); 76 | 77 | if (needsTrigger) { 78 | await page.evaluate(() => { 79 | window.dispatchEvent(new CustomEvent('run-tests', {detail: {sync: true}})); 80 | }); 81 | } 82 | }); 83 | 84 | var success = await res; 85 | console.log(success ? GREEN : RED, 86 | `${indicator(success)} ${url} is done, success: ${success}`); 87 | return success; 88 | } 89 | 90 | function staticHandler(root) { 91 | return function (req, res) { 92 | var path = url.parse(req.url).pathname; 93 | if (path.endsWith('/')) { 94 | path += 'index.html'; 95 | } 96 | fs.readFile(root + '/' + path.slice(1), (err, data) => { 97 | if (err) { 98 | res.writeHead(404, 'Not Found'); 99 | res.write('Not Found'); 100 | return res.end(); 101 | } 102 | res.writeHead(200); 103 | res.write(data); 104 | return res.end(); 105 | }); 106 | } 107 | } 108 | 109 | function iff(path) { 110 | try { 111 | if (fs.statSync(path).isFile()) { 112 | return path 113 | } 114 | } catch (e) {} 115 | } 116 | 117 | (async () => { 118 | var path = process.env.CHROMIUM_BIN || 119 | iff('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') || 120 | iff('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'); 121 | console.log('Running tests with', path); 122 | let browser = await pup.launch({ 123 | headless: true, 124 | executablePath: path, 125 | }); 126 | 127 | var root = process.argv[2] || 'public'; 128 | var server = await http.createServer(staticHandler(root)).listen(0); 129 | var base = 'http://localhost:' + server.address().port; 130 | 131 | var success = await runTests(browser, base, '/test/morph/', false) && 132 | await runTests(browser, base, '/examples/', true); 133 | console.log(success ? GREEN : RED, 134 | `${indicator(success)} ALL TESTS DONE, SUCCESS: ${success}`); 135 | await browser.close(); 136 | process.exit(success ? 0 : 1); 137 | })(); 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twinspark", 3 | "description": "Declarative enhancement for HTML", 4 | "keywords": ["HTML", "AJAX", "XMLHttpRequest", "morph"], 5 | "version": "1.0", 6 | "homepage": "https://piranha.github.io/twinspark-js/", 7 | "bugs": { 8 | "url": "https://github.com/piranha/twinspark-js/issues" 9 | }, 10 | "license": "MIT", 11 | "files": ["LICENSE", "dist/twinspark.min.js"], 12 | "main": "dist/twinspark.min.js", 13 | "unpkg": "dist/twinspark.min.js", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/piranha/twinspark-js.git" 17 | }, 18 | "devDependencies": { 19 | "puppeteer-core": "^19.6.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | twinspark.js.org 2 | -------------------------------------------------------------------------------- /site.tmpl: -------------------------------------------------------------------------------- 1 | {{/* -*- mode: web; engine: go -*- */}} 2 | 3 | {{ define "header" }} 4 | 5 | 6 | 7 | 8 | {{ if .Title }}{{ .Title }} - {{ end }}{{ .Site.Other.Title }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 40 | {{ end }} 41 | 42 | 43 | {{ define "footer" }} 44 | 56 | 57 | 58 | 59 | {{ end }} 60 | 61 | 62 | {{ define "page" }}{{ template "header" . }} 63 |
{{ .Content }}
64 | {{ template "network" . }} 65 | {{ template "footer" . }}{{ end }} 66 | 67 | 68 | {{ define "network" }} 69 | 89 | 90 | 164 | {{ end }} 165 | 166 | {{ define "example" }} 167 |

168 | {{ .Title }} 169 | {{ if .Has "Tag" "advanced" }} (advanced){{ end -}} 170 | # 171 |

172 | {{ .Content }} 173 | 174 | {{ end }} 175 | 176 | 177 | {{ define "examples" }} 178 |

Examples

179 | {{ range .Site.Pages.WithTag .Title | abcsort }} 180 | {{ .Process.Content | cut "" "" }} 181 | {{ end }} 182 | {{ end }} 183 | -------------------------------------------------------------------------------- /www/api.md: -------------------------------------------------------------------------------- 1 | title: API 2 | ---- 3 | 4 | # TwinSpark API {.text-center} 5 | 6 | ## HTML updates 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
Directive Description
Core
ts-req Make a request for an HTML
ts-target Replace another part of a page with incoming HTML
ts-req-selector Select only a part of a response
ts-swap Select a strategy for HTML replacement
ts-swap-push "Push" HTML from server to a client
ts-trigger Specify event which triggers the request
Additional
ts-req-method Is it GET or POST?
ts-req-strategy How to deal with multiple requests being generated
ts-req-history Change URL after request
ts-data Additional data for request
ts-json As ts-data, but for JSON requests
ts-req-batch Combine multiple requests into a single one
30 | 31 | ## Actions 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
Directive Description
ts-action Run actions
ts-trigger Specify event which triggers actions
ts-req-before Actions to run before request
ts-req-after Actions to run after request
42 | 43 | 44 | ## Events 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
Event Description
ts-ready When HTML is "activated"
ts-trigger Event generated by ts-trigger
ts-req-before Before request
ts-req-after After request
ts-req-error On request errors
ts-pushstate When a new entry is pushed to browser history
ts-replacestate When a browser history entry is replaced
visible When 1% of element appears on screen
invisible When element was visible and now less than 1% of it is
closeby When 1% of element is closer to viewport than half of window height
away Anthonym to closeby
remove When an element is removed (depends on a trigger subscribing)
empty When element becomes childless
notempty When element had hierarchy changes and has children
childrenChange Combination of empty and notempty
66 | 67 | 68 | ## Headers 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
Header Description
Request
accept TwinSpark requests are always text/html+partial
ts-url Current page URL
ts-origin Identifier of an element which made request
ts-target Identifier of a target element
Response
ts-swap Override HTML swap strategy
ts-swap-push "Push" some HTML, replace: selector to <= selector from
ts-history New browser history URL
ts-title New page title in case of history push
ts-location Redirect to target URL
88 | 89 | 90 | ## Config 91 | 92 | Configuration attributes can be can set on twinspark script tag: 93 | 94 | ``` 95 | 23 | 31 | 32 | -------------------------------------------------------------------------------- /www/examples/015-partial-response.html: -------------------------------------------------------------------------------- 1 | id: partial-response 2 | title: Use only part of the response 3 | tags: ts-req-selector 4 | ---- 5 | 6 |

Server returns more HTML than we need - maybe a full template is rendered, 7 | maybe something else is going on - and we need to use just a part of it.

8 | 9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |

18 | Click me 19 |

20 | 21 | 29 | 37 |
38 | -------------------------------------------------------------------------------- /www/examples/020-trigger.html: -------------------------------------------------------------------------------- 1 | title: Triggering Requests 2 | tags: ts-req, ts-trigger 3 | id: trigger 4 | ---- 5 | 6 |

Usually requests are triggered on natural interrupts: submit on forms and 7 | clicks elsewhere, but sometimes you want more, like triggering on being seen 8 | or hovered:

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |

18 | Hover me! 19 |

20 | 21 | 24 | 32 |
33 | -------------------------------------------------------------------------------- /www/examples/030-before.html: -------------------------------------------------------------------------------- 1 | title: Before/After Request 2 | tags: ts-req-before, ts-req-after 3 | id: before 4 | ---- 5 |

6 | This example demostrates how you can interfere with request using 7 | ts-req-before. 8 |

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |

18 | This will work with delay
19 | This is prevented 20 |

21 | 22 | 25 | 35 |
36 | -------------------------------------------------------------------------------- /www/examples/040-data.html: -------------------------------------------------------------------------------- 1 | id: data 2 | title: Collecting Data 3 | tags: ts-data, ts-req-method 4 | ---- 5 |

6 | Here parent span declares ts-data with three keys 7 | (a, b, c) and then child a adds another entry with key 8 | b, drops existing c value and adds another 9 | (so it overrides c value). And then sends a POST 10 | request to a backend. Click link to see what data was sent. 11 |

12 | 13 |
14 |
15 |
Demo
16 | 17 | 18 |
19 | 20 |

21 | 22 | 23 | So. Much. Data. 24 | 25 |

26 | 27 | 37 |
38 | -------------------------------------------------------------------------------- /www/examples/045-form-data.html: -------------------------------------------------------------------------------- 1 | id: form-data 2 | title: Form With Data 3 | tags: ts-data, usecase 4 | desc: Form with complex data collection 5 | ---- 6 |

7 | Form can be a bit tricky to collect data correctly, particularly weird thing 8 | is when you have a few submit buttons. The following form has: 9 |

10 | 11 | 17 | 18 |

19 | When you click one of the buttons, you can observe that only that button was 20 | included in the data &emdash; which is what you need to decide which action 21 | to take. 22 |

23 | 24 |
25 |
26 |
Demo
27 | 28 | 29 |
30 | 31 |
32 |
34 |

35 | 36 | 37 | 38 | 39 |

40 |
41 |

Click a button to replace me with form data.

42 |
43 | 44 | 62 |
63 | -------------------------------------------------------------------------------- /www/examples/050-target.html: -------------------------------------------------------------------------------- 1 | title: Targeting Other Elements 2 | tags: ts-target 3 | id: target 4 | ---- 5 | 6 |

Default behavior is to replace element, which issues the request (in this 7 | case an a). ts-target sets another element as a 8 | target for the request.

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |

18 | I'm waiting... 19 | Do it! 20 |

21 | 22 | 25 | 33 |
34 | -------------------------------------------------------------------------------- /www/examples/060-parent.html: -------------------------------------------------------------------------------- 1 | title: Targeting Parents 2 | tags: ts-target 3 | id: parent 4 | ---- 5 |

This is very common pattern - when a button needs to update an element around 6 | itself. Modifier parent will search upwards for a suitable 7 | element (using element.closest(sel)).

8 | 9 |
10 |
11 |
Demo
12 | 13 | 14 |
15 | 16 |
17 |

Wanna read text behind me? 18 | Do it! 19 |

20 |
21 | 22 | 26 | 34 |
35 | -------------------------------------------------------------------------------- /www/examples/070-target-target.html: -------------------------------------------------------------------------------- 1 | id: target-target 2 | title: Relative Targeting 3 | tags: ts-target, advanced 4 | ---- 5 |

Most twinspark commands and extensions operate directly on the current 6 | target element. However, some of them might require a pair of elements 7 | (e.g. a command that copies data from one input to another). This means 8 | supplying a TwinSpark selector as an argument. To point directly to 9 | the current target element, use the target keyword.

10 | 11 |
12 |
13 |
Demo
14 | 15 | 16 |
17 | 18 |
19 |

Click here 20 | 21 | or here 22 | 23 |

24 |

to copy the value here: 25 |

26 |

Click one of these buttons to rename it with the value above: 27 | 28 | or 29 | 30 |

31 |
32 | 33 | 42 |
43 | 44 |
45 | 46 |

Another useful target is the element that created the event. The simplest 47 | way to access it is to create a twinspark extension that sets 48 | target to event.target:

49 | 50 |
51 |
52 |
Demo
53 | 54 | 55 |
56 | 57 |
58 |
59 |

None of these buttons have ts-action attribute, but when you click, 60 | the event is bubbled to the container that executes commands:
61 | One 62 | Two 63 | Three 64 |

65 |
66 |
67 | 68 | 75 |
76 | -------------------------------------------------------------------------------- /www/examples/080-history.html: -------------------------------------------------------------------------------- 1 | id: history 2 | title: Changing URL 3 | tags: ts-req-history 4 | ---- 5 |

6 | Clicking on each of those links in turn will cause URL to change and add a 7 | new entry in browser's history. 8 |

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |
18 |

19 | Let's change history! 20 |

21 |

22 | Let's change history 2! 23 |

24 |

25 | Let's change history 3! 26 |

27 |
28 | 29 | 32 | 44 |
45 | -------------------------------------------------------------------------------- /www/examples/090-indicator.html: -------------------------------------------------------------------------------- 1 | id: indicator 2 | title: Indicator 3 | tags: ts-req 4 | ---- 5 |

It's really irritating when you click a link and nothing happens for some 6 | time. Luckily TwinSpark makes it really easy: it adds attribute 7 | aria-busy="true" to an origin element.

8 | 9 |
10 |
11 |
Demo
12 | 13 | 14 |
15 | 16 |

17 | Just click me 18 |

19 | 20 | 43 | 44 | 47 | 57 |
58 | -------------------------------------------------------------------------------- /www/examples/100-actions.html: -------------------------------------------------------------------------------- 1 | id: actions 2 | title: Actions 3 | tags: ts-action 4 | ---- 5 |

Those are simple examples of using actions, see their source for details.

6 | 7 |
8 |
9 |
Demo of remove
10 | 11 | 12 |
13 | 14 |
15 |

16 | Hey! I'm here! 17 | 18 |

19 |
20 | 21 | 28 |
29 | 30 | 31 |
32 |
33 |
Demo of delay
34 | 35 | 36 |
37 | 38 |
39 |

40 | Remove with timeout 41 | 42 |

43 |
44 | 45 | 54 |
55 | 56 | 57 |
58 |
59 |
Demo of wait (waiting for an event to happen)
60 | 61 | 62 |
63 | 64 |
65 |

66 | Remove after transition 67 | 68 |

69 |
70 | 71 | 74 | 75 | 84 |
85 | 86 | 87 |
88 |
89 |
Demo of animate
90 | 91 | 92 |
93 | 94 |
95 |

96 | 97 |

98 |
99 | 100 | 107 | 108 | 117 |
118 | 119 | 120 |
121 |
122 |
Demo of multiple actions
123 | 124 | 125 |
126 | 127 |
128 |

This is going to be removed

129 |

And this only after transition

130 | 135 |
136 |
137 | -------------------------------------------------------------------------------- /www/examples/105-sortable.html: -------------------------------------------------------------------------------- 1 | id: sortable 2 | title: Sortable 3 | tags: ts-action, usecase 4 | desc: Integration with html5sortable 5 | ---- 6 | 7 |

An advanced example showing how to use actions (in particular on 8 | and req) in conjunction with 9 | html5sortable. 10 |

11 | 12 |

It adds a handler to sortupdate event, which is raised by 13 | html5sortable, and then triggers a POST request to a backend 14 | to store a new order. 15 |

16 | 17 | 21 | 22 | 30 | 31 |
32 |
33 |
Demo of on and req
34 | 35 | 36 |
37 | 38 |
39 |

43 | 🙂Smile 44 | 😇Halo 45 | 😁Beam 46 | 🤣ROFL 47 |

48 |

Here you'll see sorted names.

49 |
50 | 51 | 58 | 59 | 60 | 67 |
68 | -------------------------------------------------------------------------------- /www/examples/120-visible.html: -------------------------------------------------------------------------------- 1 | id: visible 2 | title: Visibility 3 | tags: ts-trigger, ts-action 4 | ---- 5 |

Doing something when element is almost visible makes it possible to implement lazy 6 | loading and various analytical events

7 | 8 |
9 |
10 |
Demo
11 | 12 | 13 |
14 | 15 |
16 |

17 | You'll probably see this text after around 5 seconds or so. Click "Reset" to see 18 | loader again. 19 |

20 |

21 | This sentence will log some message when it becomes invisible (moves out of 22 | browser viewport, and, actually, on load as well). 23 |

24 |
25 |
26 | -------------------------------------------------------------------------------- /www/examples/130-outside.html: -------------------------------------------------------------------------------- 1 | id: outside 2 | title: Click Outside 3 | tags: ts-trigger, ts-action 4 | ---- 5 |

Popups, modals, menus and some other elements can make use of click 6 | happened outside. It could be done with markup and underlying element, 7 | but why bother if you have straightforward trigger.

8 | 9 |

This trigger is ideally used with modifier once, since you're 10 | probably going to remove that modal you calling it on - using once 11 | will clean up your listeners so you won't get memory leaks.

12 | 13 |
14 |
15 |
Demo
16 | 17 | 18 |
19 | 20 | 29 | 30 | 49 |
50 | -------------------------------------------------------------------------------- /www/examples/140-children.html: -------------------------------------------------------------------------------- 1 | id: children 2 | title: Multiple Children 3 | tags: ts-req-selector, ts-swap 4 | ---- 5 |

If you look how endless scrolling is implemented in HTML, it's usually a long 6 | list of elements inside some other element - so you have to deal with several 7 | elements being appended to a parent. For this and similar use cases there is 8 | a modifier children in ts-req-selector.

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |
18 |

19 | Element 1 20 | Element 2 21 | 25 |

26 | 27 |

28 | Element 1 29 | Element 2 30 | 35 |

36 |
37 | 38 | 44 | 60 |
61 | -------------------------------------------------------------------------------- /www/examples/150-push.html: -------------------------------------------------------------------------------- 1 | id: push 2 | title: Multiple Fragments 3 | tags: ts-swap-push 4 | ---- 5 |

6 | First element is updated through regular flow with ts-target. 7 | Second element uses ts-swap-push attribute. 8 | Third element uses ts-swap-push header. 9 | The latter has a form 10 | [replace strategy]: [selector-in-document] <= [selector-in-response]. 11 |

12 | 13 | 14 |
15 |
16 |
Demo
17 | 18 | 19 |
20 | 21 |
22 |
23 |

Update me!

24 |
25 |
26 |

And me!

27 |
28 |
29 |

Also waiting here.

30 |
31 | 34 |
35 | 36 | 41 | 46 |
47 | -------------------------------------------------------------------------------- /www/examples/155-redirect-from-server.html: -------------------------------------------------------------------------------- 1 | id: server-redirect 2 | title: Redirect from server 3 | tags: ts-location 4 | ---- 5 |

6 | Redirect to target url, pass to response headers ts-location parameter. 7 |

8 | 9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |
18 | 21 |
22 | 23 | 28 |
-------------------------------------------------------------------------------- /www/examples/160-batch.html: -------------------------------------------------------------------------------- 1 | id: batch 2 | title: Batching 3 | tags: ts-data, ts-req-batch, advanced 4 | ---- 5 |

6 | Here all spans trigger request on visible, so click "Reset" to 7 | see more requests. See sources and debug panel to see how requests are 8 | combined in a single one. 9 |

10 | 11 |
12 |
13 |
Demo
14 | 15 | 16 |
17 | 18 |

19 | 20 | Span 1
21 | Span 2
22 | Span 3
23 |
24 |

25 | 26 | 31 | 45 |
46 | -------------------------------------------------------------------------------- /www/examples/170-validation.html: -------------------------------------------------------------------------------- 1 | id: validation 2 | title: Dynamic Form Validation 3 | tags: ts-swap, ts-trigger, advanced, usecase 4 | desc: Validate multi-input form with no state loss 5 | ---- 6 |

Form validation is a common task, and TwinSpark allows to consolidate 7 | validation logic on the server. Surprisingly, it could be difficult, but 8 | ts-swap="morph" strategy allows us to just return whole new form 9 | with errors and not mess up with focus.

10 | 11 |

Important bits

12 | 13 | 21 | 22 |
23 |
24 |
Demo
25 | 26 | 27 |
28 |
29 |

Solve this problem with odd numbers

30 |
32 |
33 |
34 | 35 |
36 |
37 | + 38 |
39 | 40 |
41 |
42 | = 14 43 |
44 | 45 |
46 |
47 |
48 |
49 | 50 | 54 | 55 | 105 | 106 |
107 | -------------------------------------------------------------------------------- /www/examples/180-remove-event.html: -------------------------------------------------------------------------------- 1 | id: remove-event 2 | title: On Removal 3 | tags: ts-trigger, ts-action, advanced 4 | ---- 5 |

It is useful to do something when node is removed (especially if that's some child 6 | or even non-related node triggering that removal). It's possible, but not recommended 7 | to use often since performance characteristics of the code are not well 8 | understood.

9 | 10 | 11 |
12 |
13 |
Demo
14 | 15 | 16 |
17 | 18 |
19 |

20 | When this paragraph is removed by clicking button, it will resurrect itself. 21 | 22 |

23 |
24 | 25 | 32 | 33 |
34 | -------------------------------------------------------------------------------- /www/examples/190-return.html: -------------------------------------------------------------------------------- 1 | id: return 2 | title: Pipelining Actions 3 | tags: ts-action, advanced 4 | ---- 5 | 6 |

Actions pipe their return values into next action as o.input, check 7 | the source of the next example to see how it works. 8 |

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |
18 |

19 | 20 |

21 |
22 | 23 | 30 | 31 | 48 |
49 | -------------------------------------------------------------------------------- /www/examples/200-script.html: -------------------------------------------------------------------------------- 1 | id: script 2 | title: <script> in response 3 | tags: ts-req, advanced 4 | ---- 5 | 6 |

Setting innerHTML to a value which contains script 7 | element does not execute JavaScript inside that element. TwinSpark handles 8 | that for you, check it out.

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |

18 | Update me with a script 19 |

20 | 21 | 23 | 24 | 25 | 33 |
34 | -------------------------------------------------------------------------------- /www/examples/210-autocomplete.html: -------------------------------------------------------------------------------- 1 | id: autocomplete 2 | title: Autocomplete 3 | tags: ts-req-strategy, usecase 4 | desc: Search input with autocomplete from backend 5 | ---- 6 | 7 |

8 | Autocomplete is interesting because it executes many things at once. Just look 9 | at the source, the interesting part is trigger modifiers - it does something 10 | only if user typed something (rather than just navigated field with cursor 11 | keys) and then stopped for 100 ms. 12 |

13 | 14 |

15 | When autocomplete triggers a time-consuming operation (e.g. full-text search), 16 | the implementation above triggers numerous requests if the user types slow 17 | enough. If requests finish at different durations, an older request can 18 | override the latest. To avoid this, we need to abort the XHR using 19 | ts-req-strategy="last". 20 |

21 | 22 |
23 |
24 |
Demo
25 | 26 | 27 |
28 | 29 |
30 |

31 | 37 |
38 | 44 |

45 |

55 |
56 |
57 | 58 | 61 | 62 | 67 | 68 | 96 |
97 |
98 | 99 | -------------------------------------------------------------------------------- /www/examples/220-progressbar.html: -------------------------------------------------------------------------------- 1 | id: progressbar 2 | title: Progress Bar 3 | tags: usecase 4 | desc: Smooth progress bar with updates from a server 5 | ---- 6 |
{{ .Other.Desc }}
7 | 8 |

9 | If you look at source, there is a button targeting parent div. 10 | When you click it, server responds with a new markup, which will make a 11 | request to a server again in 500 ms thanks to ts-trigger="load delay 12 | 500". 13 |

14 | 15 |

16 | Interesting part of that response is div id="pb1", which is 17 | actually progress bar. Each new response from the server uses the same id, so 18 | settling kicks in, and makes 19 | .bar-item transition work. 20 |

21 | 22 |
23 |
24 |
Demo
25 | 26 | 27 |
28 | 29 |
30 |
31 |

Start progress

32 | 35 |
36 |
37 | 38 | 43 | 44 | 52 | 53 | 64 | 65 | 82 |
83 | -------------------------------------------------------------------------------- /www/examples/230-filters.html: -------------------------------------------------------------------------------- 1 | id: filters 2 | title: Filters 3 | tags: usecase 4 | desc: Faceting interface for humans and crawlers alike 5 | ---- 6 |

7 | Filtering on ecommerce sites is a complex task. On one side you want it to be 8 | crawlable by Google, on the other if a user selected two filters one by one 9 | you'd like to see products, filtered by both. Naïve implementation will filter 10 | only one of them if a pause between clicks was short enough. It seems like the 11 | best way is form, full of links (so that Google/no-js envs can still use it), 12 | which toggle checkboxes when JS is enabled and auto-submit form. 13 |

14 | 15 |
16 |
17 |
Demo
18 | 19 | 20 |
21 | 22 |
23 |
24 |
Brand
25 | 26 | 27 | Adidas 28 | 29 | 30 | 31 | Nike 32 | 33 | 34 | 35 | Puma 36 | 37 |
38 |
39 |

Selected filters:

40 |
41 | 42 | 56 | 57 | 65 |
66 | -------------------------------------------------------------------------------- /www/examples/240-pagination.html: -------------------------------------------------------------------------------- 1 | title: Animated Pagination 2 | tags: ts-swap, usecase 3 | id: pagination 4 | desc: Pagination too can be beautiful 5 | ---- 6 |

Morphing gives you ability to add animations and transitions during HTML 7 | replacement. This example uses zero lines of custom JS, just some styles to 8 | add transitions.

9 | 10 |
11 |
12 |
Demo
13 | 14 | 15 |
16 | 17 |
18 |
19 | 20 | 41 | 42 | 93 |
94 | -------------------------------------------------------------------------------- /www/examples/250-video.html: -------------------------------------------------------------------------------- 1 | title: Morphing Video 2 | tags: ts-swap 3 | id: video 4 | ---- 5 |

This is a hard problem to solve with other algorithms (notably, morphdom gets 6 | it wrong). HTML structure is changed a lot, and element with an 7 | id is deep (enough) inside that structure, but Youtube video 8 | still plays after change. 9 |

10 | 11 |
12 |
13 |
Demo
14 | 15 | 16 |
17 | 18 |
19 |
20 |
21 |

Above

22 |
23 |
24 | 28 |
29 | Update 30 |
31 |
32 | 33 | 46 | 47 | 50 |
51 | -------------------------------------------------------------------------------- /www/examples/260-json.html: -------------------------------------------------------------------------------- 1 | id: json 2 | title: Request JSON API 3 | tags: ts-json 4 | ---- 5 |

6 | Simple POST request with JSON body. 7 |

8 | 9 |
10 |
11 |
Demo
12 | 13 | 14 |
15 | 16 |

17 | Send! 21 |

22 | 23 | 35 |
36 | -------------------------------------------------------------------------------- /www/index.md: -------------------------------------------------------------------------------- 1 |

2 | Declarative enhancement for HTML: simple, composable, lean. TwinSpark 3 | transfers lots of the common logic from JS into a few declarative HTML 4 | attributes. This leads to good interactive sites with little JS and more manageable 5 | code. 6 |

7 | 8 |

9 | TwinSpark is a battle-tested technology used for years on websites with 100k+ 10 | daily active users. 11 |

12 | 13 |
14 |
15 | 28 |
29 | 30 |
31 | 41 |
42 | 43 |
44 | 54 |
55 |
56 | 57 | ## Code example 58 | 59 | Explanation: click on an `a` element (which is enhanced with a 60 | [`ts-req`](api/ts-req/) attribute) issues an XHR request to a server. Then 61 | replaces the element with response from a server, and calls 62 | [`ts-req-after`](api/ts-req-after/), which is an [action](api/ts-action). 63 | 64 | ```html 65 |
66 | 69 | Update me 70 | 71 |
I'm just hanging out here
72 |
73 | ``` 74 | 75 |
76 |
77 |
Demo
78 | 79 | 80 |
81 | 82 |
83 | 86 | Update me 87 | 88 |
I'm just hanging out here
89 |
90 | 91 | 94 | 97 |
98 | 99 | 100 | ## How to use 101 | 102 | Just add file to your server's `static` folder or use CDN: 103 | 104 | ```html 105 | 106 | ``` 107 | 108 | 109 | ## What it is 110 | 111 | TwinSpark could be mentally split in three parts: 112 | 113 | - [Page fragment updates](api/ts-req/) facilitated via HTML attributes 114 | (no JS needed). This is the core idea. 115 | - [Morphing](api/ts-swap/#morph) - a strategy to update HTML gradually, 116 | without breaking state and focus. Makes form validation and animations on HTML 117 | changes a breeze. 118 | - [Actions](api/ts-action/) - incredibly simple promise-enabled language for 119 | (limited) client-side scripting. Bring your logic into a single place. 120 | 121 | Some reasons why TwinSpark exists despite [HTMx](https://htmx.org/) and 122 | [Unpoly](https://unpoly.com/) (those are similar in approach): 123 | 124 | - It's really small ([8KB `.min.gz`](https://github.com/piranha/twinspark-js/blob/master/dist/twinspark.min.js)). 125 | - There is no attribute inheritance — keeps surprises away. 126 | - [Batching](api/ts-req-batch/) - very useful if you want to use HTTP 127 | caching effectively, while maintaining some personalisation for your 128 | users. 129 | - Bundled - a lot of practical stuff packed in, like [actions](api/ts-action/), 130 | or non-native [event triggers](api/ts-trigger/), or 131 | [morphing](api/ts-swap/#morph). 132 | - Extensibility - you can easily register new directives the same way those in 133 | core are registered. 134 | 135 | ## Who is using this 136 | 137 | - [kasta.ua](https://kasta.ua) - leading Ukrainian online fashion marketplace 138 | - [Prophy](https://www.prophy.science) - Semantic Solutions for Research, Review, and Recruitment 139 | - [PMPRO](https://pmpro.com.ua/) - online shop "everything for permanent makeup" 140 | - [I.N.K.E.D](https://inked.com.ua/) - online shop "everything for tattoo" 141 | - [Green Owl](https://greenowl.fr/) - CBD online shop 142 | 143 | Send pull request or shoot an email to add your site here. 144 | 145 | 146 | ## Resources 147 | 148 | - [A tale of webpage speed, or throwing away React](https://solovyov.net/blog/2020/a-tale-of-webpage-speed-or-throwing-away-react/) - article about how TwinSpark came to be 149 | - [ecomspark](https://github.com/piranha/ecomspark) - a little example of TwinSpark in Clojure 150 | - [ecomspark-flask](https://github.com/vsolovyov/ecomspark-flask) - same example, but in Python with Flask 151 | -------------------------------------------------------------------------------- /www/static/custom.css: -------------------------------------------------------------------------------- 1 | body { color: #333; } 2 | .grid-70 { max-width: 70ch;} 3 | pre code { display: inline-block; } /* or else padding is broken for blocks */ 4 | .card-body { overflow: auto; } 5 | .card-body > pre { margin-top: 0; } 6 | .btn-sm { margin-bottom: .2rem; } 7 | 8 | .example + h3, table + h2 { margin-top: 1.2rem; } 9 | 10 | .mb-p { margin-bottom: 1.2rem; } 11 | .gap-5 { gap: .5rem; } 12 | .back-logo { 13 | background-image: 14 | linear-gradient(to right top, 15 | rgba(255,255,255, 0.1) 0, 16 | rgba(255,255,255, 1) 100%), 17 | url(twinspark-icon.svg); 18 | background-repeat: no-repeat; 19 | background-size: 2rem 2rem; 20 | background-position: right 1rem top 1rem; 21 | } 22 | 23 | .table td, .table th { padding: .2rem .1rem; } 24 | .table tr td:first-child { white-space: nowrap; } 25 | pre { overflow-x: auto; } 26 | .nw { white-space: nowrap; } 27 | 28 | 29 | .footer { 30 | color: #bcc3ce; 31 | padding: 1.8rem .75rem 1rem; 32 | position: relative; 33 | } 34 | 35 | .footer a { 36 | color: #66758c; 37 | } 38 | 39 | .hover-hide { display: none; } 40 | .hover-group:hover .hover-hide { display: initial; } 41 | 42 | /* tests */ 43 | #tinytest {font-family:sans-serif} 44 | #tinytest ul{margin:0 0 .5rem 1rem;padding-bottom:4px} 45 | #tinytest li{margin-top: .2rem;} 46 | #tinytest .duration{background: #c09853; 47 | padding: 2px 5px; 48 | font-size: .8rem; 49 | border-radius: 5px; 50 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2);} 51 | #tinytest .test {margin-left: 2rem;} 52 | #tinytest .total {margin-left: 4rem;} 53 | #tinytest summary{padding:4px} 54 | #tinytest .test.pass summary::before { 55 | content: '✓'; display: inline-block; margin-right: 5px; color: #00d6b2; } 56 | #tinytest .test.fail summary::before, #tinytest .test.error summary::before { 57 | content: '✖'; display: inline-block; margin-right: 5px; color: #c00; } 58 | #tinytest .test.error { background: #fcf2f2; } 59 | #tinytest li.pass { list-style-type: '✓ '; } 60 | #tinytest li.fail { list-style-type: '✖ '; } 61 | -------------------------------------------------------------------------------- /www/static/examples.js: -------------------------------------------------------------------------------- 1 | XHRMock.setup(); 2 | 3 | XHRMock.delay = function(mock, ms) { 4 | return function (req, res) { 5 | var msint = typeof ms == 'function' ? ms() : ms; 6 | return new Promise((resolve) => setTimeout(() => resolve(true), msint)) 7 | .then(_ => { 8 | var ret = typeof mock === 'function' ? 9 | mock(req, res, msint) : 10 | res.status(mock.status || 200).body(mock.body); 11 | return ret; 12 | }); 13 | } 14 | } 15 | 16 | function prev(n=1) { 17 | var el = document.currentScript; 18 | while (n--) { 19 | el = el.previousElementSibling; 20 | // markdown introduces empty paragraphs sometimes 21 | if (el.tagName == 'P' && el.innerHTML == '') { 22 | el = el.previousElementSibling; 23 | } 24 | }; 25 | return el && el.innerHTML; 26 | } 27 | 28 | function escape(s) { 29 | return s 30 | .replace(/"/g, '"') // because json-in-attrs will be disgusting 31 | .replace(/&/g, '&') 32 | .replace(/&/g, "&") 33 | .replace(//g, ">") 35 | .replace(/"/g, """) 36 | .replace(/'/g, "'"); 37 | } 38 | 39 | function dedent(s) { 40 | // first remove only-whitespace lines in front or back of a string 41 | var lines = s.replace(/(^\s*\n)|(\n\s*$)/g, '').split('\n'); 42 | var offsets = lines.map(line => line.search(/\S|$/)); 43 | var offset = Math.min.apply(Math, offsets); 44 | return lines.map(line => line.slice(offset)).join('\n'); 45 | } 46 | 47 | function decode(s) { 48 | return decodeURIComponent(s.replace(/\+/g, ' ')); 49 | } 50 | 51 | function codewrap(s) { 52 | return '
' + escape(s) + '
'; 53 | } 54 | 55 | 56 | function enableExamples() { 57 | [].forEach.call(document.querySelectorAll('.card.example'), function(card) { 58 | var example = card.querySelector('.card-body'); 59 | example.initial = example.innerHTML; 60 | 61 | card.querySelector('.reset').addEventListener('click', function(e) { 62 | example.innerHTML = example.initial; 63 | twinspark.activate(example); 64 | }); 65 | 66 | function isSource(el) { 67 | if (!el) return; 68 | if (el.classList.contains('card-body') || 69 | el.tagName == 'STYLE' || 70 | (el.tagName == 'SCRIPT' && el.dataset.source)) { 71 | return true; 72 | } 73 | } 74 | 75 | var source = dedent(example.innerHTML.trim()); 76 | let next = example.nextElementSibling; 77 | while (true) { 78 | if (!isSource(next)) 79 | break; 80 | source += '\n\n' + dedent(next.outerHTML.trim()); 81 | next = next.nextElementSibling; 82 | } 83 | card.querySelector('.source').addEventListener('click', function(e) { 84 | if (example.firstElementChild.tagName == 'PRE') 85 | return; 86 | example.innerHTML = codewrap(source); 87 | example.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el)); 88 | }); 89 | }); 90 | } 91 | 92 | document.addEventListener('DOMContentLoaded', enableExamples); 93 | window.addEventListener('hotreload', _ => twinspark.activate(document.body)); 94 | window.addEventListener('hotreload', enableExamples); 95 | window.addEventListener('popstate', _ => setTimeout(enableExamples, 16)); 96 | 97 | /// Tests 98 | 99 | // this function is to write tests, see `www/examples/*` for example usage 100 | window.test = (function() { 101 | var TESTS = []; 102 | 103 | function makeTest(name, testfn) { 104 | var parent = document.currentScript.closest('div'); 105 | var example = parent.querySelector('.card-body'); 106 | 107 | if (typeof name == 'function') { 108 | testfn = name; 109 | 110 | var h = parent; 111 | while(h && !((h = h.previousElementSibling).tagName == 'H3')) { 112 | } 113 | name = h && h.innerText || 'test'; 114 | } 115 | 116 | return { 117 | name: name, 118 | func: async (t) => { 119 | example.innerHTML = example.initial; 120 | twinspark.activate(example); 121 | try { 122 | await testfn(example, t) 123 | } finally { 124 | example.innerHTML = example.initial; 125 | twinspark.activate(example); 126 | } 127 | return 1; 128 | } 129 | }; 130 | } 131 | 132 | function test(name, testfn) { 133 | TESTS.push(makeTest(name, testfn)); 134 | } 135 | 136 | // use to limit tests to only single function, so less noise happens when you 137 | // debug a test 138 | function test1(testfn) { 139 | var test = makeTest(testfn); 140 | setTimeout(_ => { TESTS = [test] }, 100); 141 | } 142 | 143 | function runTests(e) { 144 | if (e) { 145 | e.preventDefault(); 146 | e.stopPropagation(); 147 | } 148 | 149 | Element.prototype.$ = Element.prototype.querySelector; 150 | Element.prototype.$$ = Element.prototype.querySelectorAll; 151 | 152 | window.event = (type, attrs, el) => { 153 | var e = new Event(type); 154 | if (attrs) Object.assign(e, attrs); 155 | el.dispatchEvent(e); 156 | } 157 | 158 | tt.test(TESTS, e && e.detail); 159 | } 160 | 161 | window.addEventListener('run-tests', runTests); 162 | window.RUNTESTS = runTests; 163 | 164 | return test; 165 | })(); 166 | -------------------------------------------------------------------------------- /www/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piranha/twinspark-js/bdbd5095868b78b38965d0fef8bec4ffde3e3dd9/www/static/favicon.png -------------------------------------------------------------------------------- /www/static/twinspark-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /www/static/twinspark-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /www/static/twinspark-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /www/static/twinspark.js: -------------------------------------------------------------------------------- 1 | ../../twinspark.js -------------------------------------------------------------------------------- /www/test/morph/bootstrap.js: -------------------------------------------------------------------------------- 1 | // -*- js-indent-level: 4 -*- 2 | 3 | describe("Bootstrap test", function(){ 4 | 5 | beforeEach(function() { 6 | clearWorkArea(); 7 | }); 8 | 9 | // bootstrap test 10 | it('can morph content to content', function() 11 | { 12 | let btn1 = make('') 13 | let btn2 = make('') 14 | 15 | Idiomorph.morph(btn1, btn2); 16 | 17 | btn1.innerHTML.should.equal(btn2.innerHTML); 18 | }); 19 | 20 | it('can morph attributes', function() 21 | { 22 | let btn1 = make('') 23 | let btn2 = make('') 24 | 25 | Idiomorph.morph(btn1, btn2); 26 | 27 | btn1.getAttribute("class").should.equal("bar"); 28 | should.equal(null, btn1.getAttribute("disabled")); 29 | }); 30 | 31 | it('can morph children', function() 32 | { 33 | let div1 = make('
') 34 | let btn1 = div1.querySelector('button'); 35 | let div2 = make('
') 36 | let btn2 = div2.querySelector('button'); 37 | 38 | Idiomorph.morph(div1, div2); 39 | 40 | btn1.getAttribute("class").should.equal("bar"); 41 | should.equal(null, btn1.getAttribute("disabled")); 42 | btn1.innerHTML.should.equal(btn2.innerHTML); 43 | }); 44 | 45 | it('basic deep morph works', function(done) 46 | { 47 | let div1 = make('
A
B
C
') 48 | 49 | let d1 = div1.querySelector("#d1") 50 | let d2 = div1.querySelector("#d2") 51 | let d3 = div1.querySelector("#d3") 52 | 53 | let morphTo = '
E
F
D
'; 54 | let div2 = make(morphTo) 55 | 56 | print(div1); 57 | Idiomorph.morph(div1, div2); 58 | print(div1); 59 | 60 | // first paragraph should have been discarded in favor of later matches 61 | d1.innerHTML.should.equal("A"); 62 | 63 | // second and third paragraph should have morphed 64 | d2.innerHTML.should.equal("E"); 65 | d3.innerHTML.should.equal("F"); 66 | 67 | setTimeout(()=> { 68 | console.log(morphTo); 69 | console.log(div1.outerHTML); 70 | // in timeout, so that ts-remove/ts-insert have a chance to disappear 71 | div1.outerHTML.should.equal(morphTo) 72 | 73 | console.log("idiomorph mutations : ", div1.mutations); 74 | div1.mutations.attribute.should.equal(4); 75 | div1.mutations.childrenAdded.should.equal(1); 76 | div1.mutations.childrenRemoved.should.equal(1); 77 | done(); 78 | }, 0) 79 | }); 80 | }) 81 | -------------------------------------------------------------------------------- /www/test/morph/core.js: -------------------------------------------------------------------------------- 1 | describe("Core morphing tests", function(){ 2 | 3 | beforeEach(function() { 4 | clearWorkArea(); 5 | }); 6 | 7 | it('morphs outerHTML as content properly when argument is null', function() 8 | { 9 | let initial = make(""); 10 | Idiomorph.morph(initial, null, {morphStyle:'outerHTML'}); 11 | initial.isConnected.should.equal(false); 12 | }); 13 | 14 | it('morphs outerHTML as content properly when argument is single node', function() 15 | { 16 | let initial = make(""); 17 | let finalSrc = ""; 18 | let final = make(finalSrc); 19 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 20 | if (initial.outerHTML !== "") { 21 | console.log("HTML after morph: " + initial.outerHTML); 22 | console.log("Expected: " + finalSrc); 23 | } 24 | initial.outerHTML.should.equal(""); 25 | }); 26 | 27 | // it('morphs outerHTML as content properly when argument is string', function() 28 | // { 29 | // let initial = make(""); 30 | // let finalSrc = ""; 31 | // Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 32 | // if (initial.outerHTML !== "") { 33 | // console.log("HTML after morph: " + initial.outerHTML); 34 | // console.log("Expected: " + finalSrc); 35 | // } 36 | // initial.outerHTML.should.equal(""); 37 | // }); 38 | 39 | // it('morphs outerHTML as content properly when argument is an HTMLElementCollection', function() 40 | // { 41 | // let initial = make(""); 42 | // let finalSrc = "
"; 43 | // let final = make(finalSrc).children; 44 | // Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 45 | // if (initial.outerHTML !== "") { 46 | // console.log("HTML after morph: " + initial.outerHTML); 47 | // console.log("Expected: " + finalSrc); 48 | // } 49 | // initial.outerHTML.should.equal(""); 50 | // }); 51 | 52 | // it('morphs outerHTML as content properly when argument is an Array', function() 53 | // { 54 | // let initial = make(""); 55 | // let finalSrc = "
"; 56 | // let final = [...make(finalSrc).children]; 57 | // Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 58 | // if (initial.outerHTML !== "") { 59 | // console.log("HTML after morph: " + initial.outerHTML); 60 | // console.log("Expected: " + finalSrc); 61 | // } 62 | // initial.outerHTML.should.equal(""); 63 | // }); 64 | 65 | // it('morphs outerHTML as content properly when argument is HTMLElementCollection with siblings', function() 66 | // { 67 | // let parent = make("
"); 68 | // let initial = parent.querySelector("button"); 69 | // let finalSrc = "

Foo

Bar

"; 70 | // let final = makeElements(finalSrc); 71 | // Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 72 | // if (initial.outerHTML !== "") { 73 | // console.log("HTML after morph: " + initial.outerHTML); 74 | // console.log("Expected: " + finalSrc); 75 | // } 76 | // initial.outerHTML.should.equal(""); 77 | // initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 78 | // }); 79 | 80 | // it('morphs outerHTML as content properly when argument is an Array with siblings', function() 81 | // { 82 | // let parent = make("
"); 83 | // let initial = parent.querySelector("button"); 84 | // let finalSrc = "

Foo

Bar

"; 85 | // let final = [...makeElements(finalSrc)]; 86 | // Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 87 | // if (initial.outerHTML !== "") { 88 | // console.log("HTML after morph: " + initial.outerHTML); 89 | // console.log("Expected: " + finalSrc); 90 | // } 91 | // initial.outerHTML.should.equal(""); 92 | // initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 93 | // }); 94 | 95 | // it('morphs outerHTML as content properly when argument is string', function() 96 | // { 97 | // let parent = make("
"); 98 | // let initial = parent.querySelector("button"); 99 | // let finalSrc = "

Foo

Bar

"; 100 | // Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 101 | // if (initial.outerHTML !== "") { 102 | // console.log("HTML after morph: " + initial.outerHTML); 103 | // console.log("Expected: " + finalSrc); 104 | // } 105 | // initial.outerHTML.should.equal(""); 106 | // initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 107 | // }); 108 | 109 | // it('morphs outerHTML as content properly when argument is string with multiple siblings', function() 110 | // { 111 | // let parent = make("
"); 112 | // let initial = parent.querySelector("button"); 113 | // let finalSrc = "

Doh

Foo

Bar

Ray

"; 114 | // Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 115 | // if (initial.outerHTML !== "") { 116 | // console.log("HTML after morph: " + initial.outerHTML); 117 | // console.log("Expected: " + finalSrc); 118 | // } 119 | // initial.outerHTML.should.equal(""); 120 | // initial.parentElement.innerHTML.should.equal("

Doh

Foo

Bar

Ray

"); 121 | // }); 122 | 123 | // it('morphs innerHTML as content properly when argument is null', function() 124 | // { 125 | // let initial = make("
Foo
"); 126 | // Idiomorph.morph(initial, null, {morphStyle:'innerHTML'}); 127 | // initial.outerHTML.should.equal("
"); 128 | // }); 129 | 130 | // it('morphs innerHTML as content properly when argument is single node', function() 131 | // { 132 | // let initial = make("
Foo
"); 133 | // let finalSrc = ""; 134 | // let final = make(finalSrc); 135 | // Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 136 | // if (initial.outerHTML !== "") { 137 | // console.log("HTML after morph: " + initial.outerHTML); 138 | // console.log("Expected: " + finalSrc); 139 | // } 140 | // initial.outerHTML.should.equal("
"); 141 | // }); 142 | 143 | // it('morphs innerHTML as content properly when argument is string', function() 144 | // { 145 | // let initial = make(""); 146 | // let finalSrc = ""; 147 | // Idiomorph.morph(initial, finalSrc, {morphStyle:'innerHTML'}); 148 | // if (initial.outerHTML !== "") { 149 | // console.log("HTML after morph: " + initial.outerHTML); 150 | // console.log("Expected: " + finalSrc); 151 | // } 152 | // initial.outerHTML.should.equal(""); 153 | // }); 154 | 155 | // it('morphs innerHTML as content properly when argument is an HTMLElementCollection', function() 156 | // { 157 | // let initial = make(""); 158 | // let finalSrc = "
"; 159 | // let final = make(finalSrc).children; 160 | // Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 161 | // if (initial.outerHTML !== "") { 162 | // console.log("HTML after morph: " + initial.outerHTML); 163 | // console.log("Expected: " + finalSrc); 164 | // } 165 | // initial.outerHTML.should.equal(""); 166 | // }); 167 | 168 | // it('morphs innerHTML as content properly when argument is an Array', function() 169 | // { 170 | // let initial = make(""); 171 | // let finalSrc = "
"; 172 | // let final = [...make(finalSrc).children]; 173 | // Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 174 | // if (initial.outerHTML !== "") { 175 | // console.log("HTML after morph: " + initial.outerHTML); 176 | // console.log("Expected: " + finalSrc); 177 | // } 178 | // initial.outerHTML.should.equal(""); 179 | // }); 180 | 181 | // it('morphs innerHTML as content properly when argument is empty array', function() 182 | // { 183 | // let initial = make("
Foo
"); 184 | // Idiomorph.morph(initial, [], {morphStyle:'innerHTML'}); 185 | // initial.outerHTML.should.equal("
"); 186 | // }); 187 | 188 | // it('ignores active element when ignoreActive set to true', function() 189 | // { 190 | // let initialSource = "
Foo
"; 191 | // getWorkArea().innerHTML = initialSource; 192 | // let i1 = document.getElementById('i1'); 193 | // i1.focus(); 194 | // let d1 = document.getElementById('d1'); 195 | // i1.value = "asdf"; 196 | // let finalSource = "
Bar
"; 197 | // Idiomorph.morph(getWorkArea(), finalSource, {morphStyle:'innerHTML', ignoreActive:true}); 198 | // d1.innerText.should.equal("Bar") 199 | // i1.value.should.equal("asdf") 200 | // }); 201 | 202 | // https://github.com/bigskysoftware/idiomorph/pull/49 203 | it('can morph a template tag properly', function() 204 | { 205 | let initial = make(""); 206 | let final = make(""); 207 | Idiomorph.morph(initial, final); 208 | initial.outerHTML.should.equal(final.outerHTML); 209 | }); 210 | 211 | it('can morph a body tag properly', function() 212 | { 213 | let initial = parseHTML("Foo"); 214 | let finalSrc = 'Foo'; 215 | let final = parseHTML(finalSrc); 216 | Idiomorph.morph(initial.body, final.body); 217 | initial.body.outerHTML.should.equal(finalSrc); 218 | }); 219 | 220 | it('can morph a full document properly', function() 221 | { 222 | let initial = parseHTML("Foo"); 223 | let finalSrc = 'Foo'; 224 | Idiomorph.morph(initial, parseHTML(finalSrc)); 225 | if (initial.documentElement.outerHTML !== finalSrc) { 226 | console.log("HTML after morph: " + initial.documentElement.outerHTML); 227 | console.log("Expected: " + finalSrc); 228 | } 229 | initial.documentElement.outerHTML.should.equal(finalSrc); 230 | }); 231 | 232 | it('can morph input checked properly, remove checked', function() 233 | { 234 | let parent = make('
'); 235 | document.body.append(parent); 236 | let initial = parent.querySelector("input"); 237 | 238 | let finalSrc = make(''); 239 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 240 | if (initial.outerHTML !== '') { 241 | console.log("HTML after morph: " + initial.outerHTML); 242 | console.log("Expected: " + finalSrc.outerHTML); 243 | } 244 | initial.outerHTML.should.equal(''); 245 | initial.checked.should.equal(false); 246 | document.body.removeChild(parent); 247 | }); 248 | 249 | it('can morph input checked properly, add checked', function() 250 | { 251 | let parent = make('
'); 252 | document.body.append(parent); 253 | let initial = parent.querySelector("input"); 254 | 255 | let finalSrc = make(''); 256 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 257 | if (initial.outerHTML !== '') { 258 | console.log("HTML after morph: " + initial.outerHTML); 259 | console.log("Expected: " + finalSrc.outerHTML); 260 | } 261 | initial.outerHTML.should.equal(''); 262 | initial.checked.should.equal(true); 263 | document.body.removeChild(parent); 264 | }); 265 | 266 | it('can morph input checked properly, set checked property to true', function() 267 | { 268 | let parent = make('
'); 269 | document.body.append(parent); 270 | let initial = parent.querySelector("input"); 271 | initial.checked = false; 272 | 273 | let finalSrc = make(''); 274 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 275 | if (initial.outerHTML !== '') { 276 | console.log("HTML after morph: " + initial.outerHTML); 277 | console.log("Expected: " + finalSrc.outerHTML); 278 | } 279 | initial.outerHTML.should.equal(''); 280 | initial.checked.should.equal(true); 281 | document.body.removeChild(parent); 282 | }); 283 | 284 | it('can morph input checked properly, set checked property to false', function() 285 | { 286 | let parent = make('
'); 287 | document.body.append(parent); 288 | let initial = parent.querySelector("input"); 289 | initial.checked = true; 290 | 291 | let finalSrc = make(''); 292 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 293 | if (initial.outerHTML !== '') { 294 | console.log("HTML after morph: " + initial.outerHTML); 295 | console.log("Expected: " + finalSrc.outerHTML); 296 | } 297 | initial.outerHTML.should.equal(''); 298 | initial.checked.should.equal(false); 299 | document.body.removeChild(parent); 300 | }); 301 | }) 302 | -------------------------------------------------------------------------------- /www/test/morph/fidelity.js: -------------------------------------------------------------------------------- 1 | // -*- js-indent-level: 4 -*- 2 | 3 | describe("Tests to ensure that idiomorph merges properly", function(){ 4 | 5 | beforeEach(function() { 6 | clearWorkArea(); 7 | }); 8 | 9 | function compare(node, end) { 10 | if (node.outerHTML !== end) { 11 | console.log("HTML after morph: " + node.outerHTML); 12 | console.log("Expected: " + end); 13 | } 14 | node.outerHTML.should.equal(end); 15 | } 16 | 17 | function testFidelity(start, end, done) { 18 | let initial = make(start); 19 | let final = make(end); 20 | Idiomorph.morph(initial, final); 21 | 22 | if (done) { 23 | setTimeout(() => { 24 | compare(initial, end); 25 | done(); 26 | }, 2); 27 | return; 28 | } else { 29 | compare(initial, end); 30 | } 31 | } 32 | 33 | window.testFidelity = testFidelity; 34 | 35 | // bootstrap test 36 | it('morphs text correctly', function() 37 | { 38 | testFidelity("", "") 39 | }); 40 | 41 | it('morphs attributes correctly', function() 42 | { 43 | testFidelity("", "") 44 | }); 45 | 46 | it('morphs multiple attributes correctly twice', function () 47 | { 48 | const a = `
A
`; 49 | const b = `
B
`; 50 | const expectedA = make(a); 51 | const expectedB = make(b); 52 | const initial = make(a); 53 | 54 | Idiomorph.morph(initial, expectedB); 55 | compare(initial, b); 56 | 57 | Idiomorph.morph(initial, expectedA); 58 | compare(initial, a); 59 | }); 60 | 61 | it('morphs children', function() 62 | { 63 | testFidelity("

A

B

", "

C

D

") 64 | }); 65 | 66 | it('morphs white space', function() 67 | { 68 | testFidelity("

A

B

", "

C

D

") 69 | }); 70 | 71 | it('drops content', function() 72 | { 73 | testFidelity("

A

B

", "
"); 74 | }); 75 | 76 | it('adds content', function(done) 77 | { 78 | testFidelity("
", '

A

B

', done) 79 | }); 80 | 81 | it('should morph a node', function() 82 | { 83 | testFidelity("

hello world

", "

hello you

") 84 | }); 85 | 86 | it('should stay same if same', function() 87 | { 88 | testFidelity("

hello world

", "

hello world

") 89 | }); 90 | 91 | it('should replace a node', function(done) 92 | { 93 | testFidelity("

hello world

", '
hello you
', done) 94 | }); 95 | 96 | it('should append a node', function(done) 97 | { 98 | testFidelity("
", '

hello you

', done) 99 | }); 100 | }) 101 | -------------------------------------------------------------------------------- /www/test/morph/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TwinSpark.js tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | Work Area 17 |
18 |   Output Here...
19 |   
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /www/test/morph/perf.js: -------------------------------------------------------------------------------- 1 | // -*- js-indent-level: 4 -*- 2 | 3 | describe("Tests to compare perf with morphdom", function(){ 4 | 5 | beforeEach(function() { 6 | clearWorkArea(); 7 | }); 8 | 9 | 10 | it('HTML5 Elements Sample Page', function(done) 11 | { 12 | runPerfTest("HTML5 Demo", "./perf/perf1.start", "./perf/perf1.end", done); 13 | }); 14 | 15 | it('Large Table performance', function(done) 16 | { 17 | runPerfTest("Large Table Perf", "./perf/table.start", "./perf/table.end", done); 18 | }); 19 | 20 | it('Checkboxes Performance', function(done) 21 | { 22 | runPerfTest("Checkboxes Performance", "./perf/checkboxes.start", "./perf/checkboxes.start", done); 23 | }); 24 | 25 | function runPerfTest(testName, startUrl, endUrl, done) { 26 | let startPromise = fetch(startUrl).then(value => value.text()); 27 | let endPromise = fetch(endUrl).then(value => value.text()); 28 | Promise.all([startPromise, endPromise]).then(value => { 29 | let start = value[0]; 30 | let end = value[1]; 31 | 32 | let startElt = make(start); 33 | let endElt = make(end); 34 | console.log("Content Size"); 35 | console.log(" Start: " + start.length + " characters"); 36 | console.log(" End : " + end.length + " characters"); 37 | console.time('twinspark.morph timing') 38 | for (var i = 0; i < 10; i++) { 39 | endElt = make(end); 40 | Idiomorph.morph(startElt, endElt); 41 | } 42 | // startElt.outerHTML.should.equal(end); 43 | console.timeEnd('twinspark.morph timing') 44 | 45 | let startElt2 = make(start); 46 | let endElt2 = make(end); 47 | console.time('morphdom timing') 48 | for (var i = 0; i < 10; i++) { 49 | endElt = make(end); 50 | morphdom(startElt2, endElt2, {}); 51 | } 52 | // wow morphdom doesn't match... 53 | // startElt2.outerHTML.should.equal(end); 54 | console.timeEnd('morphdom timing') 55 | done(); 56 | }); 57 | } 58 | 59 | }) 60 | -------------------------------------------------------------------------------- /www/test/morph/perf/perf1.end: -------------------------------------------------------------------------------- 1 |
2 |
3 |

HTML5 Test Page

4 |

This is a test page filled with common HTML elements to be used to provide visual feedback whilst building CSS systems and frameworks.

5 |
6 | 7 | 8 | 17 | 18 |

Heading 1 Lorem ipsum dolor sit amet

19 | 20 |

Heading 2 Lorem ipsum dolor sit amet

21 |

Heading 3 Lorem ipsum dolor sit amet

22 |
Heading 6 Lorem ipsum dolor sit amet
23 |

Heading 4 Lorem ipsum dolor sit amet

24 |
Heading 5 Lorem ipsum dolor sit amet
25 | 26 |

A paragraph (from the Greek paragraphos, “to write beside” or “written beside”) is a self-contained unit of a discourse in writing dealing with a particular point or idea. A paragraph consists of one or more sentences. Though not required by the syntax of any language, paragraphs are usually an expected part of formal writing, used to organize longer prose.

27 | 28 |
29 |

A block quotation (also known as a long quotation or extract) is a quotation in a written document, that is set off from the main text as a paragraph, or block of text.

30 |

It is typically distinguished visually using indentation and a different typeface or smaller size quotation. It may or may not include a citation, usually placed at the bottom.

31 |
32 |

— Nobody, Nonexistent Book

33 | 34 |
35 |
Definition List Title
36 |
This is a definition list division.
37 |
38 | 39 |
    40 |
  1. List Item 1
  2. 41 |
  3. 42 | List Item 2 43 |
      44 |
    1. List Item 1
    2. 45 |
    3. 46 | List Item 2 47 |
        48 |
      1. List Item 1
      2. 49 |
      3. 50 | List Item 2 51 |
          52 |
        1. List Item 1
        2. 53 |
        3. 54 | List Item 2 55 |
            56 |
          1. List Item 1
          2. 57 |
          3. List Item 2
          4. 58 |
          5. List Item 3
          6. 59 |
          60 |
        4. 61 |
        5. List Item 3
        6. 62 |
        63 |
      4. 64 |
      5. List Item 3
      6. 65 |
      66 |
    4. 67 |
    5. List Item 3
    6. 68 |
    69 |
  4. 70 |
  5. List Item 3
  6. 71 |
72 | 73 | 106 | 107 |
108 | Expand for details 109 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Cum, odio! Odio natus ullam ad quaerat, eaque necessitatibus, aliquid distinctio similique voluptatibus dicta consequuntur animi. Quaerat facilis quidem unde eos! Ipsa.

110 |
111 | 112 |
113 |

Written by Jon Doe.
114 | Visit us at:
115 | Example.com
116 | Box 564, Disneyland
117 | USA

118 |
119 | 120 |
121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 |
Table Caption
Table Heading 1Table Heading 2Table Heading 3Table Heading 4Table Heading 5
Table Footer 1Table Footer 2Table Footer 3Table Footer 4Table Footer 5
Table Cell 1Table Cell 2Table Cell 3Table Cell 4346896
Table Cell 1Table Cell 2Table Cell 3Table Cell 4234235
Table Cell 1Table Cell 2Table Cell 3Table Cell 4065454
Table Cell 1Table Cell 2Table Cell 3Table Cell 4143539
173 | 174 |

Keyboard input: Cmd A

175 |

Inline code: <div>code</div>

176 |

Sample output: This is sample output from a computer program.

177 | 178 |
P R E F O R M A T T E D T E X T
179 | ! " # $ % & ' ( ) * + , - . /
180 | 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
181 | @ A B C D E F G H I J K L M N O
182 | P Q R S T U V W X Y Z [ \ ] ^ _
183 | ` a b c d e f g h i j k l m n o
184 | p q r s t u v w x y z { | } ~ 
185 | 186 |

This is a text link.

187 |

Strong is used to indicate strong importance.

188 |

This text has added emphasis.

189 |

The b element is stylistically different text from normal text, without any special importance.

190 |

The i element is text that is offset from the normal text.

191 |

The u element is text with an unarticulated, though explicitly rendered, non-textual annotation.

192 |

This text is deleted and This text is inserted.

193 |

This text has a strikethrough.

194 |

Superscript®.

195 |

Subscript for things like H2O.

196 |

This small text is small for fine print, etc.

197 |

Abbreviation: HTML

198 |

This text is a short inline quotation.

199 |

This is a citation.

200 |

The dfn element indicates a definition.

201 |

The mark element indicates a highlight.

202 |

The variable element, such as x = y.

203 |

The time element:

204 | 205 |

Photo of a kitten

206 | 207 |
Photo of a kitten
208 | 209 |
210 | Photo of a kitten 211 |
Here is a caption for this image.
212 |
213 | 214 |
215 | 216 | 217 | Photo of a kitten 218 | 219 |
220 | 221 |
222 | 223 |
224 | 225 |

226 | 227 |

228 | 229 |

230 |
231 | Input fields 232 | 233 |
234 |

235 | 236 | 237 |

238 |

239 | 240 | 241 |

242 |

243 | 244 | 245 |

246 |

247 | 248 | 249 |

250 |

251 | 252 | 253 |

254 |

255 | 256 | 257 |

258 |

259 | 260 | 261 |

262 |

263 | 264 | 265 |

266 |
267 |
268 | 269 |
270 | Select menus 271 |

272 | 273 | 280 |

281 |

282 | 283 | 290 |

291 |
292 | 293 | 294 |
295 | Radio buttons 296 |
    297 |
  • 298 |
  • 299 |
  • 300 |
301 |
302 | 303 |
304 | Checkboxes 305 |
    306 |
  • 307 |
  • 308 |
  • 309 |
310 |
311 | 312 |
313 | Textareas 314 |

315 | 316 | 317 |

318 |
319 | 320 |
321 | HTML5 inputs 322 |

323 | 324 | 325 |

326 |

327 | 328 | 329 |

330 |

331 | 332 | 333 |

334 |

335 | 336 | 337 |

338 |

339 | 340 | 341 |

342 |

343 | 344 | 345 |

346 |

347 | 348 | 349 |

350 |

351 | 352 | 353 |

354 |

355 | 356 | 357 | 358 | 362 |

363 |
364 | Radio buttons 365 |
366 |
369 |
370 |
373 |
374 |
377 |
378 |
379 |
380 |
381 | 382 |
383 | Action buttons 384 |

385 | 386 | 387 | 388 | 389 |

390 |

391 | 392 | 393 | 394 | 395 |

396 |
397 |
398 |
-------------------------------------------------------------------------------- /www/test/morph/perf/perf1.start: -------------------------------------------------------------------------------- 1 |
2 |
3 |

HTML5 Test Page

4 |

This is a test page filled with common HTML elements to be used to provide visual feedback whilst building CSS systems and frameworks.

5 |
6 | 7 |

Heading 1 Lorem ipsum dolor sit amet

8 | 9 | 18 | 19 |

Heading 2 Lorem ipsum dolor sit amet

20 |

Heading 3 Lorem ipsum dolor sit amet

21 |

Heading 4 Lorem ipsum dolor sit amet

22 |
Heading 5 Lorem ipsum dolor sit amet
23 |
Heading 6 Lorem ipsum dolor sit amet
24 | 25 |

A paragraph (from the Greek paragraphos, “to write beside” or “written beside”) is a self-contained unit of a discourse in writing dealing with a particular point or idea. A paragraph consists of one or more sentences. Though not required by the syntax of any language, paragraphs are usually an expected part of formal writing, used to organize longer prose.

26 | 27 |
28 |

A block quotation (also known as a long quotation or extract) is a quotation in a written document, that is set off from the main text as a paragraph, or block of text.

29 |

It is typically distinguished visually using indentation and a different typeface or smaller size quotation. It may or may not include a citation, usually placed at the bottom.

30 |
31 |

— Nobody, Nonexistent Book

32 | 33 |
34 |
Definition List Title
35 |
This is a definition list division.
36 |
37 | 38 |
    39 |
  1. List Item 1
  2. 40 |
  3. 41 | List Item 2 42 |
      43 |
    1. List Item 1
    2. 44 |
    3. 45 | List Item 2 46 |
        47 |
      1. List Item 1
      2. 48 |
      3. 49 | List Item 2 50 |
          51 |
        1. List Item 1
        2. 52 |
        3. 53 | List Item 2 54 |
            55 |
          1. List Item 1
          2. 56 |
          3. List Item 2
          4. 57 |
          5. List Item 3
          6. 58 |
          59 |
        4. 60 |
        5. List Item 3
        6. 61 |
        62 |
      4. 63 |
      5. List Item 3
      6. 64 |
      65 |
    4. 66 |
    5. List Item 3
    6. 67 |
    68 |
  4. 69 |
  5. List Item 3
  6. 70 |
71 | 72 | 105 | 106 |
107 | Expand for details 108 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Cum, odio! Odio natus ullam ad quaerat, eaque necessitatibus, aliquid distinctio similique voluptatibus dicta consequuntur animi. Quaerat facilis quidem unde eos! Ipsa.

109 |
110 | 111 |
112 |

Written by Jon Doe.
113 | Visit us at:
114 | Example.com
115 | Box 564, Disneyland
116 | USA

117 |
118 | 119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 |
Table Caption
Table Heading 1Table Heading 2Table Heading 3Table Heading 4Table Heading 5
Table Footer 1Table Footer 2Table Footer 3Table Footer 4Table Footer 5
Table Cell 1Table Cell 2Table Cell 3Table Cell 4234235
Table Cell 1Table Cell 2Table Cell 3Table Cell 4346896
Table Cell 1Table Cell 2Table Cell 3Table Cell 4065454
Table Cell 1Table Cell 2Table Cell 3Table Cell 4143539
172 | 173 |

Keyboard input: Cmd A

174 |

Inline code: <div>code</div>

175 |

Sample output: This is sample output from a computer program.

176 | 177 |
P R E F O R M A T T E D T E X T
178 | ! " # $ % & ' ( ) * + , - . /
179 | 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
180 | @ A B C D E F G H I J K L M N O
181 | P Q R S T U V W X Y Z [ \ ] ^ _
182 | ` a b c d e f g h i j k l m n o
183 | p q r s t u v w x y z { | } ~ 
184 | 185 |

This is a text link.

186 |

Strong is used to indicate strong importance.

187 |

This text has added emphasis.

188 |

The b element is stylistically different text from normal text, without any special importance.

189 |

The i element is text that is offset from the normal text.

190 |

The u element is text with an unarticulated, though explicitly rendered, non-textual annotation.

191 |

This text is deleted and This text is inserted.

192 |

This text has a strikethrough.

193 |

Superscript®.

194 |

Subscript for things like H2O.

195 |

This small text is small for fine print, etc.

196 |

Abbreviation: HTML

197 |

This text is a short inline quotation.

198 |

This is a citation.

199 |

The dfn element indicates a definition.

200 |

The mark element indicates a highlight.

201 |

The variable element, such as x = y.

202 |

The time element:

203 | 204 |

Photo of a kitten

205 | 206 |
Photo of a kitten
207 | 208 |
209 | Photo of a kitten 210 |
Here is a caption for this image.
211 |
212 | 213 |
214 | 215 | 216 | Photo of a kitten 217 | 218 |
219 | 220 |
221 | 222 |
223 | 224 |

225 | 226 |

227 | 228 |

229 |
230 | Input fields 231 | 232 |
233 |

234 | 235 | 236 |

237 |

238 | 239 | 240 |

241 |

242 | 243 | 244 |

245 |

246 | 247 | 248 |

249 |

250 | 251 | 252 |

253 |

254 | 255 | 256 |

257 |

258 | 259 | 260 |

261 |

262 | 263 | 264 |

265 |
266 |
267 | 268 |
269 | Select menus 270 |

271 | 272 | 279 |

280 |

281 | 282 | 289 |

290 |
291 | 292 |
293 | Checkboxes 294 |
    295 |
  • 296 |
  • 297 |
  • 298 |
299 |
300 | 301 |
302 | Radio buttons 303 |
    304 |
  • 305 |
  • 306 |
  • 307 |
308 |
309 | 310 |
311 | Textareas 312 |

313 | 314 | 315 |

316 |
317 | 318 |
319 | HTML5 inputs 320 |

321 | 322 | 323 |

324 |

325 | 326 | 327 |

328 |

329 | 330 | 331 |

332 |

333 | 334 | 335 |

336 |

337 | 338 | 339 |

340 |

341 | 342 | 343 |

344 |

345 | 346 | 347 |

348 |

349 | 350 | 351 |

352 |

353 | 354 | 355 | 356 | 360 |

361 |
362 | Radio buttons 363 |
364 |
367 |
368 |
371 |
372 |
375 |
376 |
377 |
378 |
379 | 380 |
381 | Action buttons 382 |

383 | 384 | 385 | 386 | 387 |

388 |

389 | 390 | 391 | 392 | 393 |

394 |
395 |
396 |
-------------------------------------------------------------------------------- /www/test/morph/test-utilities.js: -------------------------------------------------------------------------------- 1 | /* Test Utilities */ 2 | 3 | function make(htmlStr) { 4 | let range = document.createRange(); 5 | let fragment = range.createContextualFragment(htmlStr); 6 | 7 | let element = fragment.children[0]; 8 | element.mutations = {elt: element, attribute:0, childrenAdded:0, childrenRemoved:0, characterData:0}; 9 | 10 | let observer = new MutationObserver((mutationList, observer) => { 11 | for (const mutation of mutationList) { 12 | if (mutation.type === 'childList') { 13 | element.mutations.childrenAdded += mutation.addedNodes.length; 14 | element.mutations.childrenRemoved += mutation.removedNodes.length; 15 | } else if (mutation.type === 'attributes') { 16 | element.mutations.attribute++; 17 | } else if (mutation.type === 'characterData') { 18 | element.mutations.characterData++; 19 | } 20 | } 21 | }); 22 | observer.observe(fragment, {attributes: true, childList: true, subtree: true}); 23 | 24 | return element; 25 | } 26 | 27 | function makeElements(htmlStr) { 28 | let range = document.createRange(); 29 | let fragment = range.createContextualFragment(htmlStr); 30 | return fragment.children; 31 | } 32 | 33 | function parseHTML(src) { 34 | let parser = new DOMParser(); 35 | return parser.parseFromString(src, "text/html"); 36 | } 37 | 38 | function getWorkArea() { 39 | return document.getElementById("work-area"); 40 | } 41 | 42 | function clearWorkArea() { 43 | getWorkArea().innerHTML = ""; 44 | } 45 | 46 | function print(elt) { 47 | let text = document.createTextNode( elt.outerHTML + "\n\n" ); 48 | getWorkArea().appendChild(text); 49 | return elt; 50 | } 51 | 52 | /* Emulate Mocha with tinytest */ 53 | 54 | (function(window) { 55 | var TESTS = []; 56 | var setup = null; 57 | var SCHEDULED = null; 58 | 59 | function describe(desc, cb) { 60 | desc = desc.replace('idiomorph', 'twinspark.morph'); 61 | setup = null; 62 | TESTS.push({ 63 | group: true, 64 | name: `

${desc}

`, 65 | func: () => { 66 | // style group 67 | setTimeout(() => { 68 | var groups = document.querySelectorAll('h3.group'); 69 | for (var group of groups) { 70 | if (group.parentElement.tagName != 'SUMMARY') 71 | continue; 72 | var div = group.closest('div') 73 | div.append(group); 74 | div.className = ''; 75 | group.previousSibling.remove(); // kill that span 76 | group.style.background = 'white'; 77 | group.style.padding = '4px'; 78 | group.style.margin = '0'; 79 | } 80 | }, 0); 81 | } 82 | }); 83 | cb(); 84 | SCHEDULED || (SCHEDULED = setTimeout(() => tt.test(TESTS, {sync: true}), 100)); 85 | } 86 | 87 | function beforeEach(cb) { 88 | setup = cb; 89 | } 90 | 91 | function it(desc, cb) { 92 | let func = cb; 93 | if (cb.length == 1) { 94 | func = function() { 95 | return new Promise(resolve => cb(resolve)); 96 | } 97 | } 98 | 99 | let test = { 100 | name: desc, 101 | setup: setup, 102 | func: func, 103 | } 104 | TESTS.push(test); 105 | return test; 106 | } 107 | 108 | function only(desc, cb) { 109 | let group = TESTS.findLastIndex(item => item.group); 110 | let test = it(desc, cb); 111 | setTimeout(_ => { 112 | TESTS = [TESTS[group], test]; 113 | }, 50); 114 | } 115 | 116 | function should(obj) { 117 | return new Assertion(obj); 118 | } 119 | 120 | should.equal = (v1, v2) => tt.assert(v1 + ' == ' + v2, v1 == v2); 121 | 122 | var Assertion = should.Assertion = function Assertion(obj) { 123 | this.obj = obj; 124 | } 125 | 126 | Assertion.prototype = { 127 | constructor: Assertion, 128 | equal: function(v) { should.equal(this.obj, v) } 129 | } 130 | 131 | Object.defineProperty(Object.prototype, 'should', { 132 | set: function(value) { 133 | Object.defineProperty(this, 'should', { 134 | value: value, 135 | enumerable: true, 136 | configurable: true, 137 | writable: true 138 | }); 139 | }, 140 | get: function() { 141 | return should(this); 142 | }, 143 | configurable: true 144 | }); 145 | 146 | window.describe = describe; 147 | window.beforeEach = beforeEach; 148 | window.it = it; 149 | it.only = only; 150 | window.only = only; 151 | window.should = should; 152 | })(window); 153 | -------------------------------------------------------------------------------- /www/usecases.md: -------------------------------------------------------------------------------- 1 | title: Use Cases 2 | ---- 3 | 4 | Presented here are use cases for TwinSpark, learn, copy, modify them. 5 | 6 | 7 | 8 | 9 | {{ range .Site.Pages.WithTag "usecase" | abcsort }} 10 | 11 | 12 | 13 | 14 | {{ end }} 15 |
Use Case Description
{{ .Title }}{{ .Other.Desc }}
16 | -------------------------------------------------------------------------------- /www/vendor/highlight-foundation.min.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#eee;color:#000}.hljs-addition,.hljs-attribute,.hljs-emphasis,.hljs-link{color:#070}.hljs-emphasis{font-style:italic}.hljs-deletion,.hljs-string,.hljs-strong{color:#d14}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#998;font-style:italic}.hljs-section,.hljs-title{color:#900}.hljs-class .hljs-title,.hljs-title.class_,.hljs-type{color:#458}.hljs-template-variable,.hljs-variable{color:#369}.hljs-bullet{color:#970}.hljs-meta{color:#34b}.hljs-code,.hljs-keyword,.hljs-literal,.hljs-number,.hljs-selector-tag{color:#099}.hljs-regexp{background-color:#fff0ff;color:#808}.hljs-symbol{color:#990073}.hljs-name,.hljs-selector-class,.hljs-selector-id,.hljs-tag{color:#070} -------------------------------------------------------------------------------- /www/vendor/tinytest.js: -------------------------------------------------------------------------------- 1 | /** (c) Alexander Solovyov 2 | 3 | Tiny test runner, supports async tests. Vendor that stuff. 4 | 5 | 25 | */ 26 | 27 | window.tt = (function() { 28 | var RESULTS = [], _EL; 29 | 30 | function EL() { 31 | if (_EL) return _EL; 32 | if (_EL = document.getElementById('tinytest')) return _EL; 33 | document.body.insertAdjacentHTML('beforeend', '
'); 34 | return EL(); 35 | } 36 | 37 | function escape(s) { 38 | return s 39 | .replace(/"/g, '"') // because json-in-attrs will be disgusting 40 | .replace(/&/g, '&') 41 | .replace(/&/g, "&") 42 | .replace(//g, ">") 44 | .replace(/"/g, """) 45 | .replace(/'/g, "'"); 46 | } 47 | 48 | 49 | function doReport(prio, name, results, duration, err) { 50 | var tlen = results.length; 51 | var flen = results.filter(r => !r.result).length; 52 | var details = results 53 | .map(r => `
  • ${escape(r.desc)}
  • `) 54 | .join(''); 55 | 56 | var cls, desc; 57 | if (err) { 58 | cls = 'error', desc = 'Exception', 59 | details = `
    ${err.stack}
    `; 60 | } else if (flen) { 61 | cls = 'fail', desc = `${flen} / ${tlen} Tests Failed`; 62 | } else { 63 | cls = 'pass', desc = `${tlen} Tests Passed`; 64 | } 65 | 66 | var dt = `${duration}ms`; 67 | var t = `
    68 | ${name}: ${desc} ${dt} 69 |
      ${details}
    70 |
    `; 71 | 72 | var el; 73 | for (var child of EL().children) { 74 | if (parseInt(child.dataset.prio, 10) > prio) { 75 | el = child; 76 | break; 77 | } 78 | } 79 | if (el) { 80 | el.insertAdjacentHTML('beforebegin', t); 81 | } else { 82 | EL().insertAdjacentHTML('beforeend', t); 83 | } 84 | 85 | if (err) { 86 | console.error(`✖ ${name}: Exception ${err}`); 87 | } else if (flen) { 88 | console.error(`✖ ${name}: ${desc}`); 89 | results.forEach(r => console.log(` ${r.result ? '✓' : '✖'} ${r.desc}`)); 90 | } else { 91 | console.log(`✓ ${name}: ${desc}`); 92 | } 93 | 94 | return {name: name, 95 | success: !(err || flen), 96 | error: err, 97 | results: results, 98 | duration: duration} 99 | } 100 | 101 | function delay(t) { 102 | return new Promise(resolve => setTimeout(resolve, t || 0, true)); 103 | } 104 | 105 | function prefix(defs, func) { 106 | var flen = func.length; 107 | return function() { 108 | var len = arguments.length; 109 | var args = len < flen ? 110 | [...defs.slice(0, flen - len), ...arguments] : 111 | arguments; 112 | return func.apply(null, args); 113 | } 114 | } 115 | 116 | function innerapi() { 117 | var results = []; 118 | return { 119 | delay: delay, 120 | assert: prefix(['assert'], (desc, result) => results.push({result, desc})), 121 | eq: prefix(['eq'], (desc, left, right) => { 122 | var res = left == right; 123 | results.push({ 124 | result: res, 125 | desc: `${desc}: ${left} ${res ? '==' : '!='} ${right}` 126 | }); 127 | }), 128 | _results: results, 129 | } 130 | } 131 | 132 | async function seqMap(arr, cb) { 133 | var res = []; 134 | for (var i = 0; i < arr.length; i++) { 135 | res.push(await cb(arr[i], i)); 136 | } 137 | return res; 138 | } 139 | 140 | var global = innerapi(); 141 | 142 | async function test(tests, opts) { 143 | opts || (opts = {}); 144 | var globalStart = performance.now(); 145 | 146 | tests = Array.isArray(tests) ? tests : [tests]; 147 | 148 | async function execute(test, idx) { 149 | var start = performance.now(); 150 | var t = innerapi(); 151 | var err; 152 | try { 153 | test.setup && test.setup(); 154 | await test.func(t); 155 | } catch(e) { 156 | console.error(e); 157 | err = e; 158 | } 159 | 160 | var results = t._results; 161 | if (global._results) { 162 | // somebody used global tt.assert 163 | results = results.concat(global._results); 164 | global._results.length = 0; 165 | } 166 | 167 | return doReport(idx, test.name, results, (performance.now() - start) | 0, err); 168 | } 169 | 170 | var report; 171 | if (opts.sync) { 172 | report = await seqMap(tests, execute); 173 | } else { 174 | var promises = tests.map(execute); 175 | report = await Promise.all(promises); 176 | } 177 | 178 | var duration = (performance.now() - globalStart) | 0; 179 | EL().insertAdjacentHTML( 180 | 'beforeend', 181 | `
    182 | Total run time: ${duration}ms 183 |
    `); 184 | 185 | EL().dispatchEvent(new CustomEvent('tt-done', { 186 | detail: {success: report.filter(r => !r.success).length == 0, 187 | report: report, 188 | duration: duration}})); 189 | return report; 190 | } 191 | 192 | return Object.assign(global, {test}); 193 | })(); 194 | --------------------------------------------------------------------------------