├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── README.md ├── examples ├── lifecycle │ ├── favicon.svg │ ├── index.html │ ├── main.js │ ├── package.json │ └── style.css └── store │ ├── favicon.svg │ ├── index.html │ ├── main.js │ ├── package.json │ └── style.css ├── index.js ├── package.json ├── server.js └── test ├── browser ├── component │ ├── api.js │ ├── mount.js │ ├── render.js │ └── update.js ├── html.js ├── mount.js ├── svg.js └── update.js └── server ├── api.js ├── component.js ├── html.js └── svg.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 16.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | index.d.ts 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | yeet 3 |

Teeny-weeny front end framework

4 | 5 | 6 | API stability 8 | 9 | 10 | 11 | NPM version 13 | 14 | 15 | 16 | Build Status 18 | 19 | 20 | 21 | Download 23 | 24 | 25 | 26 | Standard 28 | 29 |
30 |
31 | 32 | ## Features 33 | - **No transpilation** – It's all plain vanilla JavaScript 34 | - **Small size** – Weighing in at `3kb`, you'll barely notice it 35 | - **Minimal API** – Only a handfull functions to learn 36 | - **No magic** – Prototypal state and events 37 | - **It's fast** – Both on server and client 38 | 39 | ## Example 40 | ```js 41 | import { html, mount, use, Component } from 'https://cdn.skypack.dev/yeet@next' 42 | 43 | mount('body', Component(App)) 44 | 45 | function App (state, emit) { 46 | use(store) 47 | 48 | return function () { 49 | return html` 50 | 51 |

Clicked ${state.count} times

52 | 53 | 54 | ` 55 | } 56 | } 57 | 58 | function store (state, emitter) { 59 | state.count = 0 60 | emitter.on('increment', function () { 61 | state.count++ 62 | emitter.emit('render') 63 | }) 64 | } 65 | ``` 66 | 67 | ## Why yeet? 68 | Building interactive and performant websites shouldn't require a whole lot of 69 | dependencies, a bundler, or even Node.js for that matter. The JavaScript 70 | language has all the capabilities required built right in, without sacrificing 71 | either developer or user experience. 72 | 73 | Frameworks are tools and tools should be interchangeable and easy to replace. 74 | That's why yeet rely on the lowest common denominator – the DOM. There are no 75 | unneccessary abstractions such as virtual DOM, synthetic events or template 76 | syntax to learn. Only functions and prototypes. 77 | 78 | If you know JavaScript you already know most there is to know about yeet. And 79 | anything new you learn from using yeet is directly benefitial to anything else 80 | you might want to use JavaScript for. 81 | 82 | ## Prototypal state 83 | The state object in yeet is shared between components using prototypes. You can 84 | think of the state object as a shared context which components can use to read 85 | from and write to. 86 | 87 | However, a component can only ever mutate its own state, it can only read from 88 | the parent state, yet they are the same object – what?! This is achieved using 89 | prototypes. The prototype of a component's state object is the parent 90 | component's state object. 91 | 92 |
93 | About prototypal inheritance 94 | 95 | JavaScript prototypes are the mechanism for inheriting properties and behavior 96 | from one object to another. What is facinating about prototypes is that they 97 | are live – meaning that any change made to an object is immediately made 98 | available to all other objects whose prototype chain includes said object. 99 | 100 | ```js 101 | const parent = {} 102 | const child = Object.create(parent) 103 | 104 | parent.name = 'world' 105 | console.log(`Hello ${parent.name}`) // Hello world 106 | console.log(`Hello ${child.name}`) // Hello world 107 | 108 | child.name = 'planet' 109 | console.log(`Hello ${parent.name}`) // Hello world 110 | console.log(`Hello ${child.name}`) // Hello planet 111 | ``` 112 | 113 | Read more about [Object prototypes][Object prototypes]. 114 | 115 |
116 | 117 | To modify a parent state object, one can use events to communicate up the 118 | component tree (or prototype chain, if you will). 119 | 120 | ## Events 121 | Events are the core mechanism for communication up the component tree. Yeet 122 | adhers to the dogma "data down, events up", which is to say that data should be 123 | passed down the component tree, either with state or as arguments. When 124 | something happens, e.g. the user clicks a button, an event should be emitted 125 | which bubbles up the component tree, notifying components which may then mutate 126 | their state and issue a re-render. 127 | 128 | ## Components 129 | Components can be usefull in situations when you need a locally contained state, 130 | want to use some third party library or want to know when components mount or 131 | unmout in the DOM. 132 | 133 | Components in yeet use [generator functions][generator functions] to control the 134 | component lifecycle. By using generators yeet can step through your component 135 | and pause execution until the appropiate time, e.g. when the component has 136 | updated or is removed from the DOM. This allows you to retain local variables 137 | which persist throughout the component lifespan without meddling with `this` or 138 | learning new state management techinques, they're just regular ol' variables. 139 | 140 | ```js 141 | import { html, ref, mount, Component } from 'https://cdn.skypack.dev/yeet@next' 142 | import mapboxgl from 'https://cdn.skypack.dev/mapbox-gl' 143 | 144 | const state = { center: [18.0704503, 59.3244897] } 145 | 146 | mount('#app', Component(Map), state) 147 | 148 | function * Map (state, emit) { 149 | const container = ref() 150 | let map 151 | 152 | yield function * () { 153 | yield html`
` 154 | 155 | map = map || new mapboxgl.Map({ 156 | container: container.current, 157 | center: state.center 158 | }) 159 | } 160 | 161 | map.destroy() 162 | } 163 | ``` 164 | 165 | ### Generators 166 | Using generators allows you to keep local variables accessible throughout the 167 | component lifecycle. If you are already familiar with generators there's not 168 | really that much to learn. 169 | 170 | If you are new to generators, learning yeet will only further build your 171 | JavaScript toolset, there is nothing here which you cannot use in other 172 | contexts. 173 | 174 | A generator function is a special kind of function which can pause execution 175 | midway and allows us to inspect intermediate values before procceding with 176 | execution. A generator function has two caracteristics which set it appart from 177 | regular functions, and asterics (`*`) after the `function` keyword and the 178 | `yield` keyword. 179 | 180 |
181 | The anatomy of a generator function 182 | 183 | ```js 184 | // ↓ This thing makes it a generator function 185 | function * createGenerator (list) { 186 | for (const num of list) { 187 | yield num // ← Pause here 188 | } 189 | return 'finished!' 190 | } 191 | 192 | // ↓ Call it like any other function 193 | const generator = createGenerator([1, 2, 3]) 194 | 195 | // We can now step through the generator 196 | generator.next() // { value: 1, done: false } 197 | generator.next() // { value: 2, done: false } 198 | generator.next() // { value: 3, done: false } 199 | generator.next() // { value: 'finished!', done: true } 200 | ``` 201 | 202 |
203 | 204 | By yielding in a yeet component you are telling yeet to halt execution and save 205 | the rest of the function for later, e.g. when the component has updated or when 206 | it is removed from the DOM. A yeet component's lifecycle is thereby clearly laid 207 | out in chronological order, from top to bottom. 208 | 209 | #### Lifecycle 210 | Generators are used to declare the lifecycle of yeet components. Only functions, 211 | html partials (returned by the `html` and `svg` tags) and promises carry any 212 | special meaning when using `yield`. When a yeet component yields a function, 213 | that is the function which will be used for any consecutive re-renders. Anything 214 | that comes after `yield` will be executed once the components is removed from 215 | the DOM (e.g. replaced by another element). 216 | 217 | ```js 218 | function * MyComponent () { 219 | // Happens only once, during setup 220 | yield function () { 221 | // Happens every time the component updates 222 | } 223 | // Happens only once, when the component is removed/replaced 224 | } 225 | ``` 226 | 227 | They yielded function may also be a generator function. This can be used to 228 | perform side effects such as setting up subscriptions, manually manipulating the 229 | DOM or initializing some third party library. This is handled asynchrounously, 230 | meaning the DOM will have updated and the changes may have been made visible to 231 | the user before the generator finishes. 232 | 233 | ```js 234 | function MyComponent () { 235 | return function * () { 236 | // Happens before every update 237 | yield html`

Hello planet!

` 238 | // Happens after every update 239 | } 240 | } 241 | ``` 242 | 243 | If you require immediate access to the rendered element, e.g. to _synchronously_ 244 | mutate or inspect the rendered element _before_ the page updates, you may yield 245 | yet another function. 246 | 247 | _Note: Use with causion, this may have a negative impact on performance._ 248 | 249 | ```js 250 | function MyComponent () { 251 | return function () { 252 | return function * () { 253 | // Happens before every update 254 | yield html`

Hello planet!

` 255 | // Happens SYNCHRONOUSLY after every update 256 | } 257 | } 258 | } 259 | ``` 260 | 261 | #### Arguments (a.k.a. `props`) 262 | Even though all components have access to the shared state, you'll probably need 263 | to supply your components with some arguments to configure behavior or forward 264 | particular properties. You can either provide extra arguments to the `Component` 265 | function or you can call the function returned by `Component` with any number of 266 | arguments. 267 | 268 | ```js 269 | function Reaction (state, emit) { 270 | // ↓ Arguments are provided to the inner function 271 | return function ({ emoji }) { 272 | return html`` 273 | } 274 | } 275 | 276 | // ↓ Declare component on beforehand 277 | const ReactionComponent = Component(Reaction) 278 | 279 | // ↓ Declare component and arguments on beforehand 280 | const SadReaction = Component(Reaction, { emoji: '😢' }) 281 | 282 | html` 283 |
284 | ${Component(Reaction, { emoji: '😀' })} 285 | ${ReactionComponent({ emoji: '😐' })} 286 | ${SadReaction} 287 |
288 | ` 289 | ``` 290 | 291 | ### Async components 292 | Components can yield any value but if you yield a Promise yeet will await the 293 | promise before it continues to render. On the server, rendering is asynchronous by 294 | design, this means that all promises are resolved as the component renders. 295 | Rendering in the browser behaves a little differently. While awaiting a promise 296 | nothing will be rendered in place of the component. Once all yielded promises 297 | have resolved (or rejected) the component will finish rendering and the element 298 | will appear on the page. 299 | 300 | Yeet does not make any difference between promises which resolve or reject, you 301 | will have to catch and handle rejections accordingly, yeet will just forward the 302 | resolved or rejected value. 303 | 304 | ```js 305 | import fetch from 'cross-fetch' 306 | import { html, use } from 'yeet' 307 | 308 | function User (state, emit) { 309 | const get = use(api) // ← Register api store with component 310 | return function () { 311 | // ↓ Expose the promise to yeet 312 | const user = yield get(`/users/${state.user.id}`) 313 | return html` 314 | 315 |

${user.name}

316 | 317 | ` 318 | } 319 | } 320 | 321 | function api (state, emit) { 322 | if (!state.cache) state.cache = {} // ← Use existing cache if available 323 | 324 | // ↓ Return a function for lazily reading from the cache 325 | return function (url) { 326 | if (url in state.cache) return state.cache[url] // ← Read from cache 327 | return fetch(url).then(async function (res) { 328 | const data = await data.json() 329 | state.cache[url] = data // ← Store response in cache 330 | return data // ← Return repsonse 331 | }) 332 | } 333 | } 334 | ``` 335 | 336 | #### Lists and Keys 337 | In most situations yeet does an excellent job at keeping track of which 338 | component goes where. This is in part handled by identifying which template tags 339 | (the `html` and `svg` tag functions) are used. In JavaScript, template 340 | literals are unique and yeet leverages this to keep track of which template tag 341 | goes where. 342 | 343 | When it comes to components, yeet uses your component function as a unique key to 344 | keep track of which component is tied to which element in the DOM. 345 | 346 | When it comes to lists of identical components, this becomes difficult and yeet 347 | needs a helping hand in keeping track. In these situations, you can provide a 348 | unique `key` to each component which will be used to make sure that everything 349 | keeps running smoothly. 350 | 351 | ```js 352 | function Exponential (state, emit) { 353 | let exponent = 1 354 | 355 | function increment () { 356 | exponent++ 357 | emit('render') 358 | } 359 | 360 | return function ({ num }) { 361 | return html` 362 |
  • 363 | 364 |
  • 365 | ` 366 | } 367 | } 368 | 369 | const numbers = [1, 2, 3, 4, 5] 370 | return html` 371 |
      372 | ${numbers.map((num) => Component(Exponential, { num, key: num }))} 373 |
    374 | ` 375 | ``` 376 | 377 | ### Stores 378 | Stores are the mechanism for sharing behavior between components, or even apps. 379 | A store can subscribe to events, mutate the local state and issue re-renders. 380 | 381 | ```js 382 | import { html, use, Component } from 'https://cdn.skypack.dev/yeet@next' 383 | 384 | function Parent (state, emit) { 385 | use(counter) // ← Use the counter store with this component 386 | 387 | return function () { 388 | return html` 389 | ${Component(Increment)} 390 | ${state.count} 391 | ${Component(Decrement)} 392 | ` 393 | } 394 | } 395 | 396 | function Increment (state, emit) { 397 | return html`` 398 | } 399 | 400 | function Decrement (state, emit) { 401 | return html`` 402 | } 403 | 404 | function counter (state, emitter) { 405 | state.count = 0 // ← Define some initial state 406 | 407 | emitter.on('increment', function () { 408 | state.count++ 409 | emitter.emit('render') 410 | }) 411 | 412 | emitter.on('decrement', function () { 413 | state.count-- 414 | emitter.emit('render') 415 | }) 416 | } 417 | ``` 418 | 419 | #### Events 420 | How you choose to name your events is entirely up to you. There's only one 421 | exception: the `render` event has special meaning and will re-render the closest 422 | component in the component tree. The `render` event does not bubble. 423 | 424 | ## Server rendering (SSR) 425 | Yeet has first-class support for server rendering. There are plans to support 426 | server-rendered templates, meaning any backend could render the actual HTML and 427 | yeet would wire up functionality using the pre-existing markup. 428 | 429 | Rendering on the server supports fully asynchronous components. If a component 430 | yields promises, yeet will wait for these promises to resolve while rendering. 431 | 432 | ### Server rendered templates (non-Node.js) 433 | _Coming soon…_ 434 | 435 | ## API 436 | The API is intentionally small. 437 | 438 | ### html 439 | Create html partials which can be rendered to DOM nodes (or strings in Node.js). 440 | 441 | ```js 442 | import { html } from 'https://cdn.skypack.dev/yeet@next' 443 | 444 | const name = 'planet' 445 | html`

    Hello ${name}!

    ` 446 | ``` 447 | 448 | #### Attributes 449 | Both literal attributes as well as dynamically "spread" attributes work. Arrays 450 | will be joined with an empty space (` `) to make it easier to work with many 451 | space separated attributes, e.g. `class`. 452 | 453 | ```js 454 | import { html } from 'https://cdn.skypack.dev/yeet@next' 455 | 456 | const attrs = { disabled: true, hidden: false, placeholder: null } 457 | html`` 458 | // → 459 | ``` 460 | 461 | ##### Events 462 | Events can be attached to elements using the standard `on`-prefix. 463 | 464 | ```js 465 | import { html } from 'https://cdn.skypack.dev/yeet@next' 466 | 467 | html`` 468 | ``` 469 | 470 | #### Arrays 471 | If you have lists of things you want to render as elements, interpolating arrays 472 | works just like you'd expect. 473 | 474 | ```js 475 | import { html } from 'https://cdn.skypack.dev/yeet@next' 476 | 477 | const list = [1, 2, 3] 478 | html`
      ${list.map((num) => html`
    1. ${num}
    2. `)}
    ` 479 | ``` 480 | 481 | #### Fragments 482 | It's not always that you can or need to have an outer containing element. 483 | Rendering fragments works just like single container elements. 484 | 485 | ```js 486 | import { html } from 'https://cdn.skypack.dev/yeet@next' 487 | 488 | html` 489 |

    Hello world!

    490 |

    Lorem ipsum dolor sit amet…

    491 | ` 492 | ``` 493 | 494 | ### svg 495 | The `svg` tag is required for rendering all kinds of SVG elements, such as 496 | ``, ``, `` etc. All the same kinds of behaviors as described in 497 | [`html`](#html) apply to `svg`. 498 | 499 | ```js 500 | import { svg } from 'https://cdn.skypack.dev/yeet@next' 501 | 502 | svg` 503 | 504 | 505 | 506 | ` 507 | ``` 508 | 509 | ### raw 510 | If you have preformatted html that you wish to render, just interpolating them 511 | in the template won't work. Text that is interpolated in templates is 512 | automatically escaped to avoid common [XXS attacks][xxs], e.g. injecting script 513 | tags. 514 | 515 | ```js 516 | import { html, raw } from 'https://cdn.skypack.dev/yeet@next' 517 | 518 | const content = 'Hello world!' 519 | 520 | html`
    ${content}
    ` 521 | // →
    <strong>Hello world!</strong>
    522 | 523 | html`
    ${raw(content)}
    ` 524 | // →
    Hello world!
    525 | ``` 526 | 527 | ### ref 528 | It's common to want to access elements in the DOM to mutate or read properties. 529 | For this there is the `ref` helper which, when called, will return an object 530 | with the property `current` which will be the currently mounted DOM node it was 531 | attached to. 532 | 533 | _Note: This only works in the client, `current` will never be available while 534 | server rendering._ 535 | 536 | ```js 537 | import { html, ref, render } from 'https://cdn.skypack.dev/yeet@next' 538 | 539 | const div = ref() 540 | render(html`
    Hello planet!
    `) 541 | 542 | div.current // ← Reference to the rendered div element 543 | ``` 544 | 545 | ### use 546 | Register a store to use with component. Accepts a function which will be called 547 | with `state` and `emitter` (an instance of [`EventEmitter`](#eventemitter)). 548 | Whatever is returned by the supplied function is returned by `use`. You should 549 | refrain from using `use` anywhere but during the component setup stage. 550 | 551 | Stores are great for sharing functionality between components. A shared store 552 | can be used to handle common operations on the shared state object or just to 553 | avoid duplicating code between components. 554 | 555 | ```js 556 | import { html, use, ref } from 'https://cdn.skypack.dev/yeet@next' 557 | 558 | function * Video (state, emit) { 559 | const video = ref() 560 | const detach = use(pauser(video)) 561 | 562 | yield ({ src }) => html`` 563 | 564 | detach() 565 | } 566 | 567 | function pauser (video) { 568 | return function (state, emitter) { 569 | function onvisibilitychange () { 570 | if (document.visibilityState === 'visible') { 571 | video.current.play() 572 | } else { 573 | video.current.pause() 574 | } 575 | } 576 | 577 | document.addEventListener('visibilitychange', onvisibilitychange) 578 | 579 | return function () { 580 | document.removeEventListener('visibilitychange', onvisibilitychange) 581 | } 582 | } 583 | } 584 | ``` 585 | 586 | ### mount 587 | Mount a given html partial on a DOM node. Accepts a html partial, a DOM node or 588 | selector and optionally a root state object. 589 | 590 | ```js 591 | import { html, mount } from 'https://cdn.skypack.dev/yeet@next' 592 | 593 | mount('body', html` 594 | 595 |

    Hello planet!

    596 | 597 | `) 598 | ``` 599 | 600 | ```js 601 | import { html, mount, Component } from 'https://cdn.skypack.dev/yeet@next' 602 | 603 | mount(document.getElementById('app'), Component(Main), { name: 'world' }) 604 | 605 | function Main (state, emit) { 606 | return html` 607 |
    608 |

    Hello ${state.name}!

    609 |
    610 | ` 611 | } 612 | ``` 613 | 614 | ### render 615 | Render a partial to element (browser) or string (server). On the client, render 616 | is synchronous and the resulting DOM node is returned. On the server `render` 617 | always returns a promise which resolves to a string. Accepts an optional root 618 | state object. 619 | 620 | ```js 621 | import { html, render } from 'https://cdn.skypack.dev/yeet@next' 622 | 623 | const h1 = render(html`

    Hello planet!

    `)) 624 | document.body.appendChild(h1) 625 | ``` 626 | 627 | ```js 628 | import { html, render } from 'yeet' 629 | import { createServer } from 'http' 630 | 631 | createServer(async function (req, res) { 632 | const body = await render(html`Hello world!`) 633 | res.end(body) 634 | }).listen(8080) 635 | ``` 636 | 637 | ```js 638 | import { Readable } from 'stream' 639 | import { html, render } from 'yeet' 640 | import { createServer } from 'http' 641 | 642 | createServer(async function (req, res) { 643 | Readable.from(html`Hello world!`).pipe(res) 644 | }).listen(8080) 645 | ``` 646 | 647 | ### Component 648 | The Component function accepts a function as its first argument and any number 649 | of additional arguments. The additional arguments will be forwarded to the inner 650 | render function. The Component function returns a function which may be called 651 | with any number of arguments, these arguments will override whichever arguments 652 | were supplied prior. 653 | 654 | It is best practice to provide an object as the first render argument since the 655 | optional `key` property is extracted from the first render argument. 656 | 657 | ```js 658 | import { html, render, Component } from 'https://cdn.skypack.dev/yeet@next' 659 | 660 | function Greeting () { 661 | return function (props, name = 'world') { 662 | return html`

    ${props?.phrase || 'Hello'} ${name}!

    ` 663 | } 664 | } 665 | 666 | render(Component(Greeting)) 667 | // →

    Hello world!

    668 | 669 | render(Component(Greeting, { phrase: 'Hi' })) 670 | // →

    Hi world!

    671 | 672 | render(Component(Greeting, { phrase: 'Howdy' }, 'planet')) 673 | // →

    Howdy planet!

    674 | 675 | const Greeter = Component(Greeting) 676 | render(Greeter({ phrase: 'Nice to meet you,' })) 677 | // →

    Nice to meet you, world!

    678 | ``` 679 | 680 | ### EventEmitter 681 | Stores are called with state and an event emitter. The event emitter can be used 682 | to act on events submitted from e.g. user actions. All events except the 683 | `render` event bubbles up the component tree. 684 | 685 | You can register a catch-all event listener by attaching a listener for the `*` 686 | event. The first argument to catch-all listeners is the event name followed by 687 | the event arguments. 688 | 689 | ```js 690 | emitter.on('*', function (event, ...args) { 691 | console.log(`Emitted event "${event}" with arguments:`, ...args) 692 | }) 693 | ``` 694 | 695 | #### `emitter.on(string, Function)` 696 | Attach listener for the specified event name. 697 | 698 | #### `emitter.removeListener(string, Function)` 699 | Remove the event listener for the specified event name. 700 | 701 | #### `emitter.emit(string, ...any)` 702 | Emit an event of the specified name accompanied by any number of arguments. 703 | 704 | ## Attribution 705 | There wouldn't be a yeet if there hadn't been a [choo][choo]. Yeet borrows a lot 706 | of the core concepts such as a shared state and event emitter from choo. The 707 | idea of performant DOM updates based on template literals was born from proof 708 | of concept work done by [Renée Kooi][goto-bus-stop]. 709 | 710 | ## TODO 711 | [ ] Server-rendered templates (non-Node.js) 712 | 713 | [choo]: https://github.com/choojs/choo 714 | [goto-bus-stop]: https://github.com/goto-bus-stop 715 | [Object prototypes]: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes 716 | [generator functions]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* 717 | [xxs]: https://en.wikipedia.org/wiki/Cross-site_scripting 718 | -------------------------------------------------------------------------------- /examples/lifecycle/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/lifecycle/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Caffeine 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/lifecycle/main.js: -------------------------------------------------------------------------------- 1 | import { html, mount, Component } from '../../lib.js' 2 | import './style.css' 3 | 4 | mount(document.getElementById('app'), Component(Caffeine)) 5 | 6 | function * Caffeine (state, emit) { 7 | // ↓ Setup variables (only happens once per component lifetime) 8 | 9 | let interval 10 | let seconds = 5 11 | const reset = () => { 12 | seconds = 5 13 | clearInterval(interval) 14 | interval = null 15 | emit('render') 16 | } 17 | 18 | // ↓ Provide yeet with the component render function and halt 19 | yield function * () { 20 | // ↓ Tell yeet to render this before continuing 21 | yield html` 22 |
    23 |

    ${seconds 24 | ? `Click the button within ${seconds} seconds.` 25 | : 'Did you fall asleep?'}

    26 | 27 |
    28 | ` 29 | 30 | // ↓ Continue here once the component has mounted/updated 31 | if (!interval) { 32 | interval = setInterval(function () { 33 | seconds-- 34 | if (!seconds) clearInterval(interval) 35 | emit('render') 36 | }, 1000) 37 | } 38 | } 39 | 40 | // ↓ Continue here when removed from the DOM (only happens once) 41 | clearInterval(interval) 42 | } 43 | -------------------------------------------------------------------------------- /examples/lifecycle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lifecycle", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "devDependencies": { 10 | "vite": "^2.1.3" 11 | } 12 | } -------------------------------------------------------------------------------- /examples/lifecycle/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | text-align: center; 6 | color: #2c3e50; 7 | margin-top: 60px; 8 | } -------------------------------------------------------------------------------- /examples/store/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/store/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/store/main.js: -------------------------------------------------------------------------------- 1 | import { html, use, mount, Component } from '../../index.js' 2 | import './style.css' 3 | 4 | mount(document.getElementById('app'), Component(App)) 5 | 6 | const Comments = Component(function Comments (state, emit) { 7 | const api = use(cache) 8 | 9 | return function * ({ id }) { 10 | const { error, data } = yield api(`/posts/${id}/comments`) 11 | 12 | if (error) return html`

    Oops! Something went wrong.

    ` 13 | if (!data) return html`

    Loading…

    ` 14 | return html` 15 |
      16 | ${data.map((comment) => html` 17 |
    1. 18 | ${comment.name}: ${comment.body} 19 |
    2. 20 | `)} 21 |
    22 | ` 23 | } 24 | }) 25 | 26 | function Posts (state, emit) { 27 | const api = use(cache) 28 | let expanded 29 | 30 | return function * () { 31 | const { error, data } = yield api('/posts') 32 | 33 | if (error) return html`

    Oops! Something went wrong.

    ` 34 | if (!data) return html`

    Loading…

    ` 35 | return html` 36 | 55 | ` 56 | } 57 | } 58 | 59 | function App () { 60 | use(cache) 61 | return function () { 62 | return html` 63 |
    64 |

    Posts

    65 | ${Component(Posts)} 66 |
    67 | ` 68 | } 69 | } 70 | 71 | function cache (state, emitter) { 72 | if (!state.cache) state.cache = {} 73 | 74 | return function (uri) { 75 | if (uri in state.cache) return state.cache[uri] 76 | 77 | const url = `https://jsonplaceholder.typicode.com${uri}` 78 | const promise = window?.fetch(url).then(async function (body) { 79 | const data = await body.json() 80 | return { data } 81 | }, (error) => ({ error })).then(function (res) { 82 | state.cache[uri] = res 83 | emitter.emit('render') 84 | return res 85 | }) 86 | 87 | // Only expose the promise while server side rendering 88 | return typeof window === 'undefined' ? promise : {} 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /examples/store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "store", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "devDependencies": { 10 | "vite": "^2.1.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/store/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | text-align: center; 6 | color: #2c3e50; 7 | margin-top: 60px; 8 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const RENDER = 'render' 2 | const WILDCARD = '*' 3 | const TEXT_NODE = 3 4 | const ELEMENT_NODE = 1 5 | const COMMENT_NODE = 8 6 | const FRAGMENT_NODE = 11 7 | const PLACEHOLDER_NODE = /^yeet-\d+$/ 8 | const PLACEHOLDER = /(?:data-)?yeet-(\d+)/ 9 | const TAG = /<[a-z-]+ [^>]+$/i 10 | const COMMENT = /)/ 11 | const LEADING_WHITESPACE = /^\s+(<)/ 12 | const TRAILING_WHITESPACE = /(>)\s+$/ 13 | const ATTRIBUTE = /<[a-z-]+[^>]*?\s+(([^\t\n\f "'>/=]+)=("|')?)?$/i 14 | const HOOK = Symbol('HOOK') 15 | const ON = /^on/ 16 | const ON_UNMOUNT = 0 17 | const ON_UPDATE = 1 18 | const ON_RENDER = 2 19 | 20 | const { isArray } = Array 21 | const { assign, create, entries, keys } = Object 22 | const raf = window.requestAnimationFrame 23 | 24 | /** 25 | * @callback Editor 26 | * @param {Partial} partial 27 | */ 28 | 29 | /** 30 | * @callback Store 31 | * @param {object} state 32 | * @param {Emitter} emitter 33 | * @returns {any} 34 | */ 35 | 36 | /** 37 | * @callback Initialize 38 | * @param {object} state 39 | * @param {Emit} emit 40 | * @returns {any} 41 | */ 42 | 43 | /** 44 | * @callback Resolver 45 | * @param {any} value 46 | * @param {number} id 47 | * @param {function(any): any} next 48 | * @returns {any} 49 | */ 50 | 51 | /** @type {Array} */ 52 | const stack = [] 53 | 54 | /** @type {WeakMap} */ 55 | const refs = new WeakMap() 56 | 57 | /** @type {WeakMap} */ 58 | const cache = new WeakMap() 59 | 60 | /** @type {WeakMap} */ 61 | const events = new WeakMap() 62 | 63 | /** @type {WeakMap, Node>} */ 64 | const templates = new WeakMap() 65 | 66 | /** 67 | * Create HTML partial 68 | * @export 69 | * @param {Array} strings Template literal strings 70 | * @param {...any} values Template literal values 71 | * @returns {Partial} 72 | */ 73 | export function html (strings, ...values) { 74 | return new Partial(strings, values) 75 | } 76 | 77 | /** 78 | * Create SVG partial 79 | * @export 80 | * @param {Array} strings Template literal strings 81 | * @param {...any} values Template literal values 82 | * @returns {Partial} 83 | */ 84 | export function svg (strings, ...values) { 85 | return new Partial(strings, values, true) 86 | } 87 | 88 | /** 89 | * Treat raw HTML string as partial, bypassing HTML escape behavior 90 | * @export 91 | * @param {any} value 92 | * @returns {Partial} 93 | */ 94 | export function raw (value) { 95 | return new Partial([String(value)], []) 96 | } 97 | 98 | /** 99 | * Register a store function to be used for current component context 100 | * @export 101 | * @param {Store} fn Store function 102 | * @returns {any} 103 | */ 104 | export function use (fn) { 105 | const { state, emitter } = stack[0] 106 | return fn(state, emitter) 107 | } 108 | 109 | /** 110 | * Create element reference 111 | * @export 112 | * @returns {Ref} 113 | */ 114 | export function ref () { 115 | return new Ref() 116 | } 117 | 118 | /** 119 | * Render partial to Node 120 | * @export 121 | * @param {Partial} partial The partial to be rendered 122 | * @param {object} [state={}] Root state 123 | * @returns {Node} 124 | */ 125 | export function render (partial, state = {}) { 126 | return mount(null, partial, state) 127 | } 128 | 129 | /** 130 | * Mount partial onto DOM node 131 | * @export 132 | * @param {Node|string} node Any compatible node or node selector 133 | * @param {Partial} partial The partial to mount 134 | * @param {object} [state={}] Root state 135 | * @returns {Node} 136 | */ 137 | export function mount (node, partial, state = {}) { 138 | if (typeof node === 'string') node = document.querySelector(node) 139 | const cached = cache.get(node) 140 | if (cached?.key === partial.key) { 141 | update(cached, partial) 142 | return node 143 | } 144 | const ctx = new Context(partial.key, state) 145 | if (partial instanceof Component) { 146 | node = unwrap(partial, ctx, new Child(node)) 147 | } else { 148 | node = morph(partial, ctx, node) 149 | } 150 | if (node) cache.set(node, ctx) 151 | return toNode(node) 152 | } 153 | 154 | /** 155 | * Creates a stateful component 156 | * @export 157 | * @param {Initialize} fn Component initialize function 158 | * @param {...args} args Arguments forwarded to component render function 159 | * @returns {function(...any): Component} Component render function 160 | */ 161 | export function Component (fn, ...args) { 162 | const props = { fn, args, key: args[0]?.key || fn } 163 | if (this instanceof Component) return assign(this, props) 164 | return Object.setPrototypeOf(assign(function Render (..._args) { 165 | if (!_args.length) _args = args 166 | return new Component(fn, ..._args) 167 | }, props), Component.prototype) 168 | } 169 | Component.prototype = create(Partial.prototype) 170 | Component.prototype.constructor = Component 171 | 172 | /** 173 | * Render partial, optionally onto an existing node 174 | * @export 175 | * @param {Partial} partial The partial to be rendered 176 | * @param {Context} ctx Current rendering context 177 | * @param {Node} [node] Existing node 178 | * @returns {Node} 179 | */ 180 | function morph (partial, ctx, node) { 181 | const { editors } = ctx 182 | const template = partial instanceof Partial ? parse(partial) : toNode(partial) 183 | 184 | return fromTemplate(template, node) 185 | 186 | /** 187 | * Render template node onto existing node 188 | * @param {Node} template Node template element 189 | * @param {Node} [node] Existing node 190 | * @returns {Node} 191 | */ 192 | function fromTemplate (template, node) { 193 | const { nodeType } = template 194 | 195 | if (!node) node = template.cloneNode() 196 | if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) { 197 | const { nodeValue } = node 198 | if (PLACEHOLDER.test(nodeValue)) { 199 | const editor = (partial) => { 200 | node.nodeValue = resolvePlaceholders(nodeValue, partial.values) 201 | } 202 | editor(partial) 203 | editors.push(editor) 204 | } else if (node.nodeValue !== template.nodeValue) { 205 | node.nodeValue = template.nodeValue 206 | } 207 | return node 208 | } 209 | 210 | if (nodeType === ELEMENT_NODE) { 211 | const editor = createAttributeEditor(template, node) 212 | if (editor) { 213 | editor(partial) 214 | editors.push(editor) 215 | } 216 | } 217 | 218 | if (node.nodeType === FRAGMENT_NODE) node = [...node.childNodes] 219 | if (node instanceof Child) node = node.node 220 | 221 | const children = [] 222 | const oldChildren = isArray(node) ? [...node] : [...node.childNodes] 223 | template.childNodes.forEach(function eachChild (child, index) { 224 | if (isPlaceholder(child)) { 225 | const id = getPlaceholderId(child) 226 | const value = partial.values[id] 227 | const oldChild = pluck(value, oldChildren) 228 | child = new Child(oldChild, index, children, node) 229 | transform(child, value, ctx) 230 | editors.push(function editor (partial) { 231 | const isComponent = partial instanceof Component 232 | transform(child, isComponent ? partial : partial.values[id], ctx) 233 | }) 234 | } else { 235 | const newChild = fromTemplate(child, pluck(child, oldChildren)) 236 | child = new Child(null, index, children, node) 237 | upsert(child, newChild) 238 | } 239 | 240 | children[index] = child 241 | if (isArray(node)) node[index] = child 242 | }) 243 | 244 | remove(oldChildren) 245 | 246 | return node 247 | } 248 | } 249 | 250 | /** 251 | * Create an attribute editor function 252 | * @param {Node} template Template node 253 | * @param {Node} node Target node 254 | * @returns {Editor} 255 | */ 256 | function createAttributeEditor (template, node) { 257 | const placeholders = [] 258 | const fixed = [] 259 | 260 | for (const { name, value } of template.attributes) { 261 | if (PLACEHOLDER.test(name) || PLACEHOLDER.test(value)) { 262 | placeholders.push({ name, value }) 263 | node.removeAttribute(name) 264 | } else { 265 | fixed.push(name) 266 | if (node.getAttribute(name) !== value) { 267 | node.setAttribute(name, value) 268 | } 269 | } 270 | } 271 | 272 | if (!placeholders.length) return null 273 | 274 | /** @type {Editor} */ 275 | return function attributeEditor (partial) { 276 | const attrs = placeholders.reduce(function (attrs, { name, value }) { 277 | name = PLACEHOLDER.test(name) 278 | ? resolvePlaceholders(name, partial.values) 279 | : name 280 | value = PLACEHOLDER.test(value) 281 | ? resolvePlaceholders(value, partial.values) 282 | : value 283 | if (typeof name === 'object') { 284 | if (isArray(name)) { 285 | for (const value of name.flat()) { 286 | if (typeof value === 'object') assign(attrs, value) 287 | else attrs[value] = '' 288 | } 289 | } else { 290 | assign(attrs, name) 291 | } 292 | } else if (ON.test(name)) { 293 | const events = EventHandler.get(node) 294 | events.set(name, value) 295 | } else if (name === 'ref') { 296 | if (typeof value === 'function') value(node) 297 | else refs.set(value, node) 298 | } else if (value != null) { 299 | attrs[name] = value 300 | } 301 | return attrs 302 | }, {}) 303 | 304 | for (let [name, value] of entries(attrs)) { 305 | if (isArray(value)) value = value.join(' ') 306 | if (name in node) { 307 | node[name] = value 308 | } else if (node.getAttribute(name) !== value) { 309 | node.setAttribute(name, value) 310 | } 311 | } 312 | 313 | const allowed = keys(attrs).concat(fixed) 314 | for (const { name } of node.attributes) { 315 | if (!allowed.includes(name)) { 316 | if (name in node) { 317 | node[name] = typeof node[name] === 'boolean' ? false : '' 318 | } 319 | node.removeAttribute(name) 320 | } 321 | } 322 | } 323 | } 324 | 325 | /** 326 | * Transform child with given target value 327 | * @param {Child} child Current node child 328 | * @param {any} value The target value 329 | * @param {Context} ctx Current render context 330 | */ 331 | function transform (child, value, ctx) { 332 | if (!value) return upsert(child, null) 333 | 334 | const pick = pool(child.node) 335 | 336 | if (isArray(value)) { 337 | const newNode = value.flat().reduce(function (order, value, index) { 338 | let node = pick(value) 339 | while (node instanceof Child) node = node.node 340 | const newChild = new Child(node, index, order, child) 341 | transform(newChild, value, ctx) 342 | order.push(newChild) 343 | return order 344 | }, []) 345 | upsert(child, newNode) 346 | return 347 | } 348 | 349 | const oldNode = pick(value) 350 | const isPartial = value instanceof Partial 351 | 352 | if (isPartial && oldNode) { 353 | const cached = cache.get(oldNode) 354 | if (cached?.key === value.key) { 355 | update(cached, value) 356 | return 357 | } 358 | } 359 | 360 | if (isPartial) ctx = spawn(ctx, value.key) 361 | 362 | if (value instanceof Component) { 363 | value = unwrap(value, ctx, child) 364 | } else { 365 | value = morph(value, ctx, oldNode) 366 | } 367 | 368 | if (value && isPartial) cache.set(value, ctx) 369 | 370 | upsert(child, value) 371 | } 372 | 373 | /** 374 | * Unpack component render value 375 | * @param {Component} value Component which to unwrap 376 | * @param {Context} root The rendering context 377 | * @param {Child} child Current child 378 | * @param {number} index Current unwrap depth 379 | * @returns {any} 380 | */ 381 | function unwrap (value, root, child, index = 0) { 382 | let rerender 383 | let { fn, args } = value 384 | let ctx = root.stack[index] 385 | 386 | ctx.emitter.on(RENDER, () => onupdate()) 387 | ctx.editors.push(function editor (component) { 388 | args = component.args 389 | onupdate() 390 | }) 391 | 392 | try { 393 | stack.unshift(ctx) 394 | value = unwind(fn(ctx.state, ctx.emit), resolve) 395 | if (value instanceof Promise) { 396 | value.then(onupdate) 397 | return null 398 | } 399 | if (value instanceof Component) { 400 | while (value instanceof Component) { 401 | ctx = spawn(ctx, value.key) 402 | root.stack.push(ctx) 403 | value = unwrap(value, root, child, index + 1) 404 | } 405 | return value 406 | } else if (value instanceof Partial) { 407 | ctx = spawn(ctx, value.key) 408 | root.stack.push(ctx) 409 | } 410 | const pick = pool(child.node) 411 | const oldNode = pick(value) 412 | if (value) value = morph(value, ctx, oldNode) 413 | return value 414 | } finally { 415 | stack.shift() 416 | } 417 | 418 | function onupdate (value = unwind(call(rerender, ...args), resolve, ON_UPDATE)) { 419 | const next = root.stack[index + 1] 420 | if (next && next.key === value?.key) { 421 | update(next, value) 422 | } else { 423 | transform(child, value, index ? root.stack[index - 1] : root) 424 | } 425 | } 426 | 427 | /** @type {Resolver} */ 428 | function resolve (value, id, next) { 429 | if (value instanceof Promise) { 430 | return value.then(next, next).then(function (value) { 431 | if (id === ON_UNMOUNT) rerender = value 432 | return value 433 | }) 434 | } 435 | try { 436 | if (id === ON_UNMOUNT) rerender = value 437 | if (typeof value === 'function') { 438 | return unwind(value(...args), resolve, id + 1) 439 | } 440 | if (value instanceof Partial) return value 441 | } finally { 442 | if (next) { 443 | if (id === ON_UNMOUNT) ctx.emitter.emit(HOOK, once(next, value)) 444 | if (id === ON_RENDER) next(value) 445 | if (id === ON_UPDATE) raf(() => next(value)) 446 | } 447 | } 448 | return next ? next(value) : value 449 | } 450 | } 451 | 452 | /** 453 | * Recursively unwind nested generator functions 454 | * @param {any} value The value to unwind 455 | * @param {Resolver} resolve Resolver function 456 | * @param {number} id Current unwind depth 457 | * @returns {any} 458 | */ 459 | function unwind (value, resolve, id = ON_UNMOUNT) { 460 | if (isGenerator(value)) { 461 | let res = value.next() 462 | return resolve(res.value, id, function next (resolved) { 463 | if (res.done) return 464 | res = value.next(resolved) 465 | const arg = res.done ? res.value : resolve(res.value, id, next) 466 | return unwind(arg, resolve, id) 467 | }) 468 | } 469 | return resolve(value, id) 470 | } 471 | 472 | /** 473 | * Update node in-place 474 | * @param {Child} child Current child 475 | * @param {any} newNode New node to put in-place 476 | */ 477 | function upsert (child, newNode) { 478 | let { node: oldNode, index, order, parent } = child 479 | 480 | if (isArray(newNode) && !cache.has(newNode) && oldNode) { 481 | if (!isArray(oldNode)) oldNode = [oldNode] 482 | newNode.forEach(function (_node, _index) { 483 | while (_node instanceof Child) _node = _node.node 484 | if (!_node) return 485 | 486 | const oldIndex = oldNode.findIndex(function (_oldNode) { 487 | while (_oldNode instanceof Child) _oldNode = _oldNode.node 488 | return _oldNode === _node 489 | }) 490 | if (oldIndex !== -1) oldNode.splice(oldIndex, 1) 491 | 492 | putInPlace(_node, _index, newNode) 493 | }) 494 | 495 | remove(oldNode) 496 | } else if (newNode) { 497 | if (oldNode && newNode !== oldNode) { 498 | replace(oldNode, newNode) 499 | } else { 500 | putInPlace(newNode, index, order) 501 | } 502 | } else { 503 | remove(oldNode) 504 | } 505 | 506 | child.node = newNode 507 | 508 | function putInPlace (newNode, index, list) { 509 | let prev = findPrev(index, list) 510 | while (prev instanceof Child) prev = prev.node 511 | while (parent instanceof Child) parent = parent.parent 512 | if (prev) { 513 | if (prev.nextSibling !== newNode) { 514 | prev.after(toNode(newNode)) 515 | } 516 | } else if (isArray(parent)) { 517 | parent[index] = newNode 518 | } else if (parent.firstChild !== newNode) { 519 | parent.prepend(toNode(newNode)) 520 | } 521 | } 522 | } 523 | 524 | /** 525 | * Execute context editors with partial values 526 | * @param {Context} ctx Context which to update 527 | * @param {Partial} partial Partial with which to update 528 | */ 529 | function update (ctx, partial) { 530 | try { 531 | stack.unshift(ctx.state) 532 | for (const editor of ctx.editors) editor(partial) 533 | } finally { 534 | stack.shift() 535 | } 536 | } 537 | 538 | /** 539 | * Find previous node sibling 540 | * @param {number} index Where to start looking 541 | * @param {Array} list Node siblings 542 | * @returns {Node} 543 | */ 544 | function findPrev (index, list) { 545 | for (let i = index - 1; i >= 0; i--) { 546 | let prev = list[i] 547 | if (prev instanceof Child) prev = prev.node 548 | if (isArray(prev)) prev = findPrev(prev.length, prev) 549 | if (prev) return prev 550 | } 551 | const item = list[index] 552 | if (item instanceof Child && item.parent instanceof Child) { 553 | return findPrev(item.parent.index, item.parent.order) 554 | } 555 | } 556 | 557 | /** 558 | * Remove node 559 | * @param {Node|Child|Array} node Node to remove 560 | */ 561 | function remove (node) { 562 | while (node instanceof Child) node = node.node 563 | if (isArray(node)) { 564 | node.forEach(remove) 565 | } else if (node) { 566 | node.remove() 567 | unhook(node) 568 | } 569 | } 570 | 571 | /** 572 | * Replace node 573 | * @param {Node|Child|Array} oldNode Node to be replaced 574 | * @param {Node} newNode New node to insert 575 | */ 576 | function replace (oldNode, newNode) { 577 | while (oldNode instanceof Child) oldNode = oldNode.node 578 | if (isArray(oldNode)) { 579 | remove(oldNode.slice(1)) 580 | replace(oldNode[0], newNode) 581 | } else { 582 | oldNode.replaceWith(toNode(newNode)) 583 | unhook(oldNode) 584 | } 585 | } 586 | 587 | /** 588 | * Deplete all hooks registered with node 589 | * @param {Node} node Node by which to lookup hooks 590 | */ 591 | function unhook (node) { 592 | raf(function () { 593 | const cached = cache.get(node) 594 | if (cached) for (const hook of cached.hooks) hook() 595 | }) 596 | } 597 | 598 | /** 599 | * Create a pool of Nodes from which to pluck values 600 | * @param {Array} nodes List of nodes from which to pluck 601 | * @returns {function(any): Node} 602 | */ 603 | function pool (nodes) { 604 | nodes = isArray(nodes) && !cache.has(nodes) ? [...nodes] : [nodes] 605 | return (value) => pluck(value, nodes) 606 | } 607 | 608 | /** 609 | * Pluck matching node from list of nodes 610 | * @param {any} value Value for which to find a match 611 | * @param {Array} list List of nodes from which to pluck 612 | * @returns {Node} 613 | */ 614 | function pluck (value, list) { 615 | if (!value) return null 616 | for (let i = 0, len = list.length; i < len; i++) { 617 | let isMatch 618 | const child = list[i] 619 | const node = child instanceof Child ? child.node : child 620 | const cached = cache.get(node) 621 | if (!node) continue 622 | if (isArray(node) && !cached) return pluck(value, node) 623 | if (value instanceof Partial) { 624 | isMatch = cached?.key === value.key 625 | if (!isMatch) { 626 | if (cached) continue 627 | value = parse(value) 628 | } 629 | } else { 630 | if (cached) continue 631 | else if (child === value) isMatch = true 632 | } 633 | if (!isMatch) isMatch = node.nodeName === toNode(value).nodeName 634 | if (isMatch && (node.id || value.id)) isMatch = node.id === value.id 635 | if (isMatch) return list.splice(i, 1)[0] 636 | } 637 | return null 638 | } 639 | 640 | /** 641 | * Cast value to node 642 | * @param {any} value The value to be cast 643 | * @returns {Node} 644 | */ 645 | function toNode (value) { 646 | if (!value) return null 647 | if (value instanceof window.Node) return value 648 | if (value instanceof Child) return toNode(value.node) 649 | if (isArray(value)) { 650 | const fragment = document.createDocumentFragment() 651 | for (const node of value) fragment.append(toNode(node)) 652 | return fragment 653 | } 654 | return document.createTextNode(String(value)) 655 | } 656 | 657 | /** 658 | * Call provided function 659 | * @param {function(...any): any} fn Function to be called 660 | * @param {...any} args Arguments to forward to provided function 661 | * @returns {any} 662 | */ 663 | function call (fn, ...args) { 664 | return typeof fn === 'function' ? fn(...args) : fn 665 | } 666 | 667 | /** 668 | * Create wrapper for function to only be called once 669 | * @param {function(any): any} fn Function which to wrap 670 | * @param {...any} args Arguments to forward to function 671 | * @returns {function(): void} 672 | */ 673 | function once (fn, ...args) { 674 | let done = false 675 | return function () { 676 | if (done) return 677 | done = true 678 | fn(...args) 679 | } 680 | } 681 | 682 | /** 683 | * Determine wether value is generator 684 | * @param {any} obj Object to test 685 | * @returns {Boolean} 686 | */ 687 | function isGenerator (obj) { 688 | return obj && 689 | typeof obj.next === 'function' && 690 | typeof obj.throw === 'function' 691 | } 692 | 693 | /** 694 | * Create a new context, forwarding events to parent 695 | * @param {Context} parent Context object from which to inherit state 696 | * @param {any} key New context key value 697 | * @returns {Context} 698 | */ 699 | function spawn (parent, key) { 700 | const ctx = new Context(key, create(parent.state)) 701 | ctx.emitter.on('*', function (event, ...args) { 702 | if (event !== RENDER) parent.emit(event, ...args) 703 | }) 704 | return ctx 705 | } 706 | 707 | /** 708 | * Get placeholder id 709 | * @param {Node} node The placeholder node 710 | * @returns {number} 711 | */ 712 | function getPlaceholderId (node) { 713 | return +node.nodeValue.match(PLACEHOLDER)[1] 714 | } 715 | 716 | /** 717 | * Determine whether node is a placeholder node 718 | * @param {Node} node The node to test 719 | * @returns {Boolean} 720 | */ 721 | function isPlaceholder (node) { 722 | const { nodeValue, nodeType } = node 723 | return nodeType === COMMENT_NODE && PLACEHOLDER_NODE.test(nodeValue) 724 | } 725 | 726 | /** 727 | * Resolve values from placeholder string 728 | * @param {string} str String from which to match values 729 | * @param {Array} values List of values to replace placeholders with 730 | * @returns {any} 731 | */ 732 | function resolvePlaceholders (str, values) { 733 | const [match, id] = str.match(PLACEHOLDER) 734 | if (match === str) return values[+id] 735 | const pattern = new RegExp(PLACEHOLDER, 'g') 736 | return str.replace(pattern, (_, id) => values[+id]) 737 | } 738 | 739 | /** 740 | * Parse partial 741 | * @param {Partial} partial The partial to parse 742 | * @returns {Node} 743 | */ 744 | function parse (partial) { 745 | const { strings, isSVG } = partial 746 | let template = templates.get(strings) 747 | if (template) return template 748 | const { length } = strings 749 | let html = strings.reduce(function compile (html, string, index) { 750 | html += string 751 | if (index === length - 1) return html 752 | if (ATTRIBUTE.test(html) || COMMENT.test(html)) html += `yeet-${index}` 753 | else if (TAG.test(html)) html += `data-yeet-${index}` 754 | else html += `` 755 | return html 756 | }, '').replace(LEADING_WHITESPACE, '$1').replace(TRAILING_WHITESPACE, '$1') 757 | const hasSVGTag = html.startsWith('${html}` 759 | template = document.createElement('template') 760 | template.innerHTML = html 761 | template = template.content 762 | if (template.childNodes.length === 1 && !isPlaceholder(template.firstChild)) { 763 | template = template.firstChild 764 | if (isSVG && !hasSVGTag) template = template.firstChild 765 | } 766 | templates.set(strings, template) 767 | return template 768 | } 769 | 770 | /** 771 | * Child node container 772 | * @class Child 773 | * @param {Node} node Current node 774 | * @param {number} index Node position 775 | * @param {Array} order List of sibling nodes 776 | * @param {Node} parent Parent node 777 | */ 778 | function Child (node, index, order, parent) { 779 | this.node = node 780 | this.index = index 781 | this.order = order 782 | this.parent = parent 783 | } 784 | 785 | /** 786 | * Create a HTML partial object 787 | * @export 788 | * @class Partial 789 | * @param {Array} strings Template strings 790 | * @param {Array} values Template partials 791 | * @param {Boolean} isSVG Whether the partial is an SVG node 792 | */ 793 | export function Partial (strings, values, isSVG = false) { 794 | this.key = strings 795 | this.strings = strings 796 | this.values = values 797 | this.isSVG = isSVG 798 | } 799 | 800 | /** 801 | * Create a context object 802 | * @export 803 | * @class Context 804 | * @param {any} key Unique context identifier 805 | * @param {object} [state={}] Context state object 806 | */ 807 | function Context (key, state = {}) { 808 | this.key = key 809 | this.hooks = [] 810 | this.editors = [] 811 | this.state = state 812 | this.stack = [this] 813 | this.emitter = new Emitter() 814 | this.emit = this.emitter.emit.bind(this.emitter) 815 | this.emitter.on(HOOK, (fn) => this.hooks.push(fn)) 816 | } 817 | 818 | /** 819 | * Reference a mounted node via ref#current 820 | * @class Ref 821 | * @export 822 | */ 823 | class Ref { 824 | get current () { 825 | return refs.get(this) 826 | } 827 | } 828 | 829 | /** 830 | * Generic event emitter 831 | * @class Emitter 832 | * @extends {Map} 833 | */ 834 | class Emitter extends Map { 835 | /** 836 | * Attach listener for event 837 | * @param {string} event Event name 838 | * @param {function(...any): void} fn Event listener function 839 | * @memberof Emitter 840 | */ 841 | on (event, fn) { 842 | const listeners = this.get(event) 843 | if (listeners) listeners.add(fn) 844 | else this.set(event, new Set([fn])) 845 | } 846 | 847 | /** 848 | * Remove given listener for event 849 | * @param {string} event Event name 850 | * @param {function(...any): void} fn Registered listener 851 | * @memberof Emitter 852 | */ 853 | removeListener (event, fn) { 854 | const listeners = this.get(event) 855 | if (listeners) listeners.delete(fn) 856 | } 857 | 858 | /** 859 | * Emit event to all listeners 860 | * @param {string} event Event name 861 | * @param {...any} args Event parameters to be forwarded to listeners 862 | * @memberof Emitter 863 | */ 864 | emit (event, ...args) { 865 | if (event !== WILDCARD) this.emit(WILDCARD, event, ...args) 866 | if (!this.has(event)) return 867 | for (const fn of this.get(event)) fn(...args) 868 | } 869 | } 870 | 871 | /** 872 | * Implementation of EventListener 873 | * @link https://developer.mozilla.org/en-US/docs/web/api/eventlistener 874 | * @class EventHandler 875 | * @extends {Map} 876 | */ 877 | class EventHandler extends Map { 878 | /** 879 | * Create a new EventHandler 880 | * @param {Node} node The node onto which to attach events 881 | * @memberof EventHandler 882 | */ 883 | constructor (node) { 884 | super() 885 | this.node = node 886 | events.set(node, this) 887 | } 888 | 889 | /** 890 | * Get an existing EvetnHandler for node or create a new one 891 | * @param {Node} node The node to bind listeners to 892 | * @returns {EventHandler} 893 | */ 894 | static get (node) { 895 | return events.get(node) || new EventHandler(node) 896 | } 897 | 898 | /** 899 | * Delegate to assigned event listener 900 | * @param {Event} event 901 | * @returns {any} 902 | * @memberof EventHandler 903 | */ 904 | handleEvent (event) { 905 | const handle = this.get(event.type) 906 | return handle.call(event.currentTarget, event) 907 | } 908 | 909 | /** 910 | * Add event listener 911 | * @param {string} key Event name 912 | * @param {function(Event): any} value Event listener 913 | * @memberof EventHandler 914 | */ 915 | set (key, value) { 916 | const { node } = this 917 | const event = key.replace(ON, '') 918 | if (value) node.addEventListener(event, this) 919 | else node.removeEventListener(event, this) 920 | super.set(event, value) 921 | } 922 | } 923 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yeet", 3 | "description": "A teeny-weeny frontend framework for creating websites", 4 | "version": "1.0.0-1", 5 | "main": "dist/server.cjs.js", 6 | "browser": "dist/browser.cjs.js", 7 | "types": "dist/index.d.ts", 8 | "type": "module", 9 | "exports": { 10 | "import": "./index.js", 11 | "node": "./server.js", 12 | "default": "./index.js" 13 | }, 14 | "scripts": { 15 | "test": "uvu test/server && umu test/browser && standard", 16 | "build": "npm run build:server && npm run build:browser && echo '{\"type\":\"commonjs\"}' > dist/package.json", 17 | "build:server": "rollup --no-esModule --format=cjs --file=dist/server.cjs.js server.js", 18 | "build:browser": "rollup --no-esModule --format=cjs --file=dist/browser.cjs.js index.js", 19 | "compile": "npx -p typescript tsc *.js --declaration --allowJs --emitDeclarationOnly --outFile dist/index.d.ts", 20 | "prepublishOnly": "npm run build && npm run compile" 21 | }, 22 | "files": [ 23 | "index.js", 24 | "server.js", 25 | "dist" 26 | ], 27 | "author": "Carl Törnqvist ", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "rollup": "^2.40.0", 31 | "standard": "^16.0.3", 32 | "umu": "^0.0.2", 33 | "uvu": "^0.5.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const RENDER = 'render' 2 | const REF_ATTR = /\s*ref=("|')?$/i 3 | const ATTRIBUTE = /<[a-z-]+[^>]*?\s+(([^\t\n\f "'>/=]+)=("|')?)?$/i 4 | const BOOL_PROPS = [ 5 | 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 6 | 'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden', 7 | 'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline', 8 | 'readonly', 'required', 'reversed', 'selected' 9 | ] 10 | 11 | /** 12 | * @callback Store 13 | * @param {object} state 14 | * @param {Emitter} emitter 15 | * @returns {any} 16 | */ 17 | 18 | /** 19 | * @callback Initialize 20 | * @param {object} state 21 | * @param {Emit} emit 22 | * @returns {any} 23 | */ 24 | 25 | /** @type {Context|null} */ 26 | let current 27 | 28 | /** @type {WeakMap} */ 29 | const cache = new WeakMap() 30 | 31 | /** 32 | * @callback Store 33 | * @param {object} state 34 | * @param {Emitter} emitter 35 | * @returns {any} 36 | */ 37 | 38 | /** 39 | * Register a store function to be used for current component context 40 | * @export 41 | * @param {Store} fn Store function 42 | * @returns {any} 43 | */ 44 | export function use (fn) { 45 | return fn(current.state, current.emitter) 46 | } 47 | 48 | /** 49 | * Create HTML partial 50 | * @export 51 | * @param {Array} strings Template literal strings 52 | * @param {...any} values Template literal values 53 | * @returns {Partial} 54 | */ 55 | export function html (strings, ...values) { 56 | return new Partial({ strings, values }) 57 | } 58 | 59 | /** 60 | * Create SVG partial 61 | * @export 62 | * @param {Array} strings Template literal strings 63 | * @param {...any} values Template literal values 64 | * @returns {Partial} 65 | */ 66 | export const svg = html 67 | 68 | /** 69 | * Treat raw HTML string as partial, bypassing HTML escape behavior 70 | * @export 71 | * @param {any} value 72 | * @returns {Partial} 73 | */ 74 | export function raw (value) { 75 | return new Raw(value) 76 | } 77 | 78 | /** 79 | * Declare where partial is to be mounted in DOM, useful for SSR 80 | * @export 81 | * @param {Node|string} node Any compatible node or node selector 82 | * @param {Partial} partial The partial to mount 83 | * @param {object} [state={}] Root state 84 | * @returns {Partial} 85 | */ 86 | export function mount (selector, partial, state = {}) { 87 | partial.selector = selector 88 | partial.state = state 89 | return partial 90 | } 91 | 92 | /** 93 | * Render partial to promise 94 | * @export 95 | * @param {Partial} partial The partial to be rendered 96 | * @param {object} [state={}] Root state 97 | * @returns {Promise} 98 | */ 99 | export async function render (partial, state = {}) { 100 | if (partial instanceof Component) partial = await unwrap(partial, state) 101 | if (!(partial instanceof Partial)) return Promise.resolve(partial) 102 | 103 | let string = '' 104 | for await (const chunk of parse(partial, state)) string += chunk 105 | return string 106 | } 107 | 108 | /** 109 | * Create element reference 110 | * @export 111 | * @returns {Ref} 112 | */ 113 | export function ref () { 114 | return new Ref() 115 | } 116 | 117 | /** 118 | * Create a context object 119 | * @class Context 120 | * @param {object} [state={}] Initial state 121 | */ 122 | function Context (state = {}) { 123 | const ctx = cache.get(state) 124 | if (ctx) state = Object.create(state) 125 | this.emitter = new Emitter(ctx?.emitter) 126 | this.state = state 127 | cache.set(state, this) 128 | } 129 | 130 | /** 131 | * Holder of raw HTML value 132 | * @class Raw 133 | */ 134 | class Raw extends String {} 135 | 136 | /** 137 | * Create a HTML partial object 138 | * @export 139 | * @class Partial 140 | */ 141 | export class Partial { 142 | constructor ({ strings, values }) { 143 | this.strings = strings 144 | this.values = values 145 | } 146 | 147 | async * [Symbol.asyncIterator] (state = {}) { 148 | yield * parse(this, state) 149 | } 150 | } 151 | 152 | /** 153 | * Creates a stateful component 154 | * @export 155 | * @param {Initialize} fn Component initialize function 156 | * @param {...args} args Arguments forwarded to component render function 157 | * @returns {function(...any): Component} Component render function 158 | */ 159 | export function Component (fn, ...args) { 160 | const props = { fn, args, key: args[0]?.key || fn } 161 | if (this instanceof Component) return Object.assign(this, props) 162 | return Object.setPrototypeOf(Object.assign(function Render (..._args) { 163 | if (!_args.length) _args = args 164 | return new Component(fn, ..._args) 165 | }, props), Component.prototype) 166 | } 167 | Component.prototype = Object.create(Partial.prototype) 168 | Component.prototype.constructor = Component 169 | Component.prototype[Symbol.asyncIterator] = async function * (state = {}) { 170 | yield * await unwrap(this, state) 171 | } 172 | 173 | /** 174 | * Create iterable for partial 175 | * @param {Partial} partial The partial to parse 176 | * @param {object} [state={}] Root state passed down to components 177 | * @memberof Partial 178 | * @returns {AsyncGenerator} 179 | */ 180 | async function * parse (partial, state = {}) { 181 | const { strings, values } = partial 182 | 183 | // Claim top level state to prevent mutations 184 | if (!cache.has(state)) cache.set(state, partial) 185 | 186 | let html = '' 187 | for (let i = 0, len = strings.length; i < len; i++) { 188 | const string = strings[i] 189 | let value = await values[i] 190 | 191 | // Aggregate HTML as we pass through 192 | html += string 193 | 194 | const isAttr = ATTRIBUTE.test(html) 195 | 196 | // Flatten arrays 197 | if (Array.isArray(value)) { 198 | value = await Promise.all(value.flat()) 199 | } 200 | 201 | if (isAttr) { 202 | if (value instanceof Ref) { 203 | const match = REF_ATTR.exec(string) 204 | console.assert(match, !match && `yeet: Got a ref as value for \`${string.match(ATTRIBUTE)?.[2]}\`, use instead \`ref=\${myRef}\`.`) 205 | yield string.replace(match[0], '') 206 | continue 207 | } else if (typeof value === 'boolean' || value == null) { 208 | const [, attr, name, quote] = html.match(ATTRIBUTE) 209 | if (attr && BOOL_PROPS.includes(name)) { 210 | console.assert(!quote, quote && `yeet: Boolean attribute \`${name}\` should not be quoted, use instead \`${name}=\${${JSON.stringify(value)}}\`.`) 211 | // Drop falsy boolean attributes altogether 212 | if (!value) yield string.slice(0, (attr.length + 1) * -1) 213 | // Leave only the attribute name in place for truthy attributes 214 | else yield string.slice(0, (attr.length - name.length) * -1) 215 | continue 216 | } 217 | } else if (Array.isArray(value)) { 218 | value = await Promise.all(value.map(function (val) { 219 | return isObject(val) ? objToAttrs(val) : val 220 | })) 221 | value = value.join(' ') 222 | } else if (isObject(value)) { 223 | value = await objToAttrs(value) 224 | } 225 | 226 | html += value 227 | yield string + value 228 | continue 229 | } 230 | 231 | // No use of aggregate outside attributes 232 | html = '' 233 | yield string 234 | 235 | if (value != null) { 236 | yield * resolve(value, state) 237 | } 238 | } 239 | } 240 | 241 | /** 242 | * Resolve a value to string 243 | * @param {any} value The value to resolve 244 | * @param {object} state Current state 245 | * @returns {AsyncGenerator} 246 | */ 247 | async function * resolve (value, state) { 248 | if (Array.isArray(value)) { 249 | for (const val of value) yield * resolve(val, state) 250 | return 251 | } 252 | 253 | if (value instanceof Component) value = await unwrap(value, state) 254 | if (value instanceof Partial) { 255 | yield * parse(value, state) 256 | } else { 257 | yield value instanceof Raw ? value : escape(value) 258 | } 259 | } 260 | 261 | /** 262 | * Escape HTML characters 263 | * @param {string} value 264 | * @returns {string} 265 | */ 266 | function escape (value) { 267 | return String(value) 268 | .replace(/&/g, '&') 269 | .replace(//g, '>') 271 | .replace(/"/g, '"') 272 | .replace(/'/g, ''') 273 | } 274 | 275 | /** 276 | * Unwrap Component value 277 | * @param {Component} component 278 | * @param {object} state 279 | * @returns {any} 280 | */ 281 | function unwrap (component, state) { 282 | const { fn, args } = component 283 | const ctx = current = new Context(state) 284 | const emit = ctx.emitter.emit.bind(ctx.emitter) 285 | return unwind(fn(ctx.state, emit), ctx, args) 286 | } 287 | 288 | /** 289 | * Serialize an object to HTML attributes 290 | * @param {object} obj An object 291 | * @returns {Promise} 292 | */ 293 | async function objToAttrs (obj) { 294 | const arr = [] 295 | for (let [key, value] of Object.entries(obj)) { 296 | value = await value 297 | arr.push(`${key}="${value}"`) 298 | } 299 | return arr.join(' ') 300 | } 301 | 302 | /** 303 | * Unwind nested generators, awaiting yielded promises 304 | * @param {any} value The value to unwind 305 | * @param {Context} ctx Current context 306 | * @param {Array} args Arguments to forward to setup functions 307 | * @returns {Promise<*>} 308 | */ 309 | async function unwind (value, ctx, args) { 310 | while (typeof value === 'function') { 311 | current = ctx 312 | value = value(...args) 313 | args = [] 314 | } 315 | if (value instanceof Component) { 316 | value = await unwrap(value, ctx.state) 317 | } 318 | if (isGenerator(value)) { 319 | let res = value.next() 320 | while (!res.done && (!res.value || res.value instanceof Promise)) { 321 | if (res.value instanceof Promise) { 322 | res.value = await res.value 323 | current = ctx 324 | } 325 | res = value.next(res.value) 326 | } 327 | return unwind(res.value, ctx, args) 328 | } 329 | return value 330 | } 331 | 332 | /** 333 | * Determine whether value is a plain object 334 | * @param {any} value 335 | * @returns {boolean} 336 | */ 337 | function isObject (value) { 338 | return Object.prototype.toString.call(value) === '[object Object]' 339 | } 340 | 341 | /** 342 | * Determine whether value is a generator object 343 | * @param {any} obj 344 | * @returns {boolean} 345 | */ 346 | function isGenerator (obj) { 347 | return obj && 348 | typeof obj.next === 'function' && 349 | typeof obj.throw === 'function' 350 | } 351 | 352 | /** 353 | * Create a reference to a element node (available in Browser only) 354 | * @class Ref 355 | */ 356 | class Ref {} 357 | 358 | /** 359 | * Generic event emitter 360 | * @class Emitter 361 | * @extends {Map} 362 | */ 363 | class Emitter extends Map { 364 | constructor (emitter) { 365 | super() 366 | if (emitter) { 367 | // Forward all event to provided emitter 368 | this.on('*', emitter.emit.bind(emitter)) 369 | } 370 | } 371 | 372 | /** 373 | * Attach listener for event 374 | * @param {string} event Event name 375 | * @param {function(...any): void} fn Event listener function 376 | * @memberof Emitter 377 | */ 378 | on (event, fn) { 379 | const listeners = this.get(event) 380 | if (listeners) listeners.add(fn) 381 | else this.set(event, new Set([fn])) 382 | } 383 | 384 | /** 385 | * Remove given listener for event 386 | * @param {string} event Event name 387 | * @param {function(...any): void} fn Registered listener 388 | * @memberof Emitter 389 | */ 390 | removeListener (event, fn) { 391 | const listeners = this.get(event) 392 | if (listeners) listeners.delete(fn) 393 | } 394 | 395 | /** 396 | * Emit event to all listeners 397 | * @param {string} event Event name 398 | * @param {...any} args Event parameters to be forwarded to listeners 399 | * @memberof Emitter 400 | */ 401 | emit (event, ...args) { 402 | if (event === RENDER) return 403 | if (event !== '*') this.emit('*', event, ...args) 404 | if (!this.has(event)) return 405 | for (const fn of this.get(event)) fn(...args) 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /test/browser/component/api.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { Partial, Component, html, use, render, mount } from '../../../index.js' 4 | 5 | const component = suite('component') 6 | const args = suite('arguments') 7 | const state = suite('state') 8 | const stores = suite('stores') 9 | const lifecycle = suite('lifecycle') 10 | 11 | component('inherits partial', function () { 12 | assert.type(Component, 'function') 13 | assert.ok(Object.isPrototypeOf.call(Partial.prototype, Component.prototype)) 14 | }) 15 | 16 | component('returns component object', function () { 17 | const fn = Component(Function.prototype) 18 | assert.type(fn, 'function') 19 | const res = fn() 20 | assert.instance(res, Partial) 21 | assert.instance(res, Component) 22 | }) 23 | 24 | args('should be function', function () { 25 | const Main = Component(null) 26 | assert.throws(() => render(Main)) 27 | }) 28 | 29 | args('inital arguments', function () { 30 | const MyComponent = Component(function (state, emit) { 31 | assert.type(state, 'object') 32 | assert.type(emit, 'function') 33 | }) 34 | render(MyComponent('test')) 35 | }) 36 | 37 | args('are forwarded', function () { 38 | const MyComponent = Component(function () { 39 | return function (str) { 40 | assert.is(str, 'test') 41 | } 42 | }) 43 | render(MyComponent('test')) 44 | }) 45 | 46 | args('can be provided on declaration', function () { 47 | const MyComponent = Component(Main, 'world') 48 | render(MyComponent) 49 | function Main () { 50 | return (name) => assert.is(name, 'world') 51 | } 52 | }) 53 | 54 | args('can be supplied when calling', function () { 55 | const MyComponent = Component(Main) 56 | render(MyComponent('world')) 57 | function Main () { 58 | return (name) => assert.is(name, 'world') 59 | } 60 | }) 61 | 62 | args('provided when called override declaration arguments', function () { 63 | const MyComponent = Component(Main, 'world') 64 | render(MyComponent('planet')) 65 | function Main () { 66 | return (name) => assert.is(name, 'planet') 67 | } 68 | }) 69 | 70 | state('is inherited', function () { 71 | render(html` 72 |
    73 | ${Component(function (rootState, emit) { 74 | return function () { 75 | return html` 76 |
    77 | ${Component(function (innerState, emit) { 78 | assert.is.not(innerState, rootState) 79 | assert.ok(Object.isPrototypeOf.call(rootState, innerState)) 80 | })} 81 |
    82 | ` 83 | } 84 | })} 85 |
    86 | `) 87 | }) 88 | 89 | state('is mutable', function () { 90 | const initialState = {} 91 | const MyComponent = Component(function (state, emit) { 92 | assert.is(state, initialState) 93 | state.test = 'test' 94 | }) 95 | render(MyComponent('test'), initialState) 96 | assert.is(initialState.test, 'test') 97 | }) 98 | 99 | stores('arguments', function () { 100 | render(Component(function (rootState, emit) { 101 | use(function (innerState, emitter) { 102 | assert.is(innerState, rootState) 103 | assert.type(emitter, 'object') 104 | assert.type(emitter.on, 'function') 105 | assert.type(emitter.emit, 'function') 106 | assert.type(emitter.removeListener, 'function') 107 | }) 108 | })) 109 | }) 110 | 111 | stores('can return', function () { 112 | render(Component(function (state, emit) { 113 | assert.is('test', use(() => 'test')) 114 | })) 115 | }) 116 | 117 | stores('can listen for events', function () { 118 | let count = 0 119 | render(Component(function (state, emit) { 120 | use(function (state, emitter) { 121 | emitter.on('test', function (value) { 122 | assert.is(++count, 2) 123 | assert.is(value, 'value') 124 | }) 125 | 126 | const fail = assert.unreachable 127 | emitter.on('test', fail) 128 | emitter.removeListener('test', fail) 129 | 130 | emitter.on('*', function (event, value) { 131 | assert.is(++count, 1) 132 | assert.is(event, 'test') 133 | assert.is(value, 'value') 134 | }) 135 | }) 136 | 137 | return function () { 138 | emit('test', 'value') 139 | } 140 | })) 141 | assert.is(count, 2) 142 | }) 143 | 144 | stores('events bubble', function () { 145 | let count = 0 146 | render(html` 147 |
    148 | ${Component(function () { 149 | use(function (state, emitter) { 150 | emitter.on('test', function (value) { 151 | count++ 152 | assert.is(value, 'value') 153 | }) 154 | }) 155 | return function () { 156 | return html` 157 |
    158 | ${Component(function (state, emit) { 159 | use(function (state, emitter) { 160 | emitter.on('test', function (value) { 161 | count++ 162 | assert.is(value, 'value') 163 | }) 164 | }) 165 | emit('test', 'value') 166 | })} 167 |
    168 | ` 169 | } 170 | })} 171 |
    172 | `) 173 | assert.is(count, 2) 174 | }) 175 | 176 | lifecycle('resolves top level promises', async function () { 177 | const res = render(html`

    Hello ${Component(Main)}!

    `) 178 | assert.is(res.outerHTML, '

    Hello !

    ') 179 | await new Promise((resolve) => setTimeout(resolve, 400)) 180 | assert.is(res.outerHTML, '

    Hello world!

    ') 181 | 182 | function * Main () { 183 | yield new Promise((resolve) => setTimeout(resolve, 100)) 184 | const value = yield new Promise((resolve) => setTimeout(resolve, 100, 'world')) 185 | yield new Promise((resolve) => setTimeout(resolve, 100)) 186 | return value 187 | } 188 | }) 189 | 190 | lifecycle('resolves nested promises', async function () { 191 | const res = render(html`

    Hello ${Component(Main)}!

    `) 192 | assert.is(res.outerHTML, '

    Hello !

    ') 193 | await new Promise((resolve) => setTimeout(resolve, 400)) 194 | assert.is(res.outerHTML, '

    Hello world!

    ') 195 | 196 | function Main () { 197 | return function * () { 198 | yield new Promise((resolve) => setTimeout(resolve, 100)) 199 | const value = yield new Promise((resolve) => setTimeout(resolve, 100, 'world')) 200 | yield new Promise((resolve) => setTimeout(resolve, 100)) 201 | return value 202 | } 203 | } 204 | }) 205 | 206 | lifecycle('unwinds nested functions', function () { 207 | let depth = 0 208 | render(Component(function (state, emit) { 209 | assert.is(++depth, 1) 210 | return function (str) { 211 | assert.is(++depth, 2) 212 | return function () { 213 | assert.is(++depth, 3) 214 | } 215 | } 216 | })) 217 | assert.is(depth, 3) 218 | }) 219 | 220 | lifecycle('resolves generators', async function () { 221 | let setup = 0 222 | let update = 0 223 | let render = 0 224 | let unmount = 0 225 | const div = document.createElement('div') 226 | 227 | await new Promise(function (resolve, reject) { 228 | mount(div, html`
    ${Component(Main)}
    `) 229 | assert.is(setup, 1, 'setup called once') 230 | assert.is(update, 0, 'update not called yet') 231 | assert.is(render, 1, 'render called once') 232 | assert.is(unmount, 0, 'unmount not called') 233 | assert.is(div.outerHTML, '

    Hello planet!

    ') 234 | window.requestAnimationFrame(function () { 235 | assert.is(update, 1, 'update called in next frame') 236 | resolve() 237 | }) 238 | }) 239 | 240 | await new Promise(function (resolve, reject) { 241 | mount(div, html`
    ${Component(Main)}
    `) 242 | assert.is(setup, 1, 'setup still only called once') 243 | assert.is(update, 1, 'update still only called once') 244 | assert.is(render, 2, 'render called twice') 245 | assert.is(unmount, 0, 'unmount still not called') 246 | window.requestAnimationFrame(function () { 247 | assert.is(update, 2, 'update called again in next frame') 248 | resolve() 249 | }) 250 | }) 251 | 252 | await new Promise(function (resolve) { 253 | mount(div, html`

    Hello world!

    `) 254 | window.requestAnimationFrame(function () { 255 | assert.is(unmount, 1, 'unmount called once') 256 | resolve() 257 | }) 258 | }) 259 | 260 | function * Main (state, emit) { 261 | setup++ 262 | yield function * onupdate (str) { 263 | yield function * onrender () { 264 | yield html`

    Hello planet!

    ` 265 | render++ 266 | } 267 | update++ 268 | } 269 | unmount++ 270 | } 271 | }) 272 | 273 | lifecycle('children unmount w/ parent', async function () { 274 | let counter = 0 275 | const div = document.createElement('div') 276 | 277 | mount(div, html`
    ${Component(Parent)}
    `) 278 | await new Promise(function (resolve) { 279 | mount(div, html`

    Hello world!

    `) 280 | window.requestAnimationFrame(function () { 281 | assert.is(counter, 2) 282 | resolve() 283 | }) 284 | }) 285 | 286 | function * Parent () { 287 | yield function () { 288 | return html`
    ${Component(Child)}
    ` 289 | } 290 | counter++ 291 | } 292 | 293 | function * Child () { 294 | yield function () { 295 | return html`

    Hello planet!

    ` 296 | } 297 | counter++ 298 | } 299 | }) 300 | 301 | component.run() 302 | args.run() 303 | state.run() 304 | stores.run() 305 | lifecycle.run() 306 | -------------------------------------------------------------------------------- /test/browser/component/mount.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, mount, Component } from '../../../index.js' 4 | 5 | const api = suite('api') 6 | const reuse = suite('reuse') 7 | 8 | api('can mount', function () { 9 | const div = document.createElement('div') 10 | mount(div, Component(Main)) 11 | assert.is(div.outerHTML, '
    Hello world!
    ') 12 | 13 | function Main (state, emit) { 14 | return html`
    Hello world!
    ` 15 | } 16 | }) 17 | 18 | reuse('immediate child component', function () { 19 | const div = document.createElement('div') 20 | let init = 0 21 | 22 | mount(div, foo()) 23 | assert.is(div.textContent.trim(), '1') 24 | 25 | const child = div.firstElementChild 26 | 27 | mount(div, bar()) 28 | assert.is(div.textContent.trim(), '2') 29 | assert.is(div.firstElementChild, child) 30 | 31 | assert.is(init, 1) 32 | 33 | function Counter (state, emit) { 34 | init++ 35 | let value = 0 36 | return function () { 37 | return html`${++value}` 38 | } 39 | } 40 | 41 | function foo () { 42 | return html`
    ${Component(Counter)}
    ` 43 | } 44 | 45 | function bar () { 46 | return html`
    ${Component(Counter)}
    ` 47 | } 48 | }) 49 | 50 | reuse('nested child component', function () { 51 | const div = document.createElement('div') 52 | let init = 0 53 | 54 | mount(div, foo()) 55 | 56 | const parent = div.firstElementChild 57 | const counter = parent.firstElementChild 58 | assert.is(counter.textContent, '1') 59 | 60 | mount(div, bar()) 61 | 62 | assert.is(div.firstElementChild, parent) 63 | assert.is(div.firstElementChild.firstElementChild, counter) 64 | assert.is(counter.textContent, '2') 65 | 66 | assert.is(init, 2) 67 | 68 | function Parent () { 69 | init++ 70 | return function () { 71 | return html`
    ${Component(Counter)}
    ` 72 | } 73 | } 74 | 75 | function Counter (state, emit) { 76 | init++ 77 | let value = 0 78 | return function () { 79 | return html`${++value}` 80 | } 81 | } 82 | 83 | function foo () { 84 | return html`
    ${Component(Parent)}
    ` 85 | } 86 | 87 | function bar () { 88 | return html`
    ${Component(Parent)}
    ` 89 | } 90 | }) 91 | 92 | reuse('not possible for unkeyed partial', function () { 93 | const div = document.createElement('div') 94 | let init = 0 95 | 96 | mount(div, foo()) 97 | 98 | const counter = div.firstElementChild.firstElementChild 99 | assert.is(counter.textContent, '1') 100 | 101 | mount(div, bar()) 102 | 103 | assert.is.not(div.firstElementChild.firstElementChild, counter) 104 | assert.is(div.firstElementChild.firstElementChild.textContent, '1') 105 | assert.is(init, 2) 106 | 107 | function Counter (state, emit) { 108 | init++ 109 | let value = 0 110 | return function () { 111 | return html`${++value}` 112 | } 113 | } 114 | 115 | function foo () { 116 | return html`
    ${html`
    ${Component(Counter)}
    `}
    ` 117 | } 118 | 119 | function bar () { 120 | return html`
    ${html`
    ${Component(Counter)}
    `}
    ` 121 | } 122 | }) 123 | 124 | api.run() 125 | reuse.run() 126 | -------------------------------------------------------------------------------- /test/browser/component/render.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, Component, render } from '../../../index.js' 4 | 5 | const element = suite('element') 6 | const children = suite('children') 7 | const fragment = suite('fragment') 8 | 9 | element('one-off', function () { 10 | const res = render(Component(() => html`

    Hello planet!

    `)) 11 | assert.is(res.outerHTML, '

    Hello planet!

    ') 12 | }) 13 | 14 | element('w/ lifecycle', function () { 15 | const res = render(Component(() => () => () => html`

    Hello planet!

    `)) 16 | assert.is(res.outerHTML, '

    Hello planet!

    ') 17 | }) 18 | 19 | children('return just child', function () { 20 | const Main = Component(function () { 21 | return Component(function () { 22 | return html`
    Hello world!
    ` 23 | }) 24 | }) 25 | assert.is(render(Main).outerHTML, '
    Hello world!
    ') 26 | }) 27 | 28 | children('nested component', function () { 29 | const Main = Component(function Main (state, emit) { 30 | return html` 31 | 32 | Hello ${Component(Child, { test: 'fest' })}! 33 | 34 | ` 35 | }) 36 | 37 | const res = html` 38 |
    39 | ${Main({ test: 'test' })} 40 |
    41 | ` 42 | assert.snapshot(dedent(render(res).outerHTML), dedent` 43 |
    44 | 45 | Hello world! 46 | 47 |
    48 | `) 49 | 50 | function Child (state, emit) { 51 | return function (props) { 52 | return 'world' 53 | } 54 | } 55 | }) 56 | 57 | children('array of components', function () { 58 | const children = new Array(3).fill(Child).map(Component) 59 | const Main = Component(function () { 60 | return function () { 61 | return html`
      ${children.map((Child, index) => Child(index + 1))}
    ` 62 | } 63 | }) 64 | 65 | assert.snapshot(render(Main).outerHTML, dedent` 66 |
    • 1
    • 2
    • 3
    67 | `) 68 | 69 | function Child () { 70 | return (num) => html`
  • ${num}
  • ` 71 | } 72 | }) 73 | 74 | fragment('can render fragment', function () { 75 | assert.snapshot(dedent(render(html` 76 |
      77 | ${Component(Main)} 78 |
    79 | `).outerHTML), dedent` 80 |
      81 |
    • 1
    • 82 |
    • 2
    • 83 |
    • 3
    • 84 |
    85 | `) 86 | 87 | function Main () { 88 | return function () { 89 | return html` 90 |
  • 1
  • 91 |
  • 2
  • 92 |
  • 3
  • 93 | ` 94 | } 95 | } 96 | }) 97 | 98 | element.run() 99 | children.run() 100 | fragment.run() 101 | 102 | function dedent (string) { 103 | if (Array.isArray(string)) string = string.join('') 104 | return string.replace(/\n\s+/g, '\n').trim() 105 | } 106 | -------------------------------------------------------------------------------- /test/browser/component/update.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, mount, render, use, Component } from '../../../index.js' 4 | 5 | const element = suite('element') 6 | const rerender = suite('rerender') 7 | const fragment = suite('fragment') 8 | 9 | element('does not update when shallow', function () { 10 | let name = 'planet' 11 | const el = document.createElement('h1') 12 | const Main = Component(() => html`

    Hello ${name}!

    `) 13 | 14 | mount(el, Main) 15 | assert.is(el.outerHTML, '

    Hello planet!

    ') 16 | 17 | name = 'world' 18 | mount(el, Main) 19 | assert.is(el.outerHTML, '

    Hello planet!

    ') 20 | }) 21 | 22 | element('does update if update function provided', function () { 23 | const el = document.createElement('h1') 24 | const Main = Component(() => (name) => html`

    Hello ${name}!

    `) 25 | 26 | mount(el, Main('planet')) 27 | assert.is(el.outerHTML, '

    Hello planet!

    ') 28 | const [hello, planet, exlamation] = el.childNodes 29 | 30 | mount(el, Main('world')) 31 | assert.is(el.outerHTML, '

    Hello world!

    ') 32 | assert.ok(hello.isSameNode(el.childNodes[0])) 33 | assert.ok(planet.isSameNode(el.childNodes[1])) 34 | assert.ok(exlamation.isSameNode(el.childNodes[2])) 35 | }) 36 | 37 | rerender('rerender on render event', async function () { 38 | let rerender 39 | let value = 'world' 40 | const res = render(html`
    ${Component(Main)}
    `) 41 | assert.is(res.outerHTML, '

    Hello world!

    ') 42 | 43 | value = 'planet' 44 | rerender() 45 | await new Promise(function (resolve) { 46 | window.requestAnimationFrame(function () { 47 | assert.is(res.outerHTML, '

    Hello planet!

    ') 48 | resolve() 49 | }) 50 | }) 51 | 52 | function Main (state, emit) { 53 | rerender = () => emit('render') 54 | return function onupdate () { 55 | return html`

    Hello ${value}!

    ` 56 | } 57 | } 58 | }) 59 | 60 | rerender('render event does not bubble', async function () { 61 | let rerender 62 | let outer = 'foo' 63 | let inner = 'bar' 64 | const res = render(html`
    ${Component(Parent)}
    `) 65 | assert.is(res.outerHTML, '
    foobar
    ') 66 | 67 | outer = 'bin' 68 | inner = 'baz' 69 | rerender() 70 | await new Promise(function (resolve) { 71 | window.requestAnimationFrame(function () { 72 | assert.is(res.outerHTML, '
    foobaz
    ') 73 | resolve() 74 | }) 75 | }) 76 | 77 | function Parent (state, emit) { 78 | use(function (state, emitter) { 79 | emitter.on('render', assert.unreachable) 80 | }) 81 | return function () { 82 | return html`${outer}${Component(Child)}` 83 | } 84 | } 85 | 86 | function Child (state, emit) { 87 | rerender = () => emit('render') 88 | return function onupdate () { 89 | return html`${inner}` 90 | } 91 | } 92 | }) 93 | 94 | rerender('update single text node', async function () { 95 | let rerender 96 | let value = 'world' 97 | const res = render(html`

    Hello ${Component(Main)}!

    `) 98 | assert.is(res.outerHTML, '

    Hello world!

    ') 99 | 100 | value = 'planet' 101 | rerender() 102 | await new Promise(function (resolve) { 103 | window.requestAnimationFrame(function () { 104 | assert.is(res.outerHTML, '

    Hello planet!

    ') 105 | resolve() 106 | }) 107 | }) 108 | 109 | function Main (state, emit) { 110 | rerender = () => emit('render') 111 | return function onupdate () { 112 | return html`${value}` 113 | } 114 | } 115 | }) 116 | 117 | fragment('can update fragment', function () { 118 | const ul = document.createElement('ul') 119 | mount(ul, html`
      ${Component(Main)}
    `) 120 | assert.snapshot(ul.outerHTML, '
    • 1
    • 2
    • 3
    ') 121 | 122 | function Child () { 123 | return function () { 124 | return html`
  • 2
  • ` 125 | } 126 | } 127 | 128 | function Main () { 129 | return function () { 130 | return html`
  • 1
  • ${Component(Child)}
  • 3
  • ` 131 | } 132 | } 133 | }) 134 | 135 | element.run() 136 | rerender.run() 137 | fragment.run() 138 | -------------------------------------------------------------------------------- /test/browser/html.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, raw, ref, Partial, render } from '../../index.js' 4 | 5 | const partial = suite('partial') 6 | const attributes = suite('attributes') 7 | const refs = suite('ref') 8 | const rawPartial = suite('raw') 9 | const children = suite('children') 10 | 11 | partial('returned from html', function () { 12 | const res = html`
    ` 13 | assert.instance(res, Partial) 14 | }) 15 | 16 | partial('can render element', function () { 17 | const res = render(html`
    Hello world!
    `) 18 | assert.instance(res, window.HTMLDivElement) 19 | assert.is(res.outerHTML, '
    Hello world!
    ') 20 | }) 21 | 22 | partial('can render fragment', function () { 23 | const res = render(html`Hello world!`) 24 | assert.instance(res, window.DocumentFragment) 25 | assert.is(res.childNodes.length, 3) 26 | assert.is(res.childElementCount, 2) 27 | const div = document.createElement('div') 28 | div.append(res) 29 | assert.is(div.innerHTML, 'Hello world!') 30 | }) 31 | 32 | partial('can render string', function () { 33 | const res = render(html`Hello world!`) 34 | assert.instance(res, window.Text) 35 | assert.is(res.nodeValue, 'Hello world!') 36 | }) 37 | 38 | partial('html strings are not parsed as html', function () { 39 | const res = render(html`
    ${''}
    `) 40 | assert.is(res.childNodes.length, 1) 41 | assert.is(res.firstChild.nodeName, '#text') 42 | assert.is(res.outerHTML, '
    <script src="evil.com/xss.js"></script>
    ') 43 | }) 44 | 45 | partial('can be comment', function () { 46 | assert.is(render(html``).nodeValue, 'comment') 47 | assert.is(render(html`
    `).outerHTML, '
    ') 48 | assert.is(render(html`
    `).outerHTML, '
    ') 49 | }) 50 | 51 | partial('trim whitespace wrapping single element nodes', function () { 52 | const res = render(html` 53 | 54 | Hello world! 55 | 56 | `) 57 | assert.instance(res, window.HTMLSpanElement) 58 | assert.snapshot(res.innerHTML, '\n Hello world!\n ') 59 | }) 60 | 61 | partial('trim whitespace wrapping fragments', function () { 62 | const res = render(html` 63 | Hello world! 64 | `) 65 | assert.instance(res, window.DocumentFragment) 66 | assert.is(res.childNodes.length, 3) 67 | assert.is(res.childElementCount, 2) 68 | const div = document.createElement('div') 69 | div.append(res) 70 | assert.snapshot(div.innerHTML, 'Hello world!') 71 | }) 72 | 73 | partial('preserve whitespace wrapping text nodes', function () { 74 | const res = render(html` Hello world! `) // eslint-disable-line no-tabs 75 | assert.instance(res, window.Text) 76 | assert.snapshot(res.nodeValue, ' Hello world! ') // eslint-disable-line no-tabs 77 | }) 78 | 79 | partial('preserve whitespace wrapping text nodes in fragments', function () { 80 | const res = render(html` Hello world! `) // eslint-disable-line no-tabs 81 | assert.instance(res, window.DocumentFragment) 82 | assert.is(res.childNodes.length, 2) 83 | assert.is(res.childElementCount, 1) 84 | const div = document.createElement('div') 85 | div.append(res) 86 | assert.snapshot(div.innerHTML, ' Hello world!') 87 | }) 88 | 89 | attributes('array values are space delimited', function () { 90 | const classes = ['foo', 'bar'] 91 | const res = render(html`
    Hello world!
    `) 92 | assert.equal(res.getAttribute('class'), classes.join(' ')) 93 | assert.is(res.outerHTML, '
    Hello world!
    ') 94 | }) 95 | 96 | attributes('can be spread', function () { 97 | const attrs = { class: 'test', id: 'test' } 98 | const data = ['data-foo', { 'data-bar': 'baz' }] 99 | const res = render(html`
    Hello world!
    `) 100 | assert.is(res.className, 'test') 101 | assert.is(res.id, 'test') 102 | assert.is(res.dataset.foo, '') 103 | assert.is(res.dataset.bar, 'baz') 104 | assert.is(res.outerHTML, '
    Hello world!
    ') 105 | }) 106 | 107 | attributes('bool props', function () { 108 | const res = render(html``) 109 | assert.is(res.required, false) 110 | assert.is(res.disabled, true) 111 | assert.is(res.dataset.hidden, 'false') 112 | assert.is(res.outerHTML, '') 113 | }) 114 | 115 | attributes('can include query string', function () { 116 | const res = render(html`Click me!`) 117 | assert.is(res.className, 'test') 118 | assert.is(res.href, 'http://example.com/?requried=false&string=string') 119 | assert.is(res.target, '_blank') 120 | assert.is(res.outerHTML, 'Click me!') 121 | }) 122 | 123 | refs('are assigned current', function () { 124 | const span = ref() 125 | const res = render(html`Hello world!`) 126 | assert.is(span.current, res) 127 | assert.is(res.outerHTML, 'Hello world!') 128 | }) 129 | 130 | refs('can be function', function () { 131 | let node 132 | const res = render(html`Hello world!`) 133 | assert.is(node, res) 134 | assert.is(res.outerHTML, 'Hello world!') 135 | 136 | function myRef (el) { 137 | node = el 138 | } 139 | }) 140 | 141 | rawPartial('is not escaped', function () { 142 | const res = render(html`
    ${raw('')}
    `) 143 | assert.is(res.childNodes.length, 1) 144 | assert.is(res.firstChild.nodeName, 'SCRIPT') 145 | assert.is(res.firstChild.innerText, 'alert("Hello planet!")') 146 | assert.is(res.outerHTML, '
    ') 147 | }) 148 | 149 | children('from nested partials', function () { 150 | const res = render(html`
    ${'Hello'} ${html`world!`}
    `) 151 | assert.is(res.childNodes.length, 3) 152 | assert.is(res.childElementCount, 1) 153 | assert.is(res.outerHTML, '
    Hello world!
    ') 154 | }) 155 | 156 | children('from arrays', function () { 157 | const children = [['Hello'], html` `, html`world!`] 158 | const res = render(html`
    ${children}
    `) 159 | assert.is(res.childNodes.length, 3) 160 | assert.is(res.childElementCount, 1) 161 | assert.is(res.outerHTML, '
    Hello world!
    ') 162 | }) 163 | 164 | children('can be plain string', function () { 165 | const res = render(html`${'Hello world!'}`) 166 | assert.instance(res, window.DocumentFragment) 167 | assert.is(res.childNodes.length, 1) 168 | assert.is(res.textContent, 'Hello world!') 169 | }) 170 | 171 | children('can be array of mixed content', function () { 172 | const res = render(html`${['Hello ', html`world!`]}`) 173 | assert.instance(res, window.DocumentFragment) 174 | assert.is(res.childNodes.length, 2) 175 | assert.is(res.childElementCount, 1) 176 | assert.is(res.textContent, 'Hello world!') 177 | }) 178 | 179 | partial.run() 180 | attributes.run() 181 | refs.run() 182 | rawPartial.run() 183 | children.run() 184 | -------------------------------------------------------------------------------- /test/browser/mount.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, mount } from '../../index.js' 4 | 5 | const test = suite('mount') 6 | 7 | test('shallow mount on DOM', function () { 8 | const div = document.createElement('div') 9 | mount(div, html`
    Hello world!
    `) 10 | assert.equal(div.className, 'test') 11 | assert.equal(div.textContent, 'Hello world!') 12 | assert.equal(div.outerHTML, '
    Hello world!
    ') 13 | }) 14 | 15 | test('append children', function () { 16 | const div = document.createElement('div') 17 | mount(div, html`
    Hello world!
    `) 18 | assert.equal(div.childElementCount, 2) 19 | assert.equal(div.textContent, 'Hello world!') 20 | assert.equal(div.outerHTML, '
    Hello world!
    ') 21 | }) 22 | 23 | test('mount children', function () { 24 | const div = document.createElement('div') 25 | div.innerHTML = 'Hello world!' 26 | const children = Array.from(div.children) 27 | mount(div, html`
    Hi planet!
    `) 28 | assert.equal(div.childElementCount, 2) 29 | assert.equal(div.textContent, 'Hi planet!') 30 | assert.ok(children.every((child, i) => child.isSameNode(div.children[i]))) 31 | assert.equal(div.outerHTML, '
    Hi planet!
    ') 32 | }) 33 | 34 | test('mount children out of order', function () { 35 | const div = document.createElement('div') 36 | div.innerHTML = 'one twothree' 37 | const [one, space, two, three] = div.childNodes 38 | mount(div, html`
    onetwo three
    `) 39 | assert.is(div.childNodes[0], two) 40 | assert.is(div.childNodes[1], three) 41 | assert.is(div.childNodes[2], space) 42 | assert.is(div.childNodes[3], one) 43 | assert.is(div.innerHTML, 'onetwo three') 44 | }) 45 | 46 | test('mount fragment', function () { 47 | const div = document.createElement('div') 48 | mount(div, html`Hello world!`) 49 | assert.is(div.outerHTML, '
    Hello world!
    ') 50 | }) 51 | 52 | test('mount on selector', function () { 53 | const id = `_${Math.random().toString(36).substring(2)}` 54 | const div = document.createElement('div') 55 | div.id = id 56 | document.body.appendChild(div) 57 | mount(`#${id}`, html`
    Hello world!
    `) 58 | assert.is(div.outerHTML, `
    Hello world!
    `) 59 | div.remove() 60 | }) 61 | 62 | test.run() 63 | -------------------------------------------------------------------------------- /test/browser/svg.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { svg, html, render } from '../../index.js' 4 | 5 | const rendering = suite('rendering') 6 | 7 | rendering('with root svg tag', function () { 8 | const res = render(svg` 9 | 10 | 11 | 12 | `) 13 | assert.instance(res, window.SVGElement) 14 | assert.instance(res.firstElementChild, window.SVGElement) 15 | }) 16 | 17 | rendering('stand alone svg child node', function () { 18 | const res = render(svg``) 19 | assert.instance(res, window.SVGElement) 20 | }) 21 | 22 | rendering('as child of html tag', function () { 23 | const res = render(html` 24 |
    25 | ${svg` 26 | 27 | 28 | 29 | `} 30 |
    31 | `) 32 | assert.instance(res.firstElementChild, window.SVGElement) 33 | assert.instance(res.firstElementChild.firstElementChild, window.SVGElement) 34 | }) 35 | 36 | rendering.run() 37 | -------------------------------------------------------------------------------- /test/browser/update.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, mount, render } from '../../index.js' 4 | 5 | const reuse = suite('reuse') 6 | const types = suite('types') 7 | const order = suite('order') 8 | const fragments = suite('fragments') 9 | 10 | reuse('children in same place', function () { 11 | const div = document.createElement('div') 12 | const header = html`
    Hi
    ` 13 | const footer = html`
    Goodbye
    ` 14 | 15 | mount(div, foo()) 16 | assert.is(div.childElementCount, 3) 17 | assert.is(div.textContent.replace(/\s+/g, ''), 'HiWelcomeGoodbye') 18 | 19 | const [first,, third] = div.childNodes 20 | const firstFirst = first.firstElementChild 21 | 22 | mount(div, bar()) 23 | assert.is(div.childElementCount, 3) 24 | assert.is(div.textContent.replace(/\s+/g, ''), 'HiYoGoodbye') 25 | assert.is(firstFirst, div.childNodes[0].firstElementChild) 26 | assert.is(third, div.childNodes[2]) 27 | 28 | function foo () { 29 | return html` 30 |
    31 | ${html`
    ${header}
    `} 32 |
    Welcome
    33 | ${footer} 34 |
    35 | ` 36 | } 37 | 38 | function bar () { 39 | return html` 40 |
    41 | ${html`
    ${header}
    `} 42 |
    Yo
    43 | ${footer} 44 |
    45 | ` 46 | } 47 | }) 48 | 49 | types('can be nested array', function () { 50 | const ul = render(html` 51 |
      ${[ 52 | [html`
    • 1
    • `], 53 | html`
    • 2
    • `, 54 | [html`
    • 3
    • `] 55 | ]}
    56 | `) 57 | assert.is(ul.childElementCount, 3) 58 | assert.is(ul.textContent, '123') 59 | }) 60 | 61 | types('can update from partial to array', function () { 62 | const ul = document.createElement('ul') 63 | 64 | mount(ul, main(child(1))) 65 | assert.is(ul.childElementCount, 1) 66 | assert.is(ul.textContent, '1') 67 | 68 | const firstChild = ul.firstElementChild 69 | 70 | mount(ul, main([1, 2, 3].map(child))) 71 | assert.is(ul.childElementCount, 3) 72 | assert.is(ul.textContent, '123') 73 | assert.is(ul.firstElementChild, firstChild) 74 | 75 | function child (value) { 76 | return html`
  • ${value}
  • ` 77 | } 78 | 79 | function main (children) { 80 | return html`
      ${children}
    ` 81 | } 82 | }) 83 | 84 | types('can update from array to partial', function () { 85 | const ul = document.createElement('ul') 86 | 87 | mount(ul, main([1, 2, 3].map(child))) 88 | assert.is(ul.childElementCount, 3) 89 | assert.is(ul.textContent, '123') 90 | 91 | const firstChild = ul.firstElementChild 92 | 93 | mount(ul, main(child(1))) 94 | assert.is(ul.childElementCount, 1) 95 | assert.is(ul.textContent, '1') 96 | assert.is(ul.firstElementChild, firstChild) 97 | 98 | function child (value) { 99 | return html`
  • ${value}
  • ` 100 | } 101 | 102 | function main (children) { 103 | return html`
      ${children}
    ` 104 | } 105 | }) 106 | 107 | order('is rearrenged for array', function () { 108 | const ul = document.createElement('ul') 109 | const children = [ 110 | () => html`
  • 1
  • `, 111 | () => html`
  • 2
  • `, 112 | () => html`
  • 3
  • ` 113 | ] 114 | mount(ul, main()) 115 | const [one, two, three] = ul.childNodes 116 | assert.is(ul.innerText, '123') 117 | children.reverse() 118 | mount(ul, main()) 119 | assert.is(ul.childNodes[0], three) 120 | assert.is(ul.childNodes[1], two) 121 | assert.is(ul.childNodes[2], one) 122 | assert.is(ul.innerText, '321') 123 | 124 | function main () { 125 | return html`
      ${children.map((fn) => fn())}
    ` 126 | } 127 | }) 128 | 129 | order('has no effect outside array', function () { 130 | const ul = document.createElement('ul') 131 | const children = [ 132 | () => html`
  • 1
  • `, 133 | () => html`
  • 2
  • `, 134 | () => html`
  • 3
  • ` 135 | ] 136 | mount(ul, main(children)) 137 | const [one, two, three] = ul.childNodes 138 | assert.is(ul.innerText, '123') 139 | children.reverse() 140 | mount(ul, main(children.slice(1), children[0])) 141 | assert.is(ul.childNodes[0], two) 142 | assert.is(ul.childNodes[1], one) 143 | assert.is.not(ul.childNodes[3], three) 144 | assert.is(ul.innerText, '213') 145 | 146 | function main (children, extra = () => null) { 147 | return html`
      ${children.map((fn) => fn())}${extra()}
    ` 148 | } 149 | }) 150 | 151 | fragments('do not leak', function () { 152 | const ul = document.createElement('ul') 153 | 154 | mount(ul, main(html`
  • 1
  • `, html`
  • 2
  • 3
  • `)) 155 | assert.is(ul.innerText, '123') 156 | 157 | mount(ul, main(html`
  • 1
  • `, html`
  • 2
  • 3
  • `)) 158 | assert.is(ul.innerText, '123') 159 | 160 | mount(ul, main(null, html`
  • 2
  • 3
  • `)) 161 | assert.is(ul.innerText, '23') 162 | 163 | mount(ul, main(html`
  • 1
  • `, html`
  • 2
  • 3
  • `)) 164 | assert.is(ul.innerText, '123') 165 | 166 | function main (a, b) { 167 | return html`
      ${a}${b}
    ` 168 | } 169 | }) 170 | 171 | reuse.run() 172 | types.run() 173 | order.run() 174 | fragments.run() 175 | -------------------------------------------------------------------------------- /test/server/api.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import { Readable } from 'stream' 3 | import * as assert from 'uvu/assert' 4 | import { html, Partial, mount, render } from '../../server.js' 5 | 6 | const partial = suite('partial') 7 | const mounting = suite('mount') 8 | 9 | partial('returned by html', function () { 10 | const partial = html`
    Hello world!
    ` 11 | assert.instance(partial, Partial) 12 | }) 13 | 14 | partial('can render to promise', async function () { 15 | const promise = render(html`
    Hello world!
    `) 16 | assert.instance(promise, Promise, 'is promise') 17 | assert.is(await promise, '
    Hello world!
    ') 18 | }) 19 | 20 | partial('is async iterable', async function () { 21 | const partial = html`
    Hello world!
    ` 22 | assert.type(partial[Symbol.asyncIterator], 'function') 23 | let res = '' 24 | for await (const chunk of partial) res += chunk 25 | assert.is(res, '
    Hello world!
    ') 26 | }) 27 | 28 | partial('can render to stream', async function () { 29 | const stream = Readable.from(html`
    Hello world!
    `) 30 | const string = await new Promise(function (resolve, reject) { 31 | let string = '' 32 | stream.on('data', function (chunk) { 33 | string += chunk 34 | }) 35 | stream.on('end', function () { 36 | resolve(string) 37 | }) 38 | stream.on('end', reject) 39 | }) 40 | assert.is(string, '
    Hello world!
    ') 41 | }) 42 | 43 | mounting('decorates partial', async function () { 44 | const initialState = {} 45 | const res = mount('body', html`Hello planet!`, initialState) 46 | assert.instance(res, Partial) 47 | assert.is(res.state, initialState) 48 | assert.is(res.selector, 'body') 49 | assert.is(await render(res), 'Hello planet!') 50 | }) 51 | 52 | partial.run() 53 | mounting.run() 54 | -------------------------------------------------------------------------------- /test/server/component.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import { Readable } from 'stream' 3 | import * as assert from 'uvu/assert' 4 | import { html, Partial, Component, use, mount, render } from '../../server.js' 5 | 6 | const api = suite('api') 7 | const lifecycle = suite('lifecycle') 8 | const rendering = suite('rendering') 9 | const state = suite('state') 10 | const stores = suite('stores') 11 | 12 | api('extends partial', function () { 13 | const MyComponent = Component(Function.prototype) 14 | assert.type(MyComponent, 'function') 15 | assert.instance(MyComponent(), Partial) 16 | }) 17 | 18 | api('can render to promise', async function () { 19 | const MyComponent = Component(() => html`
    Hello world!
    `) 20 | const promise = render(MyComponent) 21 | assert.instance(promise, Promise, 'is promise') 22 | assert.is(await promise, '
    Hello world!
    ') 23 | }) 24 | 25 | api('is async iterable', async function () { 26 | const MyComponent = Component(() => html`
    Hello world!
    `) 27 | assert.type(MyComponent()[Symbol.asyncIterator], 'function') 28 | let res = '' 29 | for await (const chunk of MyComponent()) res += chunk 30 | assert.is(res, '
    Hello world!
    ') 31 | }) 32 | 33 | api('can render to stream', async function () { 34 | const MyComponent = Component(() => html`
    Hello world!
    `) 35 | const stream = Readable.from(MyComponent()) 36 | const string = await new Promise(function (resolve, reject) { 37 | let string = '' 38 | stream.on('data', function (chunk) { 39 | string += chunk 40 | }) 41 | stream.on('end', function () { 42 | resolve(string) 43 | }) 44 | stream.on('end', reject) 45 | }) 46 | assert.is(string, '
    Hello world!
    ') 47 | }) 48 | 49 | api('can be declared with arguments', async function () { 50 | const MyComponent = Component(Main, 'world') 51 | await render(MyComponent) 52 | function Main () { 53 | return (name) => assert.is(name, 'world') 54 | } 55 | }) 56 | 57 | api('can be called with arguments', async function () { 58 | const MyComponent = Component(Main) 59 | await render(MyComponent('world')) 60 | function Main () { 61 | return (name) => assert.is(name, 'world') 62 | } 63 | }) 64 | 65 | api('calling arguments override declaration arguments', async function () { 66 | const MyComponent = Component(Main, 'world') 67 | await render(MyComponent('planet')) 68 | function Main () { 69 | return (name) => assert.is(name, 'planet') 70 | } 71 | }) 72 | 73 | api('can mount', async function () { 74 | const res = mount('body', Component(Main)) 75 | assert.is(res.selector, 'body') 76 | assert.is(await render(res), 'Hello world!') 77 | 78 | function Main (state, emit) { 79 | return html`Hello world!` 80 | } 81 | }) 82 | 83 | lifecycle('stops at yield', async function () { 84 | const MyComponent = Component(Main) 85 | const res = html` 86 |
    87 | ${MyComponent({ test: 'test' })} 88 |
    89 | ` 90 | assert.snapshot(dedent(await render(res)), dedent` 91 |
    92 | 93 | Hello world! 94 | 95 |
    96 | `) 97 | 98 | function * Main (state, emit) { 99 | assert.type(state, 'object') 100 | assert.type(emit, 'function') 101 | 102 | yield function * (props) { 103 | assert.is(props.test, 'test') 104 | 105 | yield function * () { 106 | yield html` 107 | 108 | Hello world! 109 | 110 | ` 111 | assert.unreachable() 112 | } 113 | assert.unreachable() 114 | } 115 | assert.unreachable() 116 | } 117 | }) 118 | 119 | lifecycle('await yielded promises', async function () { 120 | const res = html`
    ${Component(Main)}
    ` 121 | assert.is(await render(res), '
    Hello world!
    ') 122 | 123 | function Main (state, emit) { 124 | return function * (props) { 125 | const value = yield Promise.resolve('world') 126 | assert.is(value, 'world') 127 | return `Hello ${value}!` 128 | } 129 | } 130 | }) 131 | 132 | rendering('return just child', async function () { 133 | const Main = Component(function () { 134 | return Component(function () { 135 | return html`
    Hello world!
    ` 136 | }) 137 | }) 138 | assert.is(await render(Main), '
    Hello world!
    ') 139 | }) 140 | 141 | rendering('nested component', async function () { 142 | const Child = Component(function Child (state, emit) { 143 | assert.type(state, 'object') 144 | assert.type(emit, 'function') 145 | return function (props) { 146 | assert.is(props.test, 'fest') 147 | return 'world' 148 | } 149 | }) 150 | const Main = Component(function Main (state, emit) { 151 | return html` 152 | 153 | Hello ${Child({ test: 'fest' })}! 154 | 155 | ` 156 | }) 157 | 158 | const res = html` 159 |
    160 | ${Main({ test: 'test' })} 161 |
    162 | ` 163 | assert.snapshot(dedent(await render(res)), dedent` 164 |
    165 | 166 | Hello world! 167 | 168 |
    169 | `) 170 | }) 171 | 172 | state('is mutable by top level component', async function () { 173 | const initialState = {} 174 | const Mutator = Component(function (state, emit) { 175 | assert.is(state, initialState) 176 | state.test = 'test' 177 | }) 178 | await render(Mutator, initialState) 179 | assert.equal(initialState, { test: 'test' }) 180 | }) 181 | 182 | state('is not mutable by nested component', async function () { 183 | const initialState = {} 184 | const Mutator = Component(function (state, emit) { 185 | assert.is.not(state, initialState) 186 | state.test = 'test' 187 | }) 188 | await render(html`
    ${Mutator}
    `, initialState) 189 | assert.equal(initialState, {}) 190 | }) 191 | 192 | state('is inherited from parent', async function () { 193 | const initialState = { test: 'test' } 194 | const MainComponent = Component(Main) 195 | assert.is(await render(MainComponent, initialState), '
    Hello world!
    ') 196 | assert.is(initialState.child, undefined) 197 | 198 | function Main (state, emit) { 199 | return html`
    Hello ${[ChildA, ChildB].map(Component)}
    ` 200 | } 201 | 202 | function ChildA (state, emit) { 203 | state.child = 'a' 204 | assert.is.not(state, initialState, 'is not parent state') 205 | assert.is(Object.getPrototypeOf(state), initialState, 'child innherit from parent') 206 | assert.is(state.child, 'a', 'can modify local state') 207 | assert.is(state.test, 'test', 'can read from parent state') 208 | return 'world' 209 | } 210 | 211 | function ChildB (state, emit) { 212 | assert.is(state.child, undefined) 213 | assert.is(state.test, 'test') 214 | return '!' 215 | } 216 | }) 217 | 218 | stores('arguments and return', async function () { 219 | const MainComponent = Component(Main) 220 | await render(MainComponent) 221 | 222 | function Main (state, emit) { 223 | const res = use(function (_state, emitter) { 224 | assert.is(state, _state, 'store got component state') 225 | assert.type(emitter, 'object') 226 | return 'test' 227 | }) 228 | assert.is(res, 'test') 229 | } 230 | }) 231 | 232 | stores('emitter', async function () { 233 | let queue = 2 234 | const MainComponent = Component(Main) 235 | await render(MainComponent) 236 | assert.is(queue, 0, 'all events triggered') 237 | 238 | function Main (state, emit) { 239 | use(function (state, emitter) { 240 | emitter.on('*', function (event, value) { 241 | assert.is(event, 'test', 'got event name') 242 | assert.is(value, 'test', 'got arguments') 243 | queue-- 244 | }) 245 | 246 | emitter.on('test', assert.unreachable) 247 | emitter.removeListener('test', assert.unreachable) 248 | 249 | emitter.on('test', function (value) { 250 | assert.is(value, 'test', 'got arguments') 251 | queue-- 252 | }) 253 | }) 254 | 255 | return function () { 256 | emit('test', 'test') 257 | } 258 | } 259 | }) 260 | 261 | stores('events bubble', async function () { 262 | let queue = 2 263 | const MainComponent = Component(Main) 264 | await render(MainComponent) 265 | assert.is(queue, 0, 'all events triggered') 266 | 267 | function Main (state, emit) { 268 | use(function (state, emitter) { 269 | emitter.on('child', function () { 270 | queue-- 271 | }) 272 | }) 273 | 274 | return function (props) { 275 | return Component(Name) 276 | } 277 | } 278 | 279 | function Name (state, emit) { 280 | use(function (state, emitter) { 281 | emitter.on('child', function () { 282 | queue-- 283 | }) 284 | }) 285 | emit('child', 'child') 286 | } 287 | }) 288 | 289 | api.run() 290 | lifecycle.run() 291 | rendering.run() 292 | state.run() 293 | stores.run() 294 | 295 | function dedent (string) { 296 | if (Array.isArray(string)) string = string.join('') 297 | return string.replace(/\n\s+/g, '\n').trim() 298 | } 299 | -------------------------------------------------------------------------------- /test/server/html.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { html, raw, ref, render } from '../../server.js' 4 | 5 | const children = suite('children') 6 | const attributes = suite('attributes') 7 | const refs = suite('ref') 8 | const rawPartial = suite('raw') 9 | 10 | children('text is escaped', async function () { 11 | const partial = html`
    ${''}
    ` 12 | assert.is(await render(partial), '
    <script src="evil.com/xss.js"></script>
    ') 13 | }) 14 | 15 | children('can be comment', async function () { 16 | assert.is(await render(html``), '') 17 | assert.is(await render(html`
    `), '
    ') 18 | assert.is(await render(html`
    `), '
    ') 19 | }) 20 | 21 | children('from nested partials', async function () { 22 | const partial = html`
    ${'Hello'} ${html`world!`}
    ` 23 | assert.is(await render(partial), '
    Hello world!
    ') 24 | }) 25 | 26 | children('from arrays', async function () { 27 | const partial = html`
    ${['Hello', html` `, html`world!`]}
    ` 28 | assert.is(await render(partial), '
    Hello world!
    ') 29 | }) 30 | 31 | attributes('can be async', async function () { 32 | const partial = html`
    Hello world!
    ` 33 | assert.is(await render(partial), '
    Hello world!
    ') 34 | }) 35 | 36 | attributes('from array are space delimited', async function () { 37 | const classes = ['foo', Promise.resolve('bar')] 38 | const partial = html`
    Hello world!
    ` 39 | assert.is(await render(partial), '
    Hello world!
    ') 40 | }) 41 | 42 | attributes('can be spread', async function () { 43 | const attrs = { class: 'test', id: Promise.resolve('test') } 44 | const data = ['data-foo', Promise.resolve('data-bar'), { 'data-bin': Promise.resolve('baz') }] 45 | const partial = html`
    Hello world!
    ` 46 | assert.is(await render(partial), '
    Hello world!
    ') 47 | }) 48 | 49 | attributes('bool props', async function () { 50 | const partial = html`` 51 | assert.is(await render(partial), '') 52 | }) 53 | 54 | attributes('can include query string', async function () { 55 | const partial = html`Click me!` 56 | assert.is(await render(partial), 'Click me!') 57 | }) 58 | 59 | refs('are stripped', async function () { 60 | const span = ref() 61 | const partial = html`Hello world!` 62 | assert.is(await render(partial), 'Hello world!') 63 | }) 64 | 65 | rawPartial('is not escaped', async function () { 66 | const partial = html`
    ${raw('')}
    ` 67 | assert.is(await render(partial), '
    ') 68 | }) 69 | 70 | children.run() 71 | attributes.run() 72 | refs.run() 73 | rawPartial.run() 74 | -------------------------------------------------------------------------------- /test/server/svg.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { svg, html, render } from '../../server.js' 4 | 5 | const rendering = suite('rendering') 6 | 7 | rendering('with root svg tag', async function () { 8 | const res = svg` 9 | 10 | 11 | 12 | ` 13 | assert.snapshot(dedent` 14 | 15 | 16 | 17 | `, dedent(await render(res))) 18 | }) 19 | 20 | rendering('as child of html tag', async function () { 21 | const res = html` 22 |
    23 | ${svg` 24 | 25 | 26 | 27 | `} 28 |
    29 | ` 30 | assert.snapshot(dedent` 31 |
    32 | 33 | 34 | 35 |
    36 | `, dedent(await render(res))) 37 | }) 38 | 39 | rendering.run() 40 | 41 | function dedent (string) { 42 | if (Array.isArray(string)) string = string.join('') 43 | return string.replace(/\n\s+/g, '\n').trim() 44 | } 45 | --------------------------------------------------------------------------------