├── .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 | #  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 |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 |
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 |
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 |
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 |
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 |18 | Click me 19 |
20 | 21 | 29 | 37 |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 |18 | Hover me! 19 |
20 | 21 | 24 | 32 |
6 | This example demostrates how you can interfere with request using
7 | ts-req-before
.
8 |
18 | This will work with delay
19 | This is prevented
20 |
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 |
21 | 22 | 23 | So. Much. Data. 24 | 25 |
26 | 27 | 37 |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 |ts-data
attribute19 | 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 |Click a button to replace me with form data.
42 |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.
18 | I'm waiting... 19 | Do it! 20 |
21 | 22 | 25 | 33 |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)
).
Wanna read text behind me? 18 | Do it! 19 |
20 |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.
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 |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
:
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 |19 | Let's change history! 20 |
21 |22 | Let's change history 2! 23 |
24 |25 | Let's change history 3! 26 |
27 |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.
17 | Just click me 18 |
19 | 20 | 43 | 44 | 47 | 57 |Those are simple examples of using actions, see their source for details.
6 | 7 |remove
16 | Hey! I'm here! 17 | 18 |
19 |delay
40 | Remove with timeout 41 | 42 |
43 |wait
(waiting for an event to happen)66 | Remove after transition 67 | 68 |
69 |animate
96 | 97 |
98 |This is going to be removed
129 |And this only after transition
130 | 135 |html5sortable
5 | ----
6 |
7 | An advanced example showing how to use actions (in particular on
8 | and req
) in conjunction with
9 | html5sortable.
10 |
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 |
on
and req
43 | 🙂Smile 44 | 😇Halo 45 | 😁Beam 46 | 🤣ROFL 47 |
48 |Here you'll see sorted names.
49 |Doing something when element is almost visible makes it possible to implement lazy 6 | loading and various analytical events
7 | 8 |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 |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.
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.
23 | No clicks yet. 24 |
25 |
26 | Link to check that stopPropagation doesn't matter to outside
clicks.
27 |
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
.
19 | Element 1 20 | Element 2 21 | 25 |
26 | 27 |28 | Element 1 29 | Element 2 30 | 35 |
36 |
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 |
Update me!
24 |And me!
27 |Also waiting here.
30 |
6 | Redirect to target url, pass to response headers ts-location
parameter.
7 |
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 |
19 |
20 | Span 1
21 | Span 2
22 | Span 3
23 |
24 |
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.
Important bits
12 | 13 |id
attribute - so that morphing algorithm can find them reliably.<input type="submit">
's value - this
16 | way backend distinguish submission and validation.keyup
updates form on every character input and it feels
18 | natural - morph algorithm skips currently focused element so that state is
19 | intact.Solve this problem with odd numbers
30 | 47 |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 |20 | When this paragraph is removed by clicking button, it will resurrect itself. 21 | 22 |
23 |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 |
19 | 20 |
21 |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.
18 | Update me with a script 19 |
20 | 21 | 23 | 24 | 25 | 33 |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 |
31 |
37 |
38 |
44 |
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 |
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 |
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 |Selected filters:
40 |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 |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 |
6 | Simple POST
request with JSON body.
7 |
17 | Send! 21 |
22 | 23 | 35 |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 |ts-req
,
22 | ts-action
,
23 | ts-trigger
)
24 | and strives to avoid surprises. Also there are no dependencies
25 | on your server-side technology, you can use anything.
26 | ts-req
, add more actions,
38 | or change the outgoing requests - whatever your needs are.
39 | ' + 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 |
34 |
--------------------------------------------------------------------------------
/www/static/twinspark-icon.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/www/static/twinspark-logo.svg:
--------------------------------------------------------------------------------
1 |
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('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("A
B
C
D
A
B
C
D
A
B
A
B
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
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 |
This is a test page filled with common HTML elements to be used to provide visual feedback whilst building CSS systems and frameworks.
5 |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 |32 |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 |
— Nobody, Nonexistent Book
33 | 34 |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 |Written by Jon Doe.
114 | Visit us at:
115 | Example.com
116 | Box 564, Disneyland
117 | USA
Table Heading 1 | 127 |Table Heading 2 | 128 |Table Heading 3 | 129 |Table Heading 4 | 130 |Table Heading 5 | 131 |
---|---|---|---|---|
Table Footer 1 | 136 |Table Footer 2 | 137 |Table Footer 3 | 138 |Table Footer 4 | 139 |Table Footer 5 | 140 |
Table Cell 1 | 145 |Table Cell 2 | 146 |Table Cell 3 | 147 |Table Cell 4 | 148 |346896 | 149 |
Table Cell 1 | 152 |Table Cell 2 | 153 |Table Cell 3 | 154 |Table Cell 4 | 155 |234235 | 156 |
Table Cell 1 | 159 |Table Cell 2 | 160 |Table Cell 3 | 161 |Table Cell 4 | 162 |065454 | 163 |
Table Cell 1 | 166 |Table Cell 2 | 167 |Table Cell 3 | 168 |Table Cell 4 | 169 |143539 | 170 |
Keyboard input: Cmd A
175 |Inline code: <div>code</div>
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 | 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.
This text has a strikethrough.
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.
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 |228 | 229 |
398 |This is a test page filled with common HTML elements to be used to provide visual feedback whilst building CSS systems and frameworks.
5 |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 |31 |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 |
— Nobody, Nonexistent Book
32 | 33 |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 |Written by Jon Doe.
113 | Visit us at:
114 | Example.com
115 | Box 564, Disneyland
116 | USA
Table Heading 1 | 126 |Table Heading 2 | 127 |Table Heading 3 | 128 |Table Heading 4 | 129 |Table Heading 5 | 130 |
---|---|---|---|---|
Table Footer 1 | 135 |Table Footer 2 | 136 |Table Footer 3 | 137 |Table Footer 4 | 138 |Table Footer 5 | 139 |
Table Cell 1 | 144 |Table Cell 2 | 145 |Table Cell 3 | 146 |Table Cell 4 | 147 |234235 | 148 |
Table Cell 1 | 151 |Table Cell 2 | 152 |Table Cell 3 | 153 |Table Cell 4 | 154 |346896 | 155 |
Table Cell 1 | 158 |Table Cell 2 | 159 |Table Cell 3 | 160 |Table Cell 4 | 161 |065454 | 162 |
Table Cell 1 | 165 |Table Cell 2 | 166 |Table Cell 3 | 167 |Table Cell 4 | 168 |143539 | 169 |
Keyboard input: Cmd A
174 |Inline code: <div>code</div>
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 | 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.
This text has a strikethrough.
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.
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 |227 | 228 |
396 |Use Case | Description |
---|---|
{{ .Title }} | 12 |{{ .Other.Desc }} | 13 |
${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 = `