├── .dockerignore ├── .gitignore ├── COPYING ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── README.md ├── app ├── App.haml ├── Button.haml ├── Counter.haml ├── Details.haml ├── Files.haml ├── Files │ ├── Directory.haml │ └── File.haml ├── GC.haml ├── Haml.haml ├── Life.haml ├── List.haml ├── List2.haml ├── Shoelace.haml ├── StartPage.haml ├── Styles.haml └── words.txt ├── bin └── transform ├── config.ru ├── demo ├── favicon.ico └── index.html ├── fly.toml ├── lib ├── vdom.rb └── vdom │ ├── assets.rb │ ├── component.rb │ ├── css_units.rb │ ├── custom_element.rb │ ├── descriptor.rb │ ├── haml_transform.rb │ ├── mutation_visitor.rb │ ├── nodes.rb │ ├── patches.rb │ ├── s.demo.rb │ ├── s.rb │ ├── s.test.rb │ ├── server.rb │ ├── style_sheet.rb │ ├── text_diff.rb │ ├── transform.rb │ └── xml_utils.rb └── public ├── favicon.png ├── index.html └── rdom.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | **/vendor 3 | 4 | vendor 5 | fly.toml 6 | Dockerfile 7 | .git 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2.1-alpine3.17 as base 2 | 3 | ARG BUNDLER_VERSION=2.4.1 4 | ARG BUNDLE_WITHOUT=development:test 5 | ARG BUNDLE_PATH=vendor/bundle 6 | ENV BUNDLE_PATH ${BUNDLE_PATH} 7 | ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT} 8 | RUN gem install -N bundler -v ${BUNDLER_VERSION} 9 | RUN apk update && apk add --no-cache curl bash jemalloc gcompat 10 | SHELL ["/bin/bash", "-c"] 11 | WORKDIR /app 12 | 13 | FROM base AS install 14 | RUN apk update && apk add --no-cache build-base gzip libwebp-tools imagemagick brotli 15 | COPY Gemfile* . 16 | RUN bundle install 17 | COPY . . 18 | 19 | FROM base AS final 20 | COPY --from=install /app /app 21 | ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2 22 | CMD ["ruby", "config.ru"] 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "async", "~> 2.3" 6 | gem "async-http", "~> 0.60.1" 7 | gem "async-io", "~> 1.34" 8 | 9 | gem "diff-lcs", "~> 1.5" 10 | 11 | gem "localhost", "~> 1.1" 12 | 13 | gem "pry", "~> 0.14.1" 14 | 15 | gem "syntax_tree", "~> 6.0" 16 | gem "syntax_tree-xml", "~> 0.1.0" 17 | 18 | gem "minitest", "~> 5.18" 19 | 20 | gem "syntax_tree-haml", "~> 3.0" 21 | 22 | gem "minitest-hooks", "~> 1.5" 23 | 24 | gem "mime-types", "~> 3.4" 25 | 26 | gem "brotli", "~> 0.4.0" 27 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | async (2.3.1) 5 | console (~> 1.10) 6 | io-event (~> 1.1) 7 | timers (~> 4.1) 8 | async-http (0.60.1) 9 | async (>= 1.25) 10 | async-io (>= 1.28) 11 | async-pool (>= 0.2) 12 | protocol-http (~> 0.24.0) 13 | protocol-http1 (~> 0.15.0) 14 | protocol-http2 (~> 0.15.0) 15 | traces (>= 0.8.0) 16 | async-io (1.34.3) 17 | async 18 | async-pool (0.3.12) 19 | async (>= 1.25) 20 | brotli (0.4.0) 21 | coderay (1.1.3) 22 | console (1.16.2) 23 | fiber-local 24 | diff-lcs (1.5.0) 25 | fiber-local (1.0.0) 26 | haml (6.1.1) 27 | temple (>= 0.8.2) 28 | thor 29 | tilt 30 | io-event (1.1.4) 31 | localhost (1.1.10) 32 | method_source (1.0.0) 33 | mime-types (3.4.1) 34 | mime-types-data (~> 3.2015) 35 | mime-types-data (3.2023.0218.1) 36 | minitest (5.18.0) 37 | minitest-hooks (1.5.0) 38 | minitest (> 5.3) 39 | prettier_print (1.2.1) 40 | protocol-hpack (1.4.2) 41 | protocol-http (0.24.1) 42 | protocol-http1 (0.15.0) 43 | protocol-http (~> 0.22) 44 | protocol-http2 (0.15.1) 45 | protocol-hpack (~> 1.4) 46 | protocol-http (~> 0.18) 47 | pry (0.14.1) 48 | coderay (~> 1.1) 49 | method_source (~> 1.0) 50 | syntax_tree (6.0.2) 51 | prettier_print (>= 1.2.0) 52 | syntax_tree-haml (3.0.0) 53 | haml (>= 5.2, != 6.0.0) 54 | prettier_print (>= 1.0.0) 55 | syntax_tree (>= 5.0.1) 56 | syntax_tree-xml (0.1.0) 57 | prettier_print 58 | syntax_tree (>= 2.0.1) 59 | temple (0.10.0) 60 | thor (1.2.1) 61 | tilt (2.1.0) 62 | timers (4.3.5) 63 | traces (0.8.0) 64 | 65 | PLATFORMS 66 | x86_64-darwin-20 67 | x86_64-linux 68 | 69 | DEPENDENCIES 70 | async (~> 2.3) 71 | async-http (~> 0.60.1) 72 | async-io (~> 1.34) 73 | brotli (~> 0.4.0) 74 | diff-lcs (~> 1.5) 75 | localhost (~> 1.1) 76 | mime-types (~> 3.4) 77 | minitest (~> 5.18) 78 | minitest-hooks (~> 1.5) 79 | pry (~> 0.14.1) 80 | syntax_tree (~> 6.0) 81 | syntax_tree-haml (~> 3.0) 82 | syntax_tree-xml (~> 0.1.0) 83 | 84 | BUNDLED WITH 85 | 2.4.1 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rdom 2 | 3 | Reactive DOM updates with Ruby. 4 | 5 | 🔥 live demo at [rdom.fly.dev](https://rdom.fly.dev/) 6 | 7 | 🚀 embedding demo at [rdom.netlify.app](https://rdom.netlify.app/) 8 | 9 | ## Description 10 | 11 | This is a basic experiment with a server side VDOM in Ruby. 12 | For a more complete implementation, see 13 | [Mayu Live](https://github.com/mayu-live/framework). 14 | I had some ideas that I felt like I had to explore, 15 | and this is the result. 16 | 17 | ## Getting started 18 | 19 | Make sure you have 20 | [Ruby 3.2](https://www.ruby-lang.org/en/downloads/) and 21 | [Bundler](https://bundler.io/), 22 | then run: 23 | 24 | bundle install 25 | 26 | To start the server: 27 | 28 | ruby config.ru 29 | 30 | ## Server 31 | 32 | This thing comes with an HTTP/2 server. 33 | Start it with `ruby config.ru`. 34 | 35 | By default it binds to `https://localhost:8080`, 36 | but it can be changed by setting the environment variable 37 | `RDOM_BIND` like this `RDOM_BIND="https://[::]" ruby config.ru`. 38 | 39 | ## Embedding 40 | 41 | These are the only lines of HTML you need to mount an app. 42 | 43 | ```html 44 | 45 | 46 | ``` 47 | 48 | ## Transforms 49 | 50 | You can use `bin/transform` to see the transformed output of a Haml-file. 51 | 52 | Example: 53 | 54 | bin/transform app/List.haml 55 | 56 | ## Features and limitations 57 | 58 | ### Reactive rendering 59 | 60 | This repository contains a reactive signals library inspired 61 | by SolidJS, Preact Signals and Reactively. 62 | 63 | ### Only streaming 64 | 65 | Apps made with this can only be streamed, the server will never 66 | attempt to construct the HTML for the initial request. 67 | If you need to serve HTML in the initial request, have a look at 68 | [Mayu Live](https://github.com/mayu-live/framework). 69 | 70 | ### No resuming 71 | 72 | If the connection drops, all state is lost. 73 | For an attempt at something more reliable, check out 74 | [Mayu Live](https://github.com/mayu-live/framework). 75 | 76 | ### Custom elements 77 | 78 | All static DOM trees are extracted into custom elements, so if you write: 79 | 80 | ```haml 81 | - items = %w[foo bar baz] 82 | %ul 83 | = items.map do |item| 84 | %li= item 85 | ``` 86 | 87 | Then this code will be generated: 88 | 89 | ```ruby 90 | # frozen_string_literal: true 91 | class self::Component < VDOM::Component::Base 92 | RDOM_Partials = [ 93 | VDOM::CustomElement[ 94 | :"rdom-elem-app꞉꞉my-component.haml-0", 95 | '
' 96 | ], 97 | VDOM::CustomElement[ 98 | :"rdom-elem-app꞉꞉my-component.haml-1", 99 | '
  • ' 100 | ] 101 | ] 102 | def render 103 | items = %w[foo bar baz] 104 | H[ 105 | RDOM_Partials[0], 106 | slots: { 107 | slot0: items.map { |item| H[RDOM_Partials[1], slots: { slot0: item }] } 108 | } 109 | ] 110 | end 111 | end 112 | ``` 113 | 114 | The browser will give you: 115 | 116 | ```html 117 | 118 | #shadow-dom 119 |
    120 |
  • 121 | 122 | ↴ 123 | ↴ 124 | ↴ 125 | 126 |
  • 127 | 128 | #shadow-dom 129 |
  • 130 | 131 | <#text> ↴ 132 | 133 |
  • 134 | foo 135 |
    136 | 137 | #shadow-dom 138 |
  • 139 | 140 | <#text> ↴ 141 | 142 |
  • 143 | bar 144 |
    145 | 146 | #shadow-dom 147 |
  • 148 | 149 | <#text> ↴ 150 | 151 |
  • 152 | baz 153 |
    154 |
    155 | ``` 156 | 157 | Each slot inside the shadow DOM will have it's 158 | nodes assigned whenever children are updated. 159 | 160 | This is good for several reasons: 161 | 162 | * Markup is only transferred to the browser once and can be reused. 163 | * Diffing becomes easier because we don't have care about order. 164 | [HTMLSlotElement.assign()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assign) 165 | updates the order of children in 1 call. 166 | Most VDOM libraries use the use the famous 2-way-diffing algorithm, 167 | which is difficult to get right. 168 | 169 | Each custom element has `:host { display: contents; }` to avoid 170 | interference with flex and grid. 171 | -------------------------------------------------------------------------------- /app/App.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | StartPage = import("StartPage.haml") 3 | Shoelace = import("Shoelace.haml") 4 | Counter = import("Counter.haml") 5 | Haml = import("Haml.haml") 6 | List = import("List.haml") 7 | List2 = import("List2.haml") 8 | Styles = import("Styles.haml") 9 | GC = import("GC.haml") 10 | Files = import("Files.haml") 11 | Life = import("Life.haml") 12 | 13 | PAGES = [ 14 | StartPage, 15 | List, 16 | List2, 17 | Haml, 18 | Styles, 19 | GC, 20 | Files, 21 | Life, 22 | Shoelace, 23 | ] 24 | 25 | def initialize(**) 26 | @page = PAGES.first 27 | end 28 | 29 | :ruby 30 | emit!(:startViewTransition) 31 | %section 32 | %header 33 | %h1 34 | My webpage 35 | %Counter(initial-count=2) 36 | %nav 37 | %menu 38 | = PAGES.map do |component| 39 | - aria_current = @page == component ? "page" : false 40 | %li 41 | %button{aria_current:, onclick: -> { @page = component }} 42 | = component.title 43 | %main 44 | = H[@page] 45 | %hr 46 | %footer 47 | %p 48 | %a(href="https://github.com/aalin/rdom" target="_blank") 49 | github.com/aalin/rdom 50 | 51 | :css 52 | menu { 53 | display: flex; 54 | gap: 1em; 55 | flex-wrap: wrap; 56 | list-style-type: none; 57 | padding: 1em; 58 | background: var(--menu-bg); 59 | border-radius: 2px; 60 | } 61 | 62 | header { 63 | display: flex; 64 | align-items: center; 65 | justify-content: space-between; 66 | } 67 | button { 68 | border: 0; 69 | border-radius: 3px; 70 | cursor: pointer; 71 | font: inherit; 72 | padding: 0; 73 | margin: 0; 74 | background: transparent; 75 | } 76 | button:hover { 77 | text-decoration: underline; 78 | } 79 | button[aria-current]:not([aria-current=false]) { 80 | font-weight: bold; 81 | } 82 | -------------------------------------------------------------------------------- /app/Button.haml: -------------------------------------------------------------------------------- 1 | %button 2 | %slot 3 | -------------------------------------------------------------------------------- /app/Counter.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | def initialize(**) 3 | @count = signal(0) 4 | end 5 | 6 | def mount 7 | loop do 8 | sleep(1) 9 | @count.value += 1 10 | end 11 | end 12 | 13 | %span= @count 14 | -------------------------------------------------------------------------------- /app/Details.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | def initialize(open: false, **) = 3 | @open = open 4 | 5 | def handle_toggle(**) = 6 | @open = !@open 7 | 8 | %details(open=@open){ontoggle: method(:handle_toggle)} 9 | %summary 10 | %slot(name="summary") 11 | = if @open 12 | %div 13 | %slot 14 | = unless @open 15 | %div 16 | %p Loading… 17 | :css 18 | details { 19 | border: 1px solid #0003; 20 | border-radius: 2px; 21 | transition: background .2s; 22 | background: #0002; 23 | margin: 1em 0; 24 | } 25 | details[open] { 26 | background: #0001; 27 | } 28 | summary { 29 | user-select: none; 30 | cursor: pointer; 31 | font-size: 1.2em; 32 | font-weight: bold; 33 | padding: .5em; 34 | } 35 | summary:hover { 36 | text-decoration: underline; 37 | } 38 | div { 39 | margin: 1em; 40 | margin-top: 0; 41 | } 42 | -------------------------------------------------------------------------------- /app/Files.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | Directory = import("Files/Directory.haml") 3 | :ruby 4 | path = File.expand_path(Dir.pwd) 5 | %div 6 | %Directory(path=path open=true) 7 | -------------------------------------------------------------------------------- /app/Files/Directory.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | Self = self 3 | 4 | def initialize(open: false, **) 5 | @open = open 6 | end 7 | 8 | def handle_click 9 | @open = !self.state[:open] 10 | end 11 | 12 | .directory(aria-expanded=@open) 13 | %h3{onclick: method(:handle_click)} 14 | = File.basename($path) 15 | = "/" 16 | = if @open 17 | %ul 18 | = Dir.entries($path).to_a.difference(%w[. ..]).sort_by(&:downcase).map do |entry| 19 | - path = File.join($path, entry) 20 | - is_dir = File.directory?(path) 21 | %li[entry]{class: is_dir ? "dir" : "file"} 22 | = File.directory?(path) ? H[Self, path:] : entry 23 | 24 | :css 25 | h3 { 26 | font-weight: normal; 27 | margin: 0; 28 | padding: 0; 29 | font-size: inherit; 30 | cursor: pointer; 31 | text-decoration: underline; 32 | color: var(--link-color); 33 | } 34 | 35 | .directory[aria-expanded] > h3 { 36 | font-weight: bold; 37 | } 38 | 39 | .dir { 40 | list-style-type: '📁 '; 41 | } 42 | 43 | .file { 44 | list-style-type: '📄 '; 45 | } 46 | 47 | ul { 48 | list-style-type: none; 49 | margin: 0; 50 | padding: 0; 51 | padding-left: 1em; 52 | } 53 | 54 | ul:empty { 55 | display: none; 56 | } 57 | 58 | li { 59 | margin: 0; 60 | padding: 0; 61 | } 62 | -------------------------------------------------------------------------------- /app/Files/File.haml: -------------------------------------------------------------------------------- 1 | %span= File.basename($path) 2 | -------------------------------------------------------------------------------- /app/GC.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | Sample = Data.define(:time, :data) 3 | Details = import("./Details.haml") 4 | 5 | def initialize(**) 6 | @b = {} 7 | @o = {} 8 | @i = [] 9 | end 10 | 11 | def mount 12 | loop do 13 | GC.stat(@b) 14 | ObjectSpace.count_objects(@o) 15 | @i.push(count_internals) 16 | @i.shift while @i.size > 10 17 | rerender! 18 | sleep 1 19 | end 20 | end 21 | 22 | INTERNALS = [ 23 | S::Root, S::Signal, S::Computed, S::Effect, 24 | VDOM::Component::Base, VDOM::Nodes::Base, 25 | VDOM::Nodes::VProps::VCallback::Handler, 26 | Async::Task, 27 | ] 28 | 29 | def count_internals(h = {}) 30 | sample = Sample.new(Time.now, h) 31 | INTERNALS.each do |klass| 32 | h[klass.name] = ObjectSpace.each_object(klass).count 33 | end 34 | sample 35 | end 36 | 37 | def build_table(data) 38 | header = Set.new(["Time"]) 39 | 40 | rows = data.map do |sample| 41 | [ 42 | sample.time.strftime("%T"), 43 | sample.data 44 | .except("Async::Task", "VDOM::Nodes::Base") 45 | .sort_by { |k, v| header.add(k).find_index(k) } 46 | .map(&:last) 47 | ].flatten 48 | end 49 | 50 | [header.to_a, *rows] 51 | rescue => e 52 | Console.logger.error(self, e) 53 | end 54 | 55 | %div 56 | %button{onclick: ->{ GC.start }} Run GC 57 | %dl 58 | = @b.map do |k, v| 59 | .entry[k] 60 | %dt= k 61 | %dd= v 62 | %dl 63 | = @o.map do |k, v| 64 | .entry[k] 65 | %dt= k 66 | %dd= v 67 | = if sample = @i.last 68 | %pre= sample.inspect 69 | %dl 70 | = sample.data.map do |k, v| 71 | .entry[k] 72 | %dt= k 73 | %dd= v 74 | %Details 75 | %span#summary D2 graph 76 | %div 77 | %p 78 | Paste this on  79 | %a(href="https://play.d2lang.com/" target="_blank")< https://play.d2lang.com 80 | %pre(tabindex="0") 81 | = S::Exporter.export(S::Exporter::Formats::D2) 82 | %Details 83 | %span#summary Mermaid graph 84 | %div 85 | %p 86 | Paste this on  87 | %a(href="https://mermaid.live/" target="_blank")< https://mermaid.live/ 88 | %pre(tabindex="0") 89 | = S::Exporter.export(S::Exporter::Formats::Mermaid) 90 | %Details 91 | %span#summary Charts 92 | %google-chart(type="line"){ 93 | data: JSON.generate(build_table(@i)), 94 | options: JSON.generate( 95 | hAxis: { title: "Time" }, 96 | vAxis: { title: "Count" }, 97 | legend: { position: "bottom" }, 98 | crosshair: { 99 | color: "#000", 100 | trigger: "selection" 101 | }, 102 | axes: { 103 | x: { 0 => { side: "bottom" } } 104 | } 105 | ) 106 | } 107 | 108 | :css 109 | pre { 110 | border: 1px solid #0003; 111 | border-radius: 2px; 112 | line-height: 1.5em; 113 | font-size: 1.2em; 114 | padding: 1em; 115 | background: #0003; 116 | user-select: all; 117 | white-space: pre-wrap; 118 | } 119 | 120 | pre:focus { 121 | background: #00f3; 122 | outline: 1px dashed #00f; 123 | border-color: #00f3; 124 | } 125 | 126 | dl { 127 | columns: 16em auto; 128 | font-family: monospace; 129 | } 130 | 131 | .entry { 132 | display: flex; 133 | flex-wrap: wrap; 134 | justify-content: space-between; 135 | } 136 | 137 | dt { 138 | font-weight: bold; 139 | } 140 | dd { 141 | text-align: right; 142 | } 143 | 144 | google-chart { 145 | width: 100%; 146 | aspect-ratio: 0.75; 147 | } 148 | -------------------------------------------------------------------------------- /app/Haml.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | # setup 3 | :ruby 4 | items = %w[hello world hola mundo] 5 | %div 6 | %ul 7 | = items.map do |item| 8 | %li{key: item} 9 | %h3= item 10 | %ul 11 | = item.each_char.with_index.map do |char, i| 12 | %li{key: i}= char 13 | -------------------------------------------------------------------------------- /app/Life.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | WIDTH = 10 3 | HEIGHT = 10 4 | 5 | def initialize(**) 6 | @grid = Array.new(HEIGHT) { Array.new(WIDTH) { false } } 7 | @speed = 0.5 8 | end 9 | 10 | def mount 11 | loop do 12 | sleep @speed 13 | update_grid 14 | end 15 | end 16 | 17 | def randomize_grid 18 | @grid = @grid.map do |cells| 19 | cells.map { rand(2).zero? } 20 | end 21 | end 22 | 23 | def update_speed(target:, **) 24 | @speed = target[:value].to_f 25 | end 26 | 27 | def update_grid 28 | @grid = @grid.map.with_index do |row, y| 29 | row.map.with_index do |cell, x| 30 | sum = sum_neighbors(@grid, x, y) 31 | 32 | if cell 33 | # Any live cell with two or three live neighbours survives. 34 | # All other live cells die in the next generation. 35 | sum == 2 || sum == 3 36 | else 37 | # Any dead cell with three live neighbours becomes a live cell. 38 | # Similarly, all other dead cells stay dead. 39 | sum == 3 40 | end 41 | end 42 | end 43 | end 44 | 45 | def sum_neighbors(grid, x, y) 46 | (y.pred..y.succ).sum do |yy| 47 | (x.pred..x.succ).sum do |xx| 48 | case 49 | when yy == y && xx == x 50 | 0 51 | when grid[yy % HEIGHT][xx % WIDTH] 52 | 1 53 | else 54 | 0 55 | end 56 | end 57 | end 58 | end 59 | %div 60 | .buttons 61 | %input(type="range" min="0.1" max="1.0" step="0.01"){oninput: method(:update_speed)} 62 | %button{onclick: method(:randomize_grid)} Randomize 63 | .grid 64 | = @grid.map.with_index do |row, y| 65 | .row[y] 66 | = row.map.with_index do |cell, x| 67 | .cell[x](data-alive=cell) 68 | %span= "#{x},#{y}" 69 | :css 70 | .grid { 71 | background: #000; 72 | display: flex; 73 | flex-direction: column; 74 | gap: 1px; 75 | border: 1px solid #000; 76 | font-family: monospace; 77 | } 78 | 79 | .row { 80 | display: flex; 81 | gap: 1px; 82 | flex: 1 1; 83 | } 84 | 85 | .cell { 86 | flex: 1 1; 87 | aspect-ratio: 1; 88 | background: #fff; 89 | color: #000; 90 | position: relative; 91 | transition: background 50ms, color 50ms; 92 | } 93 | 94 | .cell > span { 95 | position: absolute; 96 | top: 50%; 97 | left: 50%; 98 | translate: -50% -50%; 99 | font-size: .4em; 100 | } 101 | 102 | .cell[data-alive] { 103 | background: #000; 104 | color: #fff; 105 | } 106 | -------------------------------------------------------------------------------- /app/List.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | Button = import("Button.haml") 3 | 4 | Item = Data.define(:value, :color) do 5 | def id = object_id 6 | def to_s = value.to_s 7 | end 8 | 9 | INITIAL_ITEMS = 10.times.map { Item[_1.to_s, rand] }.freeze 10 | 11 | :ruby 12 | input = signal("") 13 | value = signal("") 14 | 15 | items = signal(INITIAL_ITEMS) 16 | 17 | oninput = ->(target:, **) do 18 | input.value = target[:value].to_s.strip 19 | end 20 | 21 | onchange = ->(target:, **) do 22 | input.value = value.value = target[:value].to_s.strip 23 | end 24 | 25 | add_disabled = computed do 26 | puts "\e[3;33mCalculating add_disabled #{input.value.inspect}\e[0m" 27 | input.value.empty? 28 | end 29 | 30 | prepend = -> do 31 | break if value.value.empty? 32 | items.value = [Item[value.value, rand], *items.value] 33 | input.value = value.value = "" 34 | end 35 | 36 | append = -> do 37 | break if value.value.empty? 38 | items.value = [*items.value, Item[value.value, rand]] 39 | input.value = value.value = "" 40 | end 41 | 42 | sort = -> { items.value = items.value.sort_by(&:value) } 43 | sort_by_color = -> { items.value = items.value.sort_by(&:color) } 44 | shuffle = -> { items.value = items.value.shuffle } 45 | reverse = -> { items.value = items.value.reverse } 46 | clear = -> { items.value = [] } 47 | reset = -> { items.value = INITIAL_ITEMS } 48 | 49 | insert = ->(target:, **) do 50 | items.value += target[:value].to_i.times.map { Item.new(SecureRandom.alphanumeric(8), rand) } 51 | end 52 | 53 | count = computed { items.value.length } 54 | 55 | %article 56 | %p This page exists to demonstrate reordering. 57 | .flex 58 | %fieldset 59 | %legend Add 60 | .buttons 61 | %input(type="text" oninput=oninput onchange=onchange autocomplete="off" value=value) 62 | %button(onclick=prepend disabled=add_disabled) Prepend 63 | %button(onclick=append disabled=add_disabled) Append 64 | %fieldset 65 | %legend Actions 66 | .buttons 67 | %button(onclick=sort) Sort 68 | %button(onclick=sort_by_color) Sort by color 69 | %button(onclick=reverse) Reverse 70 | %button(onclick=shuffle) Shuffle 71 | %button(onclick=clear) Clear 72 | %button(onclick=reset) Reset 73 | %button(onclick=insert value=100) Insert 100 74 | %button(onclick=insert value=500) Insert 500 75 | %button(onclick=insert value=1000) Insert 1000 76 | %p 77 | Number of items: 78 | %span= count 79 | %Button hello 80 | .flex 81 | .list 82 | = computed do 83 | %ul 84 | = items.value.map do |item| 85 | %li[item]{ style: { background: format("hsl(%.8fturn 75%% 75%%)", item.color) } }= item.value 86 | 87 | :css 88 | .flex { 89 | display: flex; 90 | flex-wrap: wrap; 91 | gap: 1em; 92 | } 93 | 94 | .buttons { 95 | display: flex; 96 | flex-wrap: wrap; 97 | gap: 1em; 98 | } 99 | 100 | button, input { 101 | flex: 1; 102 | display: inline-block; 103 | } 104 | 105 | ul { 106 | font-family: monospace; 107 | margin: .5em; 108 | padding: 0; 109 | list-style-type: none; 110 | width: 100%; 111 | display: flex; 112 | flex-wrap: wrap; 113 | gap: 1px; 114 | } 115 | 116 | li { 117 | margin: 0; 118 | display: block; 119 | border: 1px solid #0003; 120 | border-radius: 2px; 121 | flex: 1 1; 122 | text-align: center; 123 | } 124 | 125 | fieldset { 126 | flex: 1 1 20em; 127 | } 128 | -------------------------------------------------------------------------------- /app/List2.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | List = import("List.haml") 3 | 4 | .flex 5 | .list 6 | %List 7 | .list 8 | %List 9 | 10 | :css 11 | .flex { 12 | display: flex; 13 | gap: 1em; 14 | } 15 | 16 | .list { 17 | flex: 1 0 50%; 18 | } 19 | -------------------------------------------------------------------------------- /app/Shoelace.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | def handle_submit(**submitted) = 3 | p(submitted:) 4 | %div 5 | %sl-split-panel(position=50){style: { __divider_width: 20.px }} 6 | %sl-icon(slot="divider" name="grip-vertical") 7 | %sl-card(slot="start") 8 | %form{onsubmit: method(:handle_submit)} 9 | %sl-input(autocomplete="off" help_text="What would you like people to call you?" label="Name" name="name" required="") 10 | %sl-select(clearable="" help_text="Select the best option." label="Favorite Animal" name="animal" required="") 11 | %sl-option(value="birds") Birds 12 | %sl-option(value="cats") Cats 13 | %sl-option(value="dogs") Dogs 14 | %sl-option(value="other") Other 15 | %sl-checkbox(required="" value="accept") Accept terms and conditions 16 | %sl-button-group 17 | %sl-button(type="submit" variant="primary") Submit 18 | %sl-button(type="reset" variant="default") Reset 19 | %sl-card(slot="end") 20 | %sl-tab-group 21 | %sl-tab(slot="nav" panel="general") General 22 | %sl-tab(slot="nav" panel="custom") Custom 23 | %sl-tab(slot="nav" panel="advanced") Advanced 24 | %sl-tab(slot="nav" panel="disabled" disabled=true) Disabled 25 | %sl-tab-panel(name="general") This is the general tab panel. 26 | %sl-tab-panel(name="custom") This is the custom tab panel. 27 | %sl-tab-panel(name="advanced") 28 | %sl-button-group 29 | %sl-button button 30 | %sl-button(variant="neutral" outline) Neutral 31 | %div 32 | %sl-rating(label="Rating") 33 | %div 34 | %sl-switch Switch 35 | %sl-tab-panel(name="disabled") This is a disabled tab panel. 36 | :css 37 | sl-input, 38 | sl-select, 39 | sl-checkbox { 40 | display: block; 41 | margin-bottom: var(--sl-spacing-medium); 42 | } 43 | 44 | /* user invalid styles */ 45 | sl-input[data-user-invalid]::part(base), 46 | sl-select[data-user-invalid]::part(combobox), 47 | sl-checkbox[data-user-invalid]::part(control) { 48 | border-color: var(--sl-color-danger-600); 49 | } 50 | 51 | [data-user-invalid]::part(form-control-label), 52 | [data-user-invalid]::part(form-control-help-text), 53 | sl-checkbox[data-user-invalid]::part(label) { 54 | color: var(--sl-color-danger-700); 55 | } 56 | 57 | sl-checkbox[data-user-invalid]::part(control) { 58 | outline: none; 59 | } 60 | 61 | sl-input:focus-within[data-user-invalid]::part(base), 62 | sl-select:focus-within[data-user-invalid]::part(combobox), 63 | sl-checkbox:focus-within[data-user-invalid]::part(control) { 64 | border-color: var(--sl-color-danger-600); 65 | box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300); 66 | } 67 | 68 | /* User valid styles */ 69 | sl-input[data-user-valid]::part(base), 70 | sl-select[data-user-valid]::part(combobox), 71 | sl-checkbox[data-user-valid]::part(control) { 72 | border-color: var(--sl-color-success-600); 73 | } 74 | 75 | [data-user-valid]::part(form-control-label), 76 | [data-user-valid]::part(form-control-help-text), 77 | sl-checkbox[data-user-valid]::part(label) { 78 | color: var(--sl-color-success-700); 79 | } 80 | 81 | sl-checkbox[data-user-valid]::part(control) { 82 | background-color: var(--sl-color-success-600); 83 | outline: none; 84 | } 85 | 86 | sl-input:focus-within[data-user-valid]::part(base), 87 | sl-select:focus-within[data-user-valid]::part(combobox), 88 | sl-checkbox:focus-within[data-user-valid]::part(control) { 89 | border-color: var(--sl-color-success-600); 90 | box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300); 91 | } 92 | 93 | sl-card::part(base) { 94 | margin: 1em; 95 | } 96 | -------------------------------------------------------------------------------- /app/StartPage.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | INITIAL_VALUE = 50 3 | 4 | def self.title = "Start" 5 | :ruby 6 | input = signal(INITIAL_VALUE) 7 | size = signal(INITIAL_VALUE) 8 | 9 | percent = computed { "#{size.value}%" } 10 | 11 | oninput = ->(target:) do 12 | size.value = target[:value].to_i 13 | end 14 | 15 | onchange = ->(target:) do 16 | input.value = size.value = target[:value].to_i 17 | end 18 | 19 | %article 20 | %h2 Start page 21 | %p This webpage is written in Ruby and updates are streamed to your browser via http/2 streams. 22 | %fieldset 23 | %legend Play with image sizes 24 | %p 25 | %input(type="range" min="1" max="100" step="1" oninput=oninput onchange=onchange value=input) 26 | %p 27 | %img(src="/favicon.ico" width=percent) 28 | -------------------------------------------------------------------------------- /app/Styles.haml: -------------------------------------------------------------------------------- 1 | :ruby 2 | def self.title = "Styles" 3 | 4 | Item = Data.define(:value) do 5 | def id = object_id 6 | def to_s = value.to_s 7 | end 8 | 9 | INITIAL_ITEMS = %w[0 1 2 3 4 5 6 7 8 9].map { Item[_1] }.freeze 10 | 11 | :ruby 12 | items = signal(INITIAL_ITEMS) 13 | 14 | %article 15 | %h2 Style demo 16 | %p Stylesheet demo 17 | %fieldset 18 | %legend Play with image sizes 19 | %p 20 | %img(src="/favicon.ico") 21 | = computed do 22 | %ul 23 | = items.value.map.with_index do |item, i| 24 | %li[item]{ 25 | style: { border: [i.px, :solid, "#f0f"] } 26 | }= item 27 | 28 | :css 29 | img { 30 | border: 15px solid #f0f; 31 | } 32 | 33 | ul { 34 | list-style-type: none; 35 | display: flex; 36 | flex-wrap: wrap; 37 | padding: 0; 38 | margin: 1em; 39 | gap: 1em; 40 | } 41 | 42 | li { 43 | flex: 1 1 20%; 44 | border-radius: 3px; 45 | background: var(--menu-bg); 46 | margin: 0; 47 | padding: .5em 1em; 48 | } 49 | -------------------------------------------------------------------------------- /app/words.txt: -------------------------------------------------------------------------------- 1 | enucleate 2 | palmer 3 | drudgery 4 | pertinency 5 | conjunctive 6 | tiptoe 7 | intercalation 8 | sepulchre 9 | primitively 10 | illiberality 11 | smudge 12 | virulency 13 | sepulture 14 | uranography 15 | detritus 16 | mazy 17 | subordinacy 18 | anaclastic 19 | siberian 20 | floret 21 | redouble 22 | salify 23 | convocation 24 | neologism 25 | laud 26 | antithetical 27 | hammock 28 | hydrazine 29 | envenom 30 | dissepiment 31 | bloat 32 | verbosity 33 | glean 34 | landlocked 35 | acquiescent 36 | aspersion 37 | locative 38 | rivel 39 | auricle 40 | puritan 41 | impassioned 42 | aphrodite 43 | irrorate 44 | inopportune 45 | grime 46 | radiator 47 | amerce 48 | sedative 49 | stonechat 50 | nullity 51 | cicatrize 52 | demonize 53 | homologue 54 | eupeptic 55 | bamboozle 56 | throe 57 | commiseration 58 | interscapular 59 | vagabondage 60 | machination 61 | calliope 62 | nippers 63 | flit 64 | stringent 65 | ceramics 66 | philanthropist 67 | illimitable 68 | enthrone 69 | foresail 70 | piteous 71 | supination 72 | deferent 73 | insecta 74 | preengage 75 | augural 76 | peregrine 77 | forsooth 78 | lucubrate 79 | consubstantiate 80 | expostulatory 81 | beadle 82 | imprescriptible 83 | tetrahexahedron 84 | penury 85 | paternity 86 | catechumen 87 | continuant 88 | sublimate 89 | catchword 90 | adore 91 | swarthiness 92 | wiseacre 93 | puerility 94 | gorget 95 | purvey 96 | pusillanimous 97 | disembogue 98 | comfit 99 | pragmatic 100 | leger 101 | cognation 102 | pathogeny 103 | inadvertency 104 | regalia 105 | stylographic 106 | amphibology 107 | depute 108 | theocracy 109 | complementary 110 | plutocracy 111 | sundry 112 | vermicular 113 | betwixt 114 | panduriform 115 | anatomize 116 | scallion 117 | executory 118 | turmoil 119 | perjured 120 | folkmote 121 | nimbus 122 | posy 123 | burke 124 | tympany 125 | excoriation 126 | shrilly 127 | infantile 128 | perambulation 129 | magistral 130 | voltaism 131 | busk 132 | hellenist 133 | daedalian 134 | armament 135 | interoceanic 136 | immortalize 137 | incontrovertible 138 | entoplastic 139 | ostracize 140 | fatality 141 | toleration 142 | initiatory 143 | enkindle 144 | odontoid 145 | valhalla 146 | implicitly 147 | indetermination 148 | incisive 149 | educe 150 | defaulter 151 | beckon 152 | catholicity 153 | gratulate 154 | necessitarianism 155 | animadvert 156 | raucity 157 | salubrity 158 | etymology 159 | cyanotic 160 | consubstantiation 161 | arsenical 162 | impropriate 163 | bagatelle 164 | peripheral 165 | subvertebral 166 | silhouette 167 | voucher 168 | asperity 169 | frontlet 170 | dorsum 171 | emollient 172 | rejuvenescence 173 | plethora 174 | dictation 175 | hearten 176 | tribular 177 | cherub 178 | spooney 179 | arrear 180 | collectivism 181 | aphlogistic 182 | propitiation 183 | abjuration 184 | accessary 185 | copiousness 186 | lyceum 187 | daggle 188 | notoriety 189 | depredation 190 | unconscionable 191 | needly 192 | haematogenesis 193 | controvertist 194 | beatify 195 | statuesque 196 | intercessor 197 | retrogression 198 | diagnosticate 199 | vinculum 200 | rascality -------------------------------------------------------------------------------- /bin/transform: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -rbundler 2 | 3 | require_relative "../lib/vdom/transform" 4 | require_relative "../lib/vdom/haml_transform" 5 | 6 | filename = 7 | case ARGF.filename 8 | in "-" then "stdin" 9 | in filename then filename 10 | end 11 | 12 | ARGF 13 | .read 14 | .then { VDOM::HamlTransform.transform(_1, filename) } 15 | .then { VDOM::Transform.transform(_1) } 16 | .then { puts(_1) } 17 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require_relative "lib/vdom" 5 | require_relative "lib/vdom/server" 6 | 7 | DEFAULT_BIND = "https://localhost:8080" 8 | 9 | server = VDOM::Server.new( 10 | bind: ENV.fetch("RDOM_BIND", DEFAULT_BIND), 11 | localhost: ENV.fetch("RDOM_LOCALHOST", "true").start_with?("t"), 12 | component: VDOM::Component.load_file("app/App.haml"), 13 | public_path: File.join(__dir__, "public"), 14 | ) 15 | 16 | Async do 17 | server.run 18 | end 19 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aalin/rdom/112a48ec5ca4f2eb97df814b9c88787fe26f8cf2/demo/favicon.ico -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RDOM embed demo 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 72 | 73 | 74 |
    75 |

    RDOM Embedding Demo

    76 |

    77 | This page shows how 78 | rdom 79 | can be embedded into webpages. 80 |

    81 |
    82 | 83 |
    84 | 85 | 86 | 87 | 88 |

    Initializing...

    89 |
    90 | 91 | 92 |

    Initializing...

    93 |
    94 |
    95 | 96 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "rdom" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [env] 7 | RDOM_BIND = "http://[::]:8080" 8 | RDOM_LOCALHOST = false 9 | 10 | [experimental] 11 | auto_rollback = true 12 | 13 | [[services]] 14 | http_checks = [] 15 | internal_port = 8080 16 | processes = ["app"] 17 | protocol = "tcp" 18 | script_checks = [] 19 | [services.concurrency] 20 | hard_limit = 25 21 | soft_limit = 20 22 | type = "connections" 23 | 24 | [[services.ports]] 25 | force_https = true 26 | handlers = ["http"] 27 | port = 80 28 | 29 | [[services.ports]] 30 | handlers = ["tls"] 31 | port = 443 32 | tls_options = { alpn = ["h2"] } 33 | 34 | [[services.tcp_checks]] 35 | grace_period = "1s" 36 | interval = "15s" 37 | restart_limit = 0 38 | timeout = "2s" 39 | -------------------------------------------------------------------------------- /lib/vdom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | require_relative "vdom/descriptor" 5 | require_relative "vdom/style_sheet" 6 | require_relative "vdom/component" 7 | require_relative "vdom/patches" 8 | require_relative "vdom/nodes" 9 | 10 | module VDOM 11 | def self.run 12 | vroot = VDOM::Nodes::VRoot.start 13 | yield vroot 14 | ensure 15 | vroot&.stop 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/vdom/assets.rb: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | require "brotli" 3 | 4 | module VDOM 5 | class Assets 6 | IntegrityHash = Data.define(:raw, :bitlen) do 7 | def self.[](content, bitlen = 256) = 8 | new(Digest::SHA2.digest(content, bitlen), bitlen) 9 | 10 | def to_s = 11 | "sha#{bitlen}-#{base64}" 12 | def base64 = 13 | Base64.strict_encode64(raw) 14 | def urlsafe_base64 = 15 | Base64.urlsafe_encode64(raw, padding: false) 16 | end 17 | 18 | EncodedContent = Data.define(:encoding, :to_s) do 19 | def self.[](content, mime_type) = 20 | case mime_type.media_type 21 | in "text" 22 | new(:br, Brotli.deflate(content)) 23 | else 24 | new(nil, content) 25 | end 26 | end 27 | 28 | Content = Data.define(:encoded, :integrity, :mime_type) do 29 | def self.[](content, mime_type) = 30 | new( 31 | EncodedContent[content, mime_type], 32 | IntegrityHash[content], 33 | mime_type, 34 | ) 35 | 36 | def encoding = 37 | encoded.encoding 38 | def to_s = 39 | encoded.to_s 40 | def type = 41 | mime_type.to_s 42 | def preferred_extension = 43 | mime_type.preferred_extension 44 | end 45 | 46 | Asset = Data.define(:filename, :content) do 47 | def self.[](content, mime_type) = 48 | Content[content, mime_type].then do |content| 49 | new(filename_from_content(content), content) 50 | end 51 | 52 | def self.filename_from_content(content) = 53 | "#{content.integrity.urlsafe_base64}.#{content.preferred_extension}" 54 | 55 | def path = 56 | filename 57 | end 58 | 59 | include Singleton 60 | 61 | def initialize = 62 | @files = {} 63 | def store(asset) = 64 | @files[asset.filename] ||= asset 65 | def fetch(filename, &) = 66 | @files.fetch(filename, &) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/vdom/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "transform" 4 | require_relative "haml_transform" 5 | require_relative "descriptor" 6 | require_relative "custom_element" 7 | require_relative "css_units" 8 | require_relative "assets" 9 | require_relative "s" 10 | 11 | module VDOM 12 | module Component 13 | class Base 14 | H = VDOM::Descriptor 15 | 16 | def self.import(filename) 17 | Component.load_file( 18 | filename, 19 | File.dirname(caller.first.split(":", 2).first) 20 | ) 21 | end 22 | 23 | def self.title = name[/[^:]+\z/] 24 | 25 | def initialize(**) = nil 26 | 27 | def state = @state ||= {} 28 | def props = @props ||= {} 29 | def slots = @slots ||= {} 30 | 31 | def mount = nil 32 | def render = nil 33 | 34 | private 35 | 36 | def async(task: Async::Task.current, &) 37 | task.async(&) 38 | end 39 | 40 | def update(&) 41 | yield 42 | rerender! 43 | end 44 | 45 | def rerender! 46 | # this method will be defined on each component. 47 | end 48 | 49 | def emit!(event, **payload) 50 | # this method will be defined on each component. 51 | end 52 | end 53 | 54 | class ComponentModule < Module 55 | using CSSUnits::Refinements 56 | using S::Refinements 57 | 58 | def initialize(code, path) = 59 | instance_eval(code, path.to_s, 1) 60 | end 61 | 62 | Metadata = Data.define(:name, :path) 63 | 64 | class Loader 65 | include Singleton 66 | 67 | def initialize 68 | @loaded_components = {} 69 | end 70 | 71 | def load_file(filename, source_path = nil) 72 | path = Pathname.new(File.expand_path(filename, source_path)).freeze 73 | @loaded_components[path] ||= load_component(File.read(path), path) 74 | end 75 | 76 | private 77 | 78 | def load_component(source, path) 79 | # puts "\e[3m SOURCE \e[0m" 80 | # puts "\e[33m#{source}\e[0m" 81 | 82 | relative_path = path.relative_path_from(Dir.pwd) 83 | source = transform_haml(source, relative_path) 84 | source = transform_ruby(source, relative_path) 85 | 86 | puts "\e[3m TRANSFORMED \e[0m" 87 | puts "\e[32m#{source}\e[0m" 88 | 89 | component = ComponentModule.new(source, path)::Export 90 | 91 | name = File.basename(path, ".*").freeze 92 | component.define_singleton_method(:title) { name } 93 | component.define_singleton_method(:display_name) { name } 94 | component.const_set(:COMPONENT_META, Metadata[name, path]) 95 | 96 | if stylesheet = component.const_get(HamlTransform::STYLES_CONST_NAME) 97 | Assets.instance.store(stylesheet.asset) 98 | end 99 | 100 | if partials = component.const_get(HamlTransform::PARTIALS_CONST_NAME) 101 | partials.each { Assets.instance.store(_1.asset) } 102 | end 103 | 104 | component 105 | end 106 | 107 | def transform_haml(source, path) 108 | if File.extname(path) == ".haml" 109 | HamlTransform.transform(source, path) 110 | else 111 | source 112 | end 113 | end 114 | 115 | def transform_ruby(source, path) = 116 | Transform.transform(source) 117 | end 118 | 119 | def self.load_file(filename, source_path = nil) = 120 | Loader.instance.load_file(filename, source_path) 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/vdom/css_units.rb: -------------------------------------------------------------------------------- 1 | module VDOM 2 | module CSSUnits 3 | CustomProperty = Data.define(:name) do 4 | def self.[](name) = new(name.to_s.tr("_", "-")) 5 | 6 | def to_s = "var(#{name})" 7 | alias inspect to_s 8 | end 9 | 10 | Calc = Data.define(:left, :operator, :right) do 11 | def to_s = "calc(#{left} #{operator} #{right})".gsub("(calc(", "((") 12 | alias inspect to_s 13 | 14 | def +(other) = Calc[self, __method__, other] 15 | def -(other) = Calc[self, __method__, other] 16 | def *(other) = Calc[self, __method__, other] 17 | def /(other) = Calc[self, __method__, other] 18 | end 19 | 20 | NumberWithUnit = Data.define(:number, :unit) do 21 | def to_s = "#{number}#{unit}" 22 | alias inspect to_s 23 | 24 | def +(other) = handle_operator(__method__, other) 25 | def -(other) = handle_operator(__method__, other) 26 | def *(other) = handle_operator(__method__, other) 27 | def /(other) = handle_operator(__method__, other) 28 | 29 | private 30 | 31 | def handle_operator(operator, other) 32 | case other 33 | when Symbol 34 | Calc[self, operator, CustomProperty[other]] 35 | when Calc 36 | Calc[self, operator, other] 37 | when NumberWithUnit 38 | if unit == other.unit 39 | NumberWithUnit[number.send(operator, other.number), unit] 40 | else 41 | Calc[self, operator, other] 42 | end 43 | else 44 | NumberWithUnit[number.send(operator, other), unit] 45 | end 46 | end 47 | end 48 | 49 | module Refinements 50 | refine Numeric do 51 | def with_unit(unit) = NumberWithUnit[self, unit] 52 | def percent = with_unit(:%) 53 | def cm = with_unit(__method__) 54 | def mm = with_unit(__method__) 55 | def Q = with_unit(:q) 56 | def in = with_unit(__method__) 57 | def pc = with_unit(__method__) 58 | def pt = with_unit(__method__) 59 | def px = with_unit(__method__) 60 | def em = with_unit(__method__) 61 | def ex = with_unit(__method__) 62 | def ch = with_unit(__method__) 63 | def rem = with_unit(__method__) 64 | def lh = with_unit(__method__) 65 | def rlh = with_unit(__method__) 66 | def vw = with_unit(__method__) 67 | def vh = with_unit(__method__) 68 | def vmin = with_unit(__method__) 69 | def vmax = with_unit(__method__) 70 | def vb = with_unit(__method__) 71 | def vi = with_unit(__method__) 72 | def svw = with_unit(__method__) 73 | def svh = with_unit(__method__) 74 | def lvw = with_unit(__method__) 75 | def lvh = with_unit(__method__) 76 | def dvw = with_unit(__method__) 77 | def dvh = with_unit(__method__) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/vdom/custom_element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module VDOM 4 | CustomElement = Data.define(:name, :stylesheet, :asset) do 5 | def self.mime_type = 6 | MIME::Types["text/html"].first 7 | 8 | def self.[](name, html, stylesheet) = 9 | new(name, stylesheet, Assets::Asset["#{stylesheet&.import_html}#{html}", mime_type]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/vdom/descriptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module VDOM 4 | class InvalidDescriptor < StandardError 5 | end 6 | 7 | Descriptor = Data.define(:type, :key, :slot, :children, :props, :hash) do 8 | def self.[](type, *children, key: nil, slot: nil, **props) = 9 | new( 10 | type, 11 | key, 12 | slot, 13 | normalize_children(children), 14 | props, 15 | calculate_hash(type, key, slot, props), 16 | ) 17 | 18 | def self.calculate_hash(type, key, slot, props) = 19 | [ 20 | type, 21 | key, 22 | slot, 23 | type == :input && props[:type], 24 | ].hash 25 | 26 | def self.same?(a, b) = get_hash(a) == get_hash(b) 27 | 28 | def self.get_hash(descriptor) = 29 | case descriptor 30 | in Descriptor then descriptor.hash 31 | in String then String.hash 32 | in Array then Array.hash 33 | else descriptor.hash 34 | end 35 | 36 | def self.normalize_children(children) = 37 | Array(children) 38 | .flatten 39 | .map { or_string(_1) } 40 | .compact 41 | 42 | def self.merge_props(*props) = 43 | props.reduce({}, &:merge) 44 | 45 | def self.or_string(descriptor) 46 | case descriptor 47 | in ^(self) 48 | descriptor 49 | in S::Reactive 50 | descriptor 51 | else 52 | (descriptor && descriptor.to_s) || nil 53 | end 54 | end 55 | 56 | def eql?(other) = 57 | self.class === other && hash == other.hash 58 | def with_children(children) = 59 | with(children: self.class.normalize_children(children)) 60 | def update_props(&) = 61 | with(props: yield(props)) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/vdom/haml_transform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | require "digest/sha2" 5 | require "base64" 6 | require "syntax_tree" 7 | require "syntax_tree/haml" 8 | require "syntax_suggest" 9 | require "syntax_suggest/code_line" 10 | require "syntax_suggest/explain_syntax" 11 | require "syntax_suggest/lex_all" 12 | require "syntax_suggest/ripper_errors" 13 | require_relative "mutation_visitor" 14 | 15 | class SyntaxTree::MatchVisitor 16 | alias old_visit visit 17 | def visit(node) 18 | old_visit(node) if node 19 | end 20 | end 21 | 22 | module VDOM 23 | class HamlTransform < SyntaxTree::Haml::Visitor 24 | def self.transform(source, filename = SecureRandom.alphanumeric(5)) 25 | transformer = new(filename) 26 | parsed = SyntaxTree::Haml.parse(source) 27 | transformed = parsed.accept(transformer) 28 | formatter = SyntaxTree::Formatter.new(source, []) 29 | transformed.format(formatter) 30 | formatter.flush 31 | formatter.output.join 32 | end 33 | 34 | class CustomElement 35 | def initialize(name, id: SecureRandom.alphanumeric(5)) 36 | @name = name 37 | @refs = [] 38 | @slots = [] 39 | @root = nil 40 | end 41 | 42 | attr_accessor :root 43 | attr_reader :name 44 | attr_reader :refs 45 | attr_reader :props 46 | attr_reader :slots 47 | end 48 | 49 | Tag = Data.define(:name, :key, :slot, :attrs, :props, :children) do 50 | def to_s 51 | "<#{name}#{attrs.map { format(' %s="%s"', _1.to_s.tr("_", "-"), _2) }.join}>#{children.join}" 52 | end 53 | end 54 | 55 | Slot = Data.define(:name, :expressions) 56 | Ref = Data.define(:name, :props) 57 | Prop = Data.define(:name, :expressions) 58 | 59 | PARTIALS_CONST_NAME = "RDOM_Partials" 60 | STYLES_CONST_NAME = "RDOM_Stylesheet" 61 | CUSTOM_ELEMENT_NAME_PREFIX = "rdom-elem-" 62 | 63 | include SyntaxTree::DSL 64 | 65 | def initialize(filename) 66 | @filename = filename 67 | @styles = [] 68 | @custom_elements = [] 69 | end 70 | 71 | def visit_silent_script(node) 72 | parse_ruby(node.value[:text]) 73 | end 74 | 75 | def visit_filter(node) 76 | case node.value 77 | in { name: "ruby", text: } 78 | SyntaxTree.parse(text.to_s).statements 79 | in { name: "css", text: } 80 | @styles.push(text) 81 | nil 82 | end 83 | end 84 | 85 | def visit_root(node) 86 | pre = [] 87 | 88 | children = node.children.dup 89 | 90 | if children.first in { type: :filter, value: { name: "ruby" } } 91 | pre.push(children.shift.accept(self)) 92 | end 93 | 94 | children = children.map do |child| 95 | child.accept(self) 96 | end.compact 97 | 98 | Program( 99 | Statements([ 100 | define_stylesheets(@styles), 101 | define_partials(@custom_elements), 102 | *pre, 103 | DefNode( 104 | nil, 105 | nil, 106 | Ident("render"), 107 | nil, 108 | BodyStmt(Statements(children), nil, nil, nil, nil) 109 | ), 110 | ].compact) 111 | ) 112 | end 113 | 114 | def define_stylesheets(styles) 115 | return Assign( 116 | VarField(Const(STYLES_CONST_NAME)), 117 | VarRef(Kw("nil")) 118 | ) if styles.empty? 119 | 120 | content = 121 | styles 122 | .join("\n") 123 | .each_line 124 | .map(&:strip) 125 | .reject(&:empty?) 126 | .map { "#{_1}\n" } 127 | .join 128 | 129 | content_hash = 130 | content 131 | .then { Digest::SHA256.digest(_1) } 132 | .then { _1.slice(0, 12).to_s } 133 | .then { Base64.urlsafe_encode64(_1) } 134 | 135 | Assign( 136 | VarField(Const(STYLES_CONST_NAME)), 137 | ARef( 138 | ConstPathRef( 139 | VarRef(Const("VDOM")), 140 | VarRef(Const("StyleSheet")), 141 | ), 142 | Args([ 143 | Heredoc( 144 | HeredocBeg("< { 281 | name:, attributes:, dynamic_attributes:, value:, parse:, object_ref: 282 | } 283 | 284 | slot = attributes.delete("slot") || attributes.delete("id") 285 | 286 | if object_ref in String 287 | key = parse_ruby(object_ref, fix: false) 288 | end 289 | 290 | if dynamic_attributes.old || dynamic_attributes.new 291 | ref = Ref[ 292 | "ref#{custom_element.refs.size}", 293 | [ 294 | *build_old_dynamic_attributes(custom_element, dynamic_attributes.old), 295 | *build_new_dynamic_attributes(dynamic_attributes.new), 296 | ] 297 | ] 298 | custom_element.refs.push(ref) 299 | attributes = { **attributes, data_rdom_ref: ref.name } 300 | end 301 | 302 | if parse 303 | return Tag[ 304 | name, 305 | key, 306 | slot, 307 | attributes, 308 | dynamic_attributes, 309 | [ 310 | create_slot( 311 | custom_element, 312 | SyntaxTree.parse(value).statements.body 313 | ) 314 | ] 315 | ] 316 | end 317 | 318 | if node.children.empty? 319 | return Tag[ 320 | name, 321 | key, 322 | slot, 323 | attributes, 324 | dynamic_attributes, 325 | [value].compact 326 | ] 327 | end 328 | 329 | Tag[ 330 | name, 331 | key, 332 | slot, 333 | attributes, 334 | dynamic_attributes, 335 | map_children(custom_element, node.children) 336 | ] 337 | end 338 | 339 | def build_old_dynamic_attributes(custom_element, attrs) 340 | return unless attrs 341 | SyntaxTree.parse(attrs).statements.body 342 | end 343 | 344 | def build_new_dynamic_attributes(attrs) 345 | return unless attrs 346 | visitor = MutationVisitor.new 347 | 348 | visitor.mutate("Assoc[key: StringLiteral]") do |node| 349 | node.copy(key: SymbolLiteral(Ident(node.key.parts.map(&:value).join.tr("-", "_")))) 350 | end 351 | 352 | SyntaxTree.parse(attrs).statements.accept(visitor) 353 | end 354 | 355 | def create_slot(custom_element, children) 356 | slot = Slot["slot#{custom_element.slots.size}", children] 357 | custom_element.slots.push(slot) 358 | Tag[:slot, nil, slot, { data_rdom_slot: slot.name }, {}, []] 359 | end 360 | 361 | def parse_ruby(code, fix: true) 362 | if fix 363 | code = fix_syntax_by_adding_missing_pairs(code) 364 | end 365 | 366 | SyntaxTree.parse(code).statements 367 | end 368 | 369 | def map_children(custom_element, children) 370 | children.map do |child| 371 | case child 372 | in { type: :tag } 373 | case child.value[:name].to_s 374 | in /\A[[:upper:]]/ 375 | create_slot( 376 | custom_element, 377 | build_component(custom_element, child) 378 | ) 379 | in "slot" 380 | create_slot( 381 | custom_element, 382 | build_slotted(custom_element, child) 383 | ) 384 | else 385 | build_tag(custom_element, child) 386 | end 387 | in { type: :plain } 388 | child.value[:text] 389 | in { type: :silent_script } 390 | parse_ruby(child.value[:text], fix: false) 391 | in { type: :script } 392 | create_slot( 393 | custom_element, 394 | build_script(custom_element, child), 395 | ) 396 | end 397 | end 398 | end 399 | 400 | def build_props(props) 401 | CallNode( 402 | VarRef(Const("H")), 403 | Period("."), 404 | Ident("merge_props"), 405 | ArgParen( 406 | Args( 407 | props.map do |prop| 408 | wrap_multiple_statements_in_begin_and_end(Array(prop)) 409 | end 410 | ) 411 | ) 412 | ) 413 | end 414 | 415 | def build_slotted(custom_element, node) 416 | node.value => { 417 | name:, attributes:, dynamic_attributes:, value:, parse:, object_ref: 418 | } 419 | 420 | props = 421 | if attributes["name"] 422 | parse_ruby(attributes["name"].inspect) 423 | else 424 | Ident("nil") 425 | end 426 | 427 | [ 428 | ARef( 429 | CallNode( 430 | VarRef(Ident("self")), 431 | Period("."), 432 | Ident("slots"), 433 | nil 434 | ), 435 | Args(Array(props)) 436 | ) 437 | ] 438 | end 439 | 440 | def build_component(custom_element, node) 441 | node.value => { 442 | name:, attributes:, dynamic_attributes:, value:, parse:, object_ref: 443 | } 444 | 445 | if object_ref in String 446 | key = parse_ruby(object_ref, fix: false) 447 | end 448 | 449 | props = [ 450 | *build_old_dynamic_attributes(custom_element, dynamic_attributes.old), 451 | *build_new_dynamic_attributes(dynamic_attributes.new), 452 | ].map(&:body).flatten.compact 453 | 454 | args = [ 455 | VarRef(Const(name.to_s)), 456 | if value 457 | if parse 458 | parse_ruby(object_ref, fix: false) 459 | else 460 | StringLiteral([TStringContent(value.to_s)], "'") 461 | end 462 | end, 463 | node[:children].map do 464 | case _1 465 | in { type: :tag } => tag 466 | build_custom_element(tag) 467 | in { type: :script } => script 468 | build_script(custom_element, script) 469 | else 470 | map_children(custom_element, [_1]) 471 | end 472 | end.flatten, 473 | BareAssocHash([ 474 | if key 475 | Assoc( 476 | Label("key:"), 477 | wrap_multiple_statements_in_begin_and_end(key) 478 | ) 479 | end, 480 | unless props.empty? 481 | AssocSplat(build_props(props)) 482 | end, 483 | ].flatten.compact), 484 | ].flatten.compact 485 | 486 | [ARef(VarRef(Const("H")), Args(args))] 487 | end 488 | 489 | def build_script(custom_element, child) 490 | parsed = parse_ruby(child.value[:text]) 491 | 492 | visitor = MutationVisitor.new 493 | 494 | visitor.mutate("Statements[body: [VoidStmt]]") do |node| 495 | node.copy(body: 496 | child[:children].map do 497 | case _1 498 | in { type: :tag } => tag 499 | build_custom_element(tag) 500 | in { type: :script } => script 501 | build_script(custom_element, script) 502 | else 503 | map_children(custom_element, [_1]) 504 | end 505 | end.flatten 506 | ) 507 | end 508 | 509 | parsed.accept(visitor).body 510 | end 511 | 512 | def fix_syntax_by_adding_missing_pairs(source) 513 | [source, *get_missing_pairs(source)].join("\n") 514 | end 515 | 516 | def get_missing_pairs(source) 517 | left_right = SyntaxSuggest::LeftRightLexCount.new 518 | SyntaxSuggest::LexAll.new(source:).each { left_right.count_lex(_1) } 519 | left_right.missing 520 | end 521 | end 522 | end 523 | 524 | if __FILE__ == $0 525 | source = <<~HAML 526 | :ruby 527 | title = props[:title] 528 | items = ["foo", "bar", "baz"] 529 | %div 530 | %h1(class="title") My webpage 531 | %h2(class="subtitle")= title 532 | %ul 533 | = items.map do |item| 534 | %li(fo=bar){class: i.zero? && "foo"} 535 | %h3= item 536 | %ul 537 | = item.each_char.map do |char| 538 | %li= char 539 | HAML 540 | 541 | puts "\e[3m SOURCE: \e[0m" 542 | puts source 543 | puts "\e[3m TRANSFORMED: \e[0m" 544 | puts VDOM::HamlTransform.transform(source, __FILE__) 545 | end 546 | -------------------------------------------------------------------------------- /lib/vdom/mutation_visitor.rb: -------------------------------------------------------------------------------- 1 | require "syntax_tree" 2 | require "syntax_tree/mutation_visitor" 3 | 4 | module VDOM 5 | class MutationVisitor < SyntaxTree::MutationVisitor 6 | def self.build(&) = new.tap(&) 7 | 8 | def visit_assign(node) 9 | node.copy(target: visit(node.target), value: visit(node.value)) 10 | end 11 | 12 | def visit_unary(node) 13 | node.copy(statement: visit(node.statement)) 14 | end 15 | 16 | def visit_opassign(node) 17 | node.copy(target: visit(node.target), value: visit(node.value)) 18 | end 19 | 20 | def visit_assoc_splat(node) 21 | node.copy(value: visit(node.value)) 22 | end 23 | 24 | def visit_field(node) 25 | node.copy( 26 | parent: visit(node.parent), 27 | operator: node.operator == :"::" ? :"::" : visit(node.operator), 28 | name: visit(node.name) 29 | ) 30 | end 31 | 32 | def visit_binary(node) 33 | node.copy(left: visit(node.left), right: visit(node.right)) 34 | end 35 | 36 | def visit_lambda(node) 37 | node.copy(params: visit(node.params), statements: visit(node.statements)) 38 | end 39 | 40 | def visit_assoc(node) 41 | node.copy(key: visit(node.key), value: visit(node.value)) 42 | end 43 | 44 | def visit_aref(node) 45 | node.copy( 46 | collection: visit(node.collection), 47 | index: visit(node.index), 48 | ) 49 | end 50 | 51 | def visit_if_op(node) 52 | node.copy( 53 | predicate: visit(node.predicate), 54 | truthy: visit(node.truthy), 55 | falsy: visit(node.falsy) 56 | ) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/vdom/nodes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | require "async" 5 | require "async/barrier" 6 | require "async/condition" 7 | require "async/queue" 8 | require_relative "patches" 9 | require_relative "descriptor" 10 | require_relative "custom_element" 11 | require_relative "text_diff" 12 | require "pry" 13 | 14 | module VDOM 15 | module Nodes 16 | class Base 17 | CURRENT_KEY = :current_vnode 18 | Unmount = Class.new(Async::Stop) 19 | 20 | def self.current = Fiber[CURRENT_KEY] 21 | 22 | def self.use(node, &) 23 | prev = Fiber[CURRENT_KEY] 24 | Fiber[CURRENT_KEY] = node 25 | yield node 26 | ensure 27 | Fiber[CURRENT_KEY] = prev 28 | end 29 | 30 | def self.run(...) 31 | node = start(...) 32 | yield node 33 | ensure 34 | node&.stop 35 | end 36 | 37 | def self.start(...) = 38 | new.start(...) 39 | 40 | def initialize(parent: Base.current) 41 | @parent = parent 42 | @incoming = Async::Queue.new 43 | @task = nil 44 | end 45 | 46 | def inspect 47 | "#<#{self.class.name}>" 48 | end 49 | 50 | def start(...) 51 | raise "There is already a task!" if @task 52 | 53 | @task = async do 54 | Fiber[CURRENT_KEY] = self 55 | run(...) 56 | end 57 | 58 | self 59 | end 60 | 61 | def run(...) = 62 | raise(NotImplementedError, "#{self.class.name}##{__method__} is not implemented") 63 | 64 | def resume(*args) 65 | # empty queue first, then enqueue the new args, 66 | # so that we don't process something that will be updated anyways. 67 | @incoming.dequeue until @incoming.empty? 68 | @incoming.enqueue(args) 69 | end 70 | 71 | def stop = 72 | @task&.stop 73 | 74 | def dom_id(traverse = false) = 75 | @dom_id || (traverse && parent.dom_id) || nil 76 | 77 | protected 78 | 79 | def parent = 80 | @parent || raise("There is no parent") 81 | 82 | def receive(&) 83 | if block_given? 84 | yield(*receive) while true 85 | else 86 | @incoming.dequeue 87 | end 88 | end 89 | 90 | def closest(klass) 91 | if klass === self 92 | self 93 | else 94 | parent.closest(klass) 95 | end 96 | end 97 | 98 | def async(&) = 99 | Async::Task.current.async(&) 100 | def hierarchy = 101 | [*parent.hierarchy, self] 102 | def running? = 103 | !@task&.stopped? 104 | def batch(&) = 105 | parent.batch(&) 106 | def patch(patch) = 107 | parent.patch(patch) 108 | def get_slot(name) = 109 | parent.get_slot(name) 110 | def callbacks = 111 | parent.callbacks 112 | def mount_dom_node(id, &) = 113 | parent.mount_dom_node(id, &) 114 | end 115 | 116 | class VNode < Base 117 | def generate_id = 118 | SecureRandom.alphanumeric(5) 119 | end 120 | 121 | class VText < VNode 122 | def run(content) 123 | with_text_node(content.to_s) do |id| 124 | mount_dom_node(id) do 125 | receive do |new_content| 126 | content = update_content( 127 | id, 128 | content, 129 | new_content.to_s 130 | ) 131 | end 132 | end 133 | end 134 | end 135 | 136 | private 137 | 138 | def update_content(id, content, new_content) 139 | if content == new_content 140 | content 141 | else 142 | TextDiff.diff( 143 | id, 144 | content, 145 | new_content, 146 | ) { patch(_1) } 147 | end 148 | end 149 | 150 | def with_text_node(content, id: generate_id) 151 | patch(Patches::CreateTextNode[id, content]) 152 | yield id 153 | ensure 154 | patch(Patches::RemoveNode[id]) 155 | end 156 | end 157 | 158 | class VComponent < Base 159 | def get_slot(name) 160 | if @slots 161 | @slots.fetch(name) do 162 | $stderr.puts "\e[33mCould not find slot #{name.inspect}\e[0m" 163 | nil 164 | end 165 | else 166 | $stderr.puts "\e[33mCould not find slot #{name.inspect}\e[0m" 167 | nil 168 | end 169 | end 170 | 171 | def emit!(event, **payload) = 172 | patch(Patches::Event[event, payload]) 173 | 174 | def run(descriptor) 175 | instance = descriptor.type.allocate 176 | # Define @props before calling initialize, 177 | # so we can use self.props inside initialize. 178 | instance.instance_variable_set(:@props, descriptor.props) 179 | 180 | S.root(name: descriptor.type.display_name) do 181 | instance.send(:initialize, **descriptor.props) 182 | 183 | yield_self do |vcomponent| 184 | instance.define_singleton_method(:rerender!) do 185 | vcomponent.resume(:rerender!) 186 | end 187 | 188 | instance.define_singleton_method(:emit!) do |event, **payload| 189 | vcomponent.emit!(event, **payload) 190 | end 191 | end 192 | 193 | @slots = group_descriptors_by_slots(descriptor.children) 194 | instance.instance_variable_set(:@slots, @slots) 195 | 196 | VAny.run(instance.render) do |vnode| 197 | async { instance.mount } 198 | 199 | receive do |descriptor| 200 | case descriptor 201 | in :rerender! 202 | vnode.resume(instance.render) 203 | in Descriptor 204 | @slots = group_descriptors_by_slots(descriptor.children) 205 | instance.instance_variable_set(:@slots, @slots) 206 | instance.instance_variable_set(:@props, descriptor.props) 207 | vnode.resume(instance.render) 208 | end 209 | end 210 | end 211 | end.wait 212 | end 213 | 214 | def group_descriptors_by_slots(descriptors) 215 | descriptors.group_by do |descriptor| 216 | case descriptor 217 | in Descriptor[slot:] 218 | slot 219 | else 220 | nil 221 | end 222 | end 223 | end 224 | end 225 | 226 | # class VFragment < VNode 227 | # def run(descriptors) 228 | # p(descriptors:) 229 | # with_fragment do |id| 230 | # VChildren.run(id, descriptors) do |children| 231 | # mount_dom_node(id) do 232 | # receive do |descriptors| 233 | # children.resume(descriptors) 234 | # end 235 | # end 236 | # end 237 | # end 238 | # end 239 | # 240 | # def with_fragment(id: generate_id) 241 | # patch(Patches::CreateDocumentFragment[id]) 242 | # yield id 243 | # ensure 244 | # patch(Patches::RemoveNode[id]) 245 | # end 246 | # end 247 | 248 | class VCustomElement < VNode 249 | class VChildSlots < VNode 250 | def run(slots) 251 | children = diff_slots({}, slots) 252 | 253 | receive do |slots| 254 | children = diff_slots(children, slots) 255 | end 256 | ensure 257 | diff_slots(children, {}) 258 | end 259 | 260 | def diff_slots(slots, new_slots) 261 | new_slots.map do |name, descriptor| 262 | if slot = slots.delete(name) 263 | slot.resume(descriptor) 264 | [name, slot] 265 | else 266 | [name, VChildren.start(parent.dom_id, name, descriptor)] 267 | end 268 | end.to_h 269 | ensure 270 | slots.values.flatten.each(&:stop) 271 | end 272 | end 273 | 274 | class VPropRefs < VNode 275 | def run(refs) 276 | refs = diff_refs({}, refs) 277 | 278 | receive do |new_refs| 279 | refs = diff_refs(refs, new_refs) 280 | end 281 | ensure 282 | refs.values.flatten.each(&:stop) 283 | refs.clear 284 | end 285 | 286 | def diff_refs(refs, new_refs) 287 | new_refs.map do |name, props| 288 | if ref = refs.delete(name) 289 | ref.resume(props) 290 | [name, ref] 291 | else 292 | [name, VProps.start(parent.dom_id, name, props)] 293 | end 294 | end.to_h 295 | ensure 296 | refs.values.flatten.each(&:stop) 297 | end 298 | end 299 | 300 | def run(descriptor) 301 | custom_element = descriptor.type 302 | closest(VRoot)&.register_custom_element(custom_element) 303 | 304 | with_element(custom_element.name) do 305 | VChildSlots.run(descriptor.props[:slots] || {}) do |vchild_slots| 306 | VPropRefs.run(descriptor.props[:refs] || {}) do |vprop_refs| 307 | receive do |descriptor| 308 | vprop_refs.resume(descriptor.props[:refs] || {}) 309 | vchild_slots.resume(descriptor.props[:slots] || {}) 310 | end 311 | end 312 | end 313 | end 314 | end 315 | 316 | def with_element(type, id: generate_id) 317 | @dom_id = id 318 | patch(Patches::CreateElement[id, type.to_s.tr("_", "-")]) 319 | 320 | mount_dom_node(id) do 321 | yield 322 | end 323 | ensure 324 | patch(Patches::RemoveNode[id]) 325 | @dom_id = nil 326 | end 327 | end 328 | 329 | class VChildren < VNode 330 | class UpdateOrder < VNode 331 | def run(parent_id, slot_name, children) 332 | order = [] 333 | 334 | receive do |children| 335 | new_order = calculate_order(children) 336 | next if new_order == order 337 | order = new_order 338 | 339 | puts "Updating order for #{parent_id} #{order.size}" if order.size > 1 340 | patch(Patches::AssignSlot[parent_id, slot_name, order]) 341 | end 342 | end 343 | 344 | def calculate_order(children) 345 | children 346 | .sort_by(&:index) 347 | .map(&:dom_id) 348 | .compact 349 | end 350 | end 351 | 352 | class VChild < Base 353 | attr_accessor :index 354 | attr_accessor :hash 355 | 356 | def run(descriptor) 357 | descriptor = receive 358 | 359 | VAny.run(descriptor) do |vnode| 360 | receive do |descriptor| 361 | vnode.resume(descriptor) 362 | end 363 | end 364 | end 365 | 366 | def mount_dom_node(id, &) 367 | if @dom_id 368 | raise "Attempted to mount #{id} into #{self.class.name} already has mounted #{@dom_id}" 369 | end 370 | 371 | begin 372 | @dom_id = id 373 | super(id, &) 374 | ensure 375 | @dom_id = nil 376 | end 377 | end 378 | end 379 | 380 | def reorder! = 381 | @reorder&.signal(true) 382 | 383 | def run(parent_id, name, descriptors) 384 | @reorder = Async::Condition.new 385 | @parent_id = parent_id 386 | @name = name 387 | @semaphore = Async::Semaphore.new(1) 388 | 389 | children = update_children({}, descriptors) 390 | 391 | UpdateOrder.run(parent_id, name, children) do |update_order| 392 | async do 393 | loop do 394 | @semaphore.async do 395 | update_order.resume(children) 396 | end 397 | 398 | @reorder.wait 399 | end 400 | end 401 | 402 | receive do |descriptors| 403 | @semaphore.async do 404 | children = update_children(children, descriptors) 405 | reorder! 406 | end 407 | end 408 | ensure 409 | children.each(&:stop) 410 | children.clear 411 | end 412 | end 413 | 414 | def update_children(children, descriptors) 415 | diff_children( 416 | children, 417 | normalize_descriptors(descriptors), 418 | ) 419 | end 420 | 421 | UPDATE_SLICE_SIZE = 10 422 | 423 | def diff_children(children, descriptors, task: Async::Task.current) 424 | grouped = children.group_by(&:hash) 425 | 426 | new_children = 427 | descriptors 428 | .map.with_index do |descriptor, index| 429 | if found = grouped[Descriptor.get_hash(descriptor)]&.shift 430 | found.index = index 431 | [found, descriptor] 432 | else 433 | child = VChild.new 434 | child.hash = Descriptor.get_hash(descriptor) 435 | child.index = index 436 | child.start(descriptor) 437 | [child, descriptor] 438 | end 439 | end 440 | 441 | task.async do 442 | grouped.values.flatten.each(&:stop) 443 | end 444 | 445 | task.async do |subtask| 446 | new_children.each_slice(UPDATE_SLICE_SIZE) do |slice| 447 | subtask.async do 448 | slice.each do |child, descriptor| 449 | child.resume(descriptor) 450 | end 451 | end.wait 452 | 453 | sleep 0 454 | end 455 | end 456 | 457 | new_children.map(&:first) 458 | end 459 | 460 | def normalize_descriptors(descriptors) 461 | Descriptor.normalize_children(descriptors) 462 | end 463 | 464 | def mount_dom_node(id) 465 | # puts "Mounting #{id} into #{@parent_id}" 466 | patch(Patches::InsertBefore[@parent_id, id, nil]) 467 | reorder! 468 | yield 469 | ensure 470 | patch(Patches::RemoveChild[@parent_id, id]) 471 | reorder! 472 | end 473 | end 474 | 475 | class VProps < VNode 476 | class VAttr < Base 477 | def run(parent_id, ref_id, name, value) 478 | loop do 479 | catch do |value_changed| 480 | update_attribute(parent_id, ref_id, name, value) do 481 | receive do |new_value| 482 | next if new_value == value 483 | value = new_value 484 | throw(value_changed) 485 | end 486 | end 487 | end 488 | end 489 | ensure 490 | patch(Patches::RemoveAttribute[parent_id, ref_id, name]) 491 | end 492 | 493 | def update_attribute(parent_id, ref_id, name, value, &) 494 | if value in S::Reactive 495 | update_reactive(parent_id, ref_id, name, value, &) 496 | else 497 | update_static(parent_id, ref_id, name, value, &) 498 | end 499 | end 500 | 501 | def update_reactive(parent_id, ref_id, name, signal, &) 502 | sub = signal.subscribe(name: "attribute: #{parent_id}.#{ref_id}.#{name}") do |value| 503 | update_static(parent_id, ref_id, name, value) 504 | end 505 | 506 | yield 507 | ensure 508 | sub&.stop 509 | end 510 | 511 | def update_static(parent_id, ref_id, name, value, &) 512 | if value 513 | patch(Patches::SetAttribute[parent_id, ref_id, name, value.to_s]) 514 | else 515 | patch(Patches::RemoveAttribute[parent_id, ref_id, name]) 516 | end 517 | 518 | yield if block_given? 519 | end 520 | end 521 | 522 | class VCallback < Base 523 | Handler = Data.define(:id, :root, :callback) do 524 | def self.[](callback, root: S::Root.current!) = 525 | new(generate_id, root, callback) 526 | 527 | def self.generate_id = 528 | SecureRandom.alphanumeric(32) 529 | 530 | def call(payload) = 531 | root.async do 532 | root.batch do 533 | S.untrack do 534 | callback.call( 535 | **payload.slice( 536 | *extract_kwargs(callback.parameters) 537 | ) 538 | ) 539 | end 540 | end 541 | end 542 | 543 | def extract_kwargs(parameters) = 544 | parameters.map do |param| 545 | if param in [:key | :keyreq, name] 546 | name 547 | end 548 | end.compact 549 | end 550 | 551 | def run(parent_id, ref_id, name, callback) 552 | handler = Handler[callback] 553 | callbacks.store(handler.id, handler) 554 | 555 | patch(Patches::SetHandler[parent_id, ref_id, name, handler.id]) 556 | 557 | receive do |callback| 558 | next if handler.callback == callback 559 | handler = handler.with(callback:) 560 | callbacks.store(handler.id, handler) 561 | end 562 | ensure 563 | callbacks.delete(handler.id) 564 | patch(Patches::RemoveHandler[parent_id, ref_id, name, handler.id]) 565 | end 566 | end 567 | 568 | class VStyles < Base 569 | class VStyle < Base 570 | def run(parent_id, ref_id, name, value) 571 | value = Array(value).join(" ").tr("_", "-") 572 | patch(Patches::SetCSSProperty[parent_id, ref_id, name, value]) 573 | 574 | receive do |new_value| 575 | new_value = Array(new_value).join(" ").tr("_", "-") 576 | next if new_value == value 577 | value = new_value 578 | patch(Patches::SetCSSProperty[parent_id, ref_id, name, value]) 579 | end 580 | ensure 581 | patch(Patches::RemoveCSSProperty[parent_id, ref_id, name]) 582 | end 583 | end 584 | 585 | def run(parent_id, ref_id, name, value) 586 | vstyles = update_styles(parent_id, ref_id, {}, value) 587 | 588 | receive do |value| 589 | vstyles = update_styles(parent_id, ref_id, vstyles, value) 590 | end 591 | ensure 592 | vstyles.each_value(&:stop) 593 | patch(Patches::RemoveAttribute[parent_id, ref_id, name]) 594 | end 595 | 596 | def update_styles(parent_id, ref_id, vstyles, styles) 597 | stopped = vstyles.except(*styles.keys) 598 | stopped.each_value(&:stop) 599 | 600 | styles.map do |name, value| 601 | if old = vstyles[name] 602 | old.resume(value) 603 | [name, old] 604 | else 605 | [name, VStyle.start(parent_id, ref_id, name.to_s.tr("_", "-"), value)] 606 | end 607 | end.to_h 608 | end 609 | end 610 | 611 | def run(parent_id, ref_id, attributes) 612 | vattrs = update_attributes(parent_id, ref_id, {}, attributes) 613 | 614 | receive do |attributes| 615 | vattrs = update_attributes(parent_id, ref_id, vattrs, attributes) 616 | end 617 | end 618 | 619 | def update_attributes(parent_id, ref_id, vattrs, attributes) 620 | stopped = vattrs.except(*attributes.keys) 621 | stopped.each_value(&:stop) 622 | 623 | attributes.map do |name, value| 624 | if old = vattrs[name] 625 | old.resume(value) 626 | [name, old] 627 | else 628 | [name, attr_node_class(name).start(parent_id, ref_id, name.to_s.tr("_", "-"), value)] 629 | end 630 | end.to_h 631 | end 632 | 633 | def attr_node_class(name) 634 | case name 635 | in :style 636 | VStyles 637 | in /\Aon/ 638 | VCallback 639 | else 640 | VAttr 641 | end 642 | end 643 | end 644 | 645 | class VSlot < Base 646 | def run(descriptor) 647 | name = descriptor.props[:name] 648 | 649 | VAny.run(get_slot(name)) do |vnode| 650 | receive do |descriptor| 651 | name = descriptor.props[:name] 652 | vnode.resume(get_slot(name)) 653 | end 654 | end 655 | end 656 | end 657 | 658 | class VReactive < Base 659 | def run(reactive) 660 | VAny.run(Descriptor.normalize_children(reactive.peek)) do |vnode| 661 | sub = reactive.subscribe(name: "VReactive##{object_id}") do |value| 662 | vnode.resume(Descriptor.normalize_children(value)) 663 | end 664 | 665 | receive do |new_reactive| 666 | unless reactive == new_reactive 667 | raise "Reactive changed!" 668 | end 669 | end 670 | ensure 671 | sub&.stop 672 | end 673 | end 674 | end 675 | 676 | class VAny < Base 677 | def run(descriptor) 678 | loop do 679 | catch do |type_changed| 680 | descriptor = unwrap(descriptor) 681 | type = descriptor_to_node_type(descriptor) 682 | 683 | type.run(descriptor) do |vnode| 684 | receive do |new_descriptor| 685 | new_descriptor = unwrap(new_descriptor) 686 | 687 | unless Descriptor.same?(descriptor, new_descriptor) 688 | throw(type_changed) 689 | end 690 | 691 | vnode.resume(descriptor = new_descriptor) 692 | end 693 | end 694 | end 695 | end 696 | end 697 | 698 | def unwrap(descriptor) 699 | case Array(descriptor).compact.flatten 700 | in [] then "" 701 | in [one] then one 702 | in [*many] then many 703 | end 704 | end 705 | 706 | def descriptor_to_node_type(descriptor) 707 | case descriptor 708 | in S::Reactive 709 | VReactive 710 | in Array 711 | VFragment 712 | in Descriptor[type: CustomElement] 713 | VCustomElement 714 | in Descriptor[type: Class] 715 | VComponent 716 | in Descriptor[type: :slot] 717 | VSlot 718 | in Descriptor[type: Symbol] 719 | p descriptor 720 | VElement 721 | else 722 | VText 723 | end 724 | end 725 | end 726 | 727 | class VRoot < Base 728 | def initialize(task: Async::Task.current) 729 | @barrier = Async::Barrier.new(parent: task) 730 | @patches = Async::Queue.new 731 | @callbacks = {} 732 | @sent_assets = Set.new 733 | super() 734 | end 735 | 736 | attr_reader :callbacks 737 | 738 | def register_custom_element(custom_element) 739 | Assets.instance.store(custom_element.asset) 740 | if stylesheet = custom_element.stylesheet 741 | Assets.instance.store(custom_element.stylesheet.asset) 742 | end 743 | patch(Patches::DefineCustomElement[ 744 | custom_element.name, 745 | custom_element.asset.filename, 746 | ]) if @sent_assets.add?(custom_element) 747 | end 748 | 749 | def patch(patch) = 750 | @patches.enqueue(patch) 751 | def take = 752 | @patches.dequeue 753 | def handle_callback(id, payload) = 754 | @callbacks.fetch(id).call(payload) 755 | 756 | def mount_dom_node(id) 757 | patch(Patches::InsertBefore[nil, id, nil]) 758 | yield 759 | ensure 760 | patch(Patches::RemoveChild[nil, id]) 761 | end 762 | 763 | def run(children = nil) 764 | patch(Patches::CreateRoot[]) 765 | 766 | VChildren.run(nil, "children", children) do |vslotted| 767 | receive do |children| 768 | vslotted.resume(children) 769 | end 770 | end 771 | ensure 772 | patch(Patches::DestroyRoot[]) 773 | end 774 | end 775 | end 776 | end 777 | -------------------------------------------------------------------------------- /lib/vdom/patches.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module VDOM 4 | module Patches 5 | CreateRoot = Data.define() 6 | DestroyRoot = Data.define() 7 | 8 | CreateElement = Data.define(:id, :type) 9 | CreateDocumentFragment = Data.define(:id) 10 | CreateTextNode = Data.define(:id, :content) 11 | CreateCommentNode = Data.define(:id, :content) 12 | 13 | InsertBefore = Data.define(:parent_id, :id, :ref_id) 14 | RemoveChild = Data.define(:parent_id, :id) 15 | RemoveNode = Data.define(:id) 16 | 17 | DefineCustomElement = Data.define(:name, :filename) 18 | AssignSlot = Data.define(:parent_id, :name, :node_ids) 19 | 20 | CreateChildren = Data.define(:parent_id, :slot_id) 21 | RemoveChildren = Data.define(:slot_id) 22 | ReorderChildren = Data.define(:slot_id, :child_ids) 23 | 24 | SetAttribute = Data.define(:parent_id, :ref_id, :name, :value) 25 | RemoveAttribute = Data.define(:parent_id, :ref_id, :name) 26 | 27 | SetHandler = Data.define(:parent_id, :ref_id, :name, :handler_id) 28 | RemoveHandler = Data.define(:parent_id, :ref_id, :name, :handler_id) 29 | 30 | SetCSSProperty = Data.define(:parent_id, :ref_id, :name, :value) 31 | RemoveCSSProperty = Data.define(:parent_id, :ref_id, :name) 32 | 33 | SetTextContent = Data.define(:id, :content) 34 | ReplaceData = Data.define(:id, :offset, :count, :data) 35 | InsertData = Data.define(:id, :offset, :data) 36 | DeleteData = Data.define(:id, :offset, :count) 37 | 38 | Ping = Data.define(:time) 39 | 40 | Event = Data.define(:event, :payload) 41 | 42 | def self.serialize(patch) 43 | [patch.class.name[/[^:]+\z/], *patch.deconstruct] 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/vdom/s.demo.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require_relative "s" 3 | 4 | Async do 5 | S.root do 6 | a = S.signal(0) 7 | b = S.signal(0) 8 | 9 | c = S.computed do 10 | p(a.value + b.value) 11 | end 12 | 13 | d = S.computed do 14 | if a.value == 2 15 | p(b2: b.value * 2) 16 | else 17 | p(a2: a.value * 2) 18 | end 19 | end 20 | 21 | e = S.effect do 22 | p(e: c.value) 23 | end 24 | 25 | f = S.effect do 26 | p(f: d.value) 27 | end 28 | 29 | puts 30 | sleep 0.1 31 | puts 32 | puts "**** INCREMENTING A" 33 | a.value += 1 34 | puts "**** INCREMENTED A" 35 | sleep 0.1 36 | puts 37 | 38 | puts "**** INCREMENTING B" 39 | b.value += 1 40 | puts "**** INCREMENTED B" 41 | sleep 0.1 42 | puts 43 | 44 | puts "**** INCREMENTING A" 45 | a.value += 1 46 | puts "**** INCREMENTED A" 47 | sleep 0.1 48 | puts 49 | 50 | puts "**** INCREMENTING B" 51 | b.value += 1 52 | puts "**** INCREMENTED B" 53 | sleep 0.1 54 | puts 55 | 56 | puts "**** INCREMENTING B" 57 | b.value += 1 58 | puts "**** INCREMENTED B" 59 | sleep 0.1 60 | puts 61 | 62 | puts "**** INCREMENTING A AND B" 63 | S.batch do 64 | a.value += 1 65 | b.value += 1 66 | end 67 | puts "**** INCREMENTED A AND B" 68 | sleep 0.1 69 | puts 70 | 71 | puts "**** INCREMENTING B" 72 | b.value += 1 73 | puts "**** INCREMENTED B" 74 | sleep 0.1 75 | puts 76 | 77 | Async::Task.current.stop 78 | end 79 | 80 | def assert_equal(a, b) 81 | unless a == b 82 | raise "#{a.inspect} does not equal #{b.inspect}" 83 | end 84 | end 85 | 86 | S.root do 87 | a = S.signal("a") 88 | called_times = 0 89 | 90 | b = 91 | S.computed do 92 | a.value 93 | "foo" 94 | end 95 | 96 | c = S.computed do 97 | puts "CALCULATING C" 98 | called_times += 1 99 | b.value 100 | end 101 | 102 | assert_equal("foo", c.value) 103 | sleep 0.1 104 | assert_equal(1, called_times) 105 | 106 | a.value = "aa" 107 | sleep 0.1 108 | assert_equal("foo", c.value) 109 | assert_equal(1, called_times) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/vdom/s.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Copyright Andreas Alin 4 | # Released under AGPL-3.0 5 | 6 | require "async" 7 | require "async/barrier" 8 | require "async/condition" 9 | require "async/queue" 10 | 11 | module S 12 | class CycleDetectedError < StandardError 13 | end 14 | 15 | module AsyncRefinements 16 | refine Async::Condition do 17 | def size = @waiting.size 18 | end 19 | end 20 | 21 | using AsyncRefinements 22 | 23 | module Utils 24 | def self.with_fiber_local(name, value) 25 | prev = Fiber[name] 26 | Fiber[name] = value 27 | yield value 28 | ensure 29 | Fiber[name] = prev 30 | end 31 | end 32 | 33 | module States 34 | State = Data.define(:to_i, :to_s) do 35 | include Comparable 36 | def <=>(other) = to_i <=> other.to_i 37 | end 38 | 39 | Clean = State[0, "🟢"] 40 | Check = State[1, "🟡"] 41 | Dirty = State[2, "🔴"] 42 | end 43 | 44 | class Reactive 45 | CURRENT_KEY = :S_Reactive_current 46 | TRACKING_KEY = :S_Reactive_tracking? 47 | 48 | def self.tracking? = 49 | Fiber[TRACKING_KEY] != false 50 | def self.track(&) = 51 | Utils.with_fiber_local(TRACKING_KEY, true, &) 52 | def self.untrack(&) = 53 | Utils.with_fiber_local(TRACKING_KEY, false, &) 54 | 55 | def self.current = 56 | Fiber[CURRENT_KEY] 57 | def self.current_tracking = 58 | (current if tracking?) 59 | 60 | def initialize(name: nil) 61 | @name = name 62 | @condition = Async::Condition.new 63 | end 64 | 65 | attr_reader :name 66 | 67 | def to_s = 68 | @value.to_s 69 | 70 | def inspect = 71 | [ 72 | self.class.name, 73 | "name=#{@name.inspect}", 74 | "subscribers=#{@condition.size}", 75 | "value=#{@value.inspect}", 76 | @state 77 | ].join(" ").prepend("#<").concat(">") 78 | 79 | def wait = 80 | @condition.wait 81 | def empty? = 82 | @condition.empty? 83 | 84 | def clean? = 85 | @state == States::Clean 86 | def check? = 87 | @state == States::Check 88 | def dirty? = 89 | @state == States::Dirty 90 | 91 | def subscribe(name: caller.first, &) = 92 | S.effect(name:) do 93 | value = self.value 94 | 95 | Reactive.untrack do 96 | yield value 97 | end 98 | end 99 | 100 | def value 101 | Reactive.current_tracking&.add_source(self) 102 | peek 103 | end 104 | 105 | def peek 106 | update! 107 | @value 108 | end 109 | 110 | protected 111 | 112 | def stop_if_empty! = 113 | nil 114 | 115 | def value=(value) 116 | S.batch do 117 | @value = value 118 | notify!(States::Dirty) 119 | end unless @value == value 120 | end 121 | 122 | def update! = 123 | nil 124 | 125 | def notify!(state) = 126 | @condition.signal(state) 127 | 128 | def mark!(state) = 129 | unless @state == state 130 | @state = state 131 | end 132 | end 133 | 134 | class Signal < Reactive 135 | def initialize(value, name:) 136 | super(name:) 137 | @state = States::Clean 138 | @value = value 139 | end 140 | 141 | public :value= 142 | end 143 | 144 | class Computed < Reactive 145 | Disposed = Data.define do 146 | def self.inspect = "❌" 147 | end 148 | 149 | def initialize(name: compute.source_location.join(":"), task: Async::Task.current, &compute) 150 | super(name:) 151 | @root = Root.current! 152 | @compute = compute 153 | @sources = {} 154 | @state = States::Dirty 155 | @barrier = Async::Barrier.new(parent: task) 156 | end 157 | 158 | def stop 159 | cleanup! 160 | ensure 161 | dispose! 162 | @barrier.stop 163 | @sources.each_key(&:stop_if_empty!).clear 164 | end 165 | 166 | def disposed? = 167 | @compute == Disposed 168 | 169 | def add_source(source) = 170 | unless self == source 171 | @sources[source] ||= create_listener(source) 172 | end 173 | 174 | protected 175 | 176 | def stop_if_empty! = 177 | (stop if empty?) 178 | 179 | def create_listener(source) = 180 | @barrier.async do |subtask| 181 | loop do 182 | state = source.wait 183 | next unless @state < state 184 | enqueue_effect if clean? 185 | mark!(state) 186 | notify!(States::Check) 187 | end 188 | ensure 189 | @sources.delete_if { _1 == source && _2 == subtask } 190 | source.stop_if_empty! 191 | end 192 | 193 | def update! 194 | until clean? or disposed? 195 | wait_for_sources if check? 196 | 197 | if dirty? 198 | self.value = call 199 | end 200 | 201 | mark!(States::Clean) 202 | notify!(States::Clean) 203 | end 204 | end 205 | 206 | def wait_for_sources = 207 | @sources.each_key do |source| 208 | source.peek 209 | break if dirty? 210 | rescue 211 | nil 212 | end 213 | 214 | def call = 215 | update_sources do 216 | S.batch do 217 | Async do 218 | Fiber[CURRENT_KEY] = self 219 | Fiber[TRACKING_KEY] = true 220 | @compute.call 221 | end.wait 222 | end 223 | end 224 | 225 | def update_sources = 226 | @sources.values.then do |old_listeners| 227 | # start_cleanup_task 228 | @sources.clear 229 | cleanup! 230 | yield unless disposed? 231 | ensure 232 | old_listeners.each(&:stop) 233 | end 234 | 235 | def cleanup! = 236 | if @value in Proc => cleanup 237 | @value = nil 238 | 239 | S.batch do 240 | Reactive.untrack do 241 | cleanup.call 242 | end 243 | rescue => e 244 | Console.logger.error(self, e) 245 | stop 246 | raise 247 | end 248 | end 249 | 250 | def dispose! 251 | @value = nil 252 | mark!(States::Dirty) 253 | end 254 | 255 | def enqueue_effect = 256 | nil 257 | end 258 | 259 | class Effect < Computed 260 | def initialize(...) 261 | super(...) 262 | update! 263 | end 264 | 265 | protected 266 | 267 | def stop_if_empty! = 268 | nil 269 | 270 | def dispose! 271 | @compute = @value = Disposed 272 | mark!(States::Clean) 273 | end 274 | 275 | def enqueue_effect = 276 | @root&.enqueue(self) 277 | end 278 | 279 | class Root 280 | CYCLE_LIMIT = 50 281 | CURRENT_KEY = :S_Root_current 282 | 283 | def inspect 284 | "#<#{self.class.name}##{object_id} #{@name} empty?=#{@barrier.empty?} #{@barrier.tasks.size}>" 285 | end 286 | 287 | def self.current = 288 | Fiber[CURRENT_KEY] 289 | def self.current! = 290 | current || raise("No root!") 291 | 292 | def self.with(root, &) = 293 | Utils.with_fiber_local(CURRENT_KEY, root, &) 294 | 295 | def self.run(name: caller.first, task: Async::Task.current, &) = 296 | task.async do 297 | root = new(name:, task: _1) 298 | puts "\e[32mStarted root #{root.object_id}\e[0m" 299 | Fiber[CURRENT_KEY] = root 300 | yield 301 | ensure 302 | root.stop 303 | puts "\e[31mStopped root #{root&.object_id}\e[0m" 304 | end.wait 305 | 306 | def initialize(name:, task: Async::Task.current) 307 | @name = name 308 | @barrier = Async::Barrier.new(parent: task) 309 | @queue = Async::Queue.new(parent: @barrier) 310 | @level = 0 311 | end 312 | 313 | def running? = 314 | !@barrier.empty? 315 | 316 | def async(&) = 317 | @barrier.async(&) 318 | 319 | def stop 320 | @queue.dequeue until @queue.empty? 321 | @queue = nil 322 | @barrier.stop 323 | end 324 | 325 | def enqueue(effect) = 326 | @queue.enqueue(effect) 327 | 328 | def batch(&) = 329 | self.class.with(self) do 330 | cycle do |level| 331 | yield self 332 | ensure 333 | flush! if level == 1 334 | end 335 | end 336 | 337 | protected 338 | 339 | def flush! = 340 | catch_error do 341 | until @queue.empty? 342 | @queue.dequeue.peek 343 | end 344 | end 345 | 346 | def cycle 347 | @level += 1 348 | 349 | if @level > CYCLE_LIMIT 350 | raise CycleDetectedError 351 | end 352 | 353 | yield @level 354 | ensure 355 | @level -= 1 356 | end 357 | 358 | def catch_error(error = nil) 359 | yield 360 | rescue => e 361 | error ||= e 362 | Console.logger.error(self, e) 363 | retry 364 | ensure 365 | raise error if error 366 | end 367 | end 368 | 369 | class Exporter 370 | module Formats 371 | module Mermaid 372 | def init = 373 | "flowchart LR" 374 | 375 | def label(node) = 376 | " #{node_id(node)}[#{escape(node.inspect)}]" 377 | def link(source, target) = 378 | " #{node_id(source)} --> #{node_id(target)}" 379 | def link_dotted(source, target) = 380 | " #{node_id(source)} -.-> #{node_id(target)}" 381 | 382 | def escape(str) = 383 | str 384 | .gsub("#", "#35;") 385 | .gsub('"', "#34;") 386 | .gsub("<", "#lt;") 387 | .gsub(">", "#gt;") 388 | .inspect 389 | end 390 | 391 | module D2 392 | def init = 393 | nil 394 | def label(node) = 395 | "#{node_id(node)}: #{node.inspect.inspect}" 396 | def link(source, target) = 397 | "#{node_id(source)} -> #{node_id(target)}" 398 | def link_dotted(source, target) = 399 | "#{node_id(source)} -> #{node_id(target)}" 400 | end 401 | end 402 | 403 | def self.export(format) = 404 | StringIO.new.tap do |out| 405 | new(out, format).tap do |exporter| 406 | if init = exporter.init 407 | out.puts(init) 408 | end 409 | ObjectSpace.each_object(Root) { exporter.visit(_1) } 410 | ObjectSpace.each_object(Reactive) { exporter.visit(_1) } 411 | end 412 | out.rewind 413 | end.read 414 | 415 | def initialize(out, format) 416 | extend format 417 | 418 | @out = out 419 | @visited = Set.new 420 | end 421 | 422 | def visit(node) = 423 | if @visited.add?(node) 424 | @out.puts(label(node)) 425 | 426 | case node 427 | in Root 428 | in Signal 429 | in Computed 430 | if root = node.instance_variable_get(:@root) 431 | visit(root) 432 | @out.puts(link_dotted(node, root)) 433 | end 434 | node.instance_variable_get(:@sources).each_key do |source| 435 | visit(source) 436 | @out.puts(link(source, node)) 437 | end 438 | end 439 | end 440 | 441 | private 442 | 443 | def node_id(node) = 444 | "#{node.class.name.split("::").last.downcase}#{node.object_id}" 445 | end 446 | 447 | module Helpers 448 | def root(name: caller.first, task: Async::Task.current, &) = 449 | Root.run(name:, task:, &) 450 | 451 | def batch(&) = Root.current!.batch(&) 452 | 453 | def signal(value, name: caller.first) = Signal.new(value, name:) 454 | def computed(name: caller.first, &) = Computed.new(name:, &) 455 | def effect(name: caller.first, &) = Effect.new(name:, &) 456 | 457 | def tracking? = Reactive.tracking? 458 | def track(&) = Reactive.track(&) 459 | def untrack(&) = Reactive.untrack(&) 460 | end 461 | 462 | module Refinements 463 | refine Kernel do 464 | import_methods Helpers 465 | end 466 | end 467 | 468 | extend Helpers 469 | end 470 | -------------------------------------------------------------------------------- /lib/vdom/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "async" 4 | require "async/queue" 5 | require "async/http/endpoint" 6 | require "async/http/protocol/response" 7 | require "async/http/server" 8 | require_relative "assets" 9 | 10 | module VDOM 11 | class Server 12 | class Session 13 | attr_reader :id 14 | 15 | def initialize 16 | @id = SecureRandom.alphanumeric(32) 17 | @input = Async::Queue.new 18 | @output = Async::Queue.new 19 | @stop = Async::Condition.new 20 | end 21 | 22 | def take = 23 | @output.dequeue 24 | 25 | def callback(id, payload) = 26 | @input.enqueue([:callback, id, payload]) 27 | def pong(time) = 28 | @input.enqueue([:pong, time]) 29 | 30 | def run(component, task: Async::Task.current) 31 | VDOM.run do |vroot| 32 | task.async { input_loop(vroot) } 33 | task.async { ping_loop } 34 | task.async { patch_loop(vroot) } 35 | 36 | vroot.resume(VDOM::Descriptor[component]) 37 | 38 | @stop.wait 39 | ensure 40 | vroot&.stop 41 | end 42 | end 43 | 44 | private 45 | 46 | def input_loop(vroot, task: Async::Task.current) 47 | loop do 48 | msg = @input.dequeue 49 | task.async { handle_input(vroot, msg) } 50 | rescue Protocol::HTTP2::ProtocolError, EOFError => e 51 | Console.logger.error(self, e) 52 | raise 53 | rescue => e 54 | Console.logger.error(self, e) 55 | end 56 | end 57 | 58 | def handle_input(vroot, message) 59 | case message 60 | in :callback, callback_id, payload 61 | vroot.handle_callback(callback_id, payload) 62 | in :pong, time 63 | pong = current_ping_time - time 64 | puts format("Ping: %.2fms", pong) 65 | in unhandled 66 | puts "\e[31mUnhandled: #{unhandled.inspect}\e[0m" 67 | end 68 | rescue => e 69 | Console.logger.error(self, e) 70 | end 71 | 72 | def current_ping_time = 73 | Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) 74 | 75 | def ping_loop 76 | loop do 77 | sleep 5 78 | @output.enqueue( 79 | VDOM::Patches.serialize(VDOM::Patches::Ping[current_ping_time]) 80 | ) 81 | end 82 | end 83 | 84 | def patch_loop(vroot) 85 | while patch = vroot.take 86 | @output.enqueue(VDOM::Patches.serialize(patch)) 87 | # Uncomment the following line to add some latency 88 | # sleep 0.0005 89 | end 90 | rescue IOError, Errno::EPIPE, Protocol::HTTP2::ProtocolError => e 91 | puts "\e[31m#{e.message}\e[0m" 92 | ensure 93 | @stop.signal 94 | end 95 | end 96 | 97 | module RequestRefinements 98 | refine Async::HTTP::Protocol::HTTP2::Request do 99 | def deconstruct_keys(keys) 100 | keys.each_with_object({}) do |key, obj| 101 | var = "@#{key}" 102 | 103 | if instance_variable_defined?(var) 104 | obj[key] = instance_variable_get(var) 105 | end 106 | end 107 | end 108 | end 109 | end 110 | 111 | class App 112 | using RequestRefinements 113 | 114 | SESSION_ID_HEADER_NAME = "x-rdom-session-id" 115 | 116 | ALLOW_HEADERS = Ractor.make_shareable({ 117 | "access-control-allow-methods" => "GET, POST, OPTIONS", 118 | "access-control-allow-headers" => [ 119 | "content-type", 120 | "accept", 121 | "accept-encoding", 122 | SESSION_ID_HEADER_NAME, 123 | ].join(", ") 124 | }) 125 | 126 | ASSET_CACHE_CONTROL = [ 127 | "public", 128 | "max-age=#{7 * 24 * 60 * 60}", 129 | "immutable", 130 | ].join(", ").freeze 131 | 132 | def initialize(component:, public_path:) 133 | @component = component 134 | @public_path = public_path 135 | @sessions = {} 136 | @file_cache = {} 137 | end 138 | 139 | def call(request, task: Async::Task.current) 140 | Console.logger.info( 141 | "#{request.method} #{request.path}", 142 | ) 143 | 144 | case request 145 | in path: "/" 146 | handle_index(request) 147 | in path: "/favicon.ico" 148 | handle_favicon(request) 149 | in path: "/rdom.js" 150 | handle_script(request) 151 | in path: "/.rdom", method: "OPTIONS" 152 | handle_options(request) 153 | in path: "/.rdom", method: "GET" 154 | handle_rdom_get(request) 155 | in path: "/.rdom", method: "POST" 156 | handle_rdom_post(request) 157 | in path: %r{\A/\.rdom/(.+)\z}, method: "GET" 158 | handle_rdom_asset(request) 159 | else 160 | handle_404(request) 161 | end 162 | end 163 | 164 | def handle_index(_) = 165 | send_file("index.html", "text/html; charset=utf-8") 166 | def handle_favicon(_) = 167 | send_file("favicon.png", "image/png") 168 | def handle_script(request) = 169 | send_file("rdom.js", "application/javascript; charset=utf-8", origin_header(request)) 170 | 171 | def handle_404(request) 172 | Console.logger.error(self, "File not found at #{request.path.inspect}") 173 | 174 | Protocol::HTTP::Response[ 175 | 404, 176 | { "content-type" => "text/plain; charset-utf-8" }, 177 | ["File not found at #{request.path}"] 178 | ] 179 | end 180 | 181 | def handle_rdom_asset(request) 182 | asset = 183 | Assets.instance.fetch(File.basename(request.path)) do 184 | return handle_404(request) 185 | end 186 | 187 | Protocol::HTTP::Response[ 188 | 200, 189 | { 190 | "content-type" => asset.content.type, 191 | "content-encoding" => asset.content.encoding, 192 | "cache-control" => ASSET_CACHE_CONTROL, 193 | **origin_header(request), 194 | }, 195 | [asset.content.to_s] 196 | ] 197 | end 198 | 199 | def handle_options(request) 200 | headers = { 201 | **ALLOW_HEADERS, 202 | **origin_header(request), 203 | } 204 | 205 | Protocol::HTTP::Response[204, headers, []] 206 | end 207 | 208 | def handle_rdom_get(request, task: Async::Task.current) 209 | body = Async::HTTP::Body::Writable.new 210 | 211 | session = Session.new 212 | 213 | task.async do |subtask| 214 | @sessions.store(session.id, session) 215 | 216 | subtask.async do 217 | while msg = session.take 218 | body.write(JSON.generate(msg) + "\n") 219 | end 220 | end 221 | 222 | session.run(@component) 223 | ensure 224 | @sessions.delete(session.id) 225 | end 226 | 227 | Protocol::HTTP::Response[ 228 | 200, 229 | { 230 | "content-type" => "x-rdom/json-stream", 231 | SESSION_ID_HEADER_NAME => session.id, 232 | "access-control-expose-headers" => SESSION_ID_HEADER_NAME, 233 | **origin_header(request), 234 | }, 235 | body 236 | ] 237 | end 238 | 239 | def handle_rdom_post(request) 240 | session_id = request.headers[SESSION_ID_HEADER_NAME].to_s 241 | 242 | session = @sessions.fetch(session_id) do 243 | Console.logger.error(self, "Could not find session #{session_id.inspect}") 244 | 245 | return Protocol::HTTP::Response[ 246 | 401, 247 | origin_header(request), 248 | ["Could not find session #{session_id.inspect}"] 249 | ] 250 | end 251 | 252 | each_message(request.body) do |message| 253 | case message 254 | in "callback", String => callback_id, payload 255 | session.callback(callback_id, payload) 256 | in "pong", Numeric => time 257 | session.pong(time) 258 | end 259 | rescue => e 260 | Console.logger.error(e) 261 | end 262 | 263 | Protocol::HTTP::Response[204, origin_header(request), []] 264 | end 265 | 266 | def each_message(body) 267 | buf = String.new 268 | 269 | body.each do |chunk| 270 | buf += chunk 271 | 272 | if idx = buf.index("\n") 273 | yield JSON.parse(buf[0..idx], symbolize_names: true) 274 | buf = buf[idx.succ..-1].to_s 275 | end 276 | end 277 | end 278 | 279 | def origin_header(request) = 280 | { "access-control-allow-origin" => request.headers["origin"] } 281 | 282 | def send_file(filename, content_type, headers = {}) 283 | content = read_public_file(filename) 284 | 285 | Protocol::HTTP::Response[ 286 | 200, 287 | { 288 | "content-type" => content_type, 289 | "content-length" => content.bytesize, 290 | **headers 291 | }, 292 | [content] 293 | ] 294 | end 295 | 296 | def read_public_file(filename) 297 | path = 298 | filename 299 | .then { File.expand_path(_1, "/") } 300 | .then { File.join(@public_path, _1) } 301 | @file_cache[path] ||= File.read(path) 302 | end 303 | end 304 | 305 | def initialize(bind:, localhost:, component:, public_path:) 306 | @uri = URI.parse(bind) 307 | @app = App.new(component:, public_path:) 308 | 309 | endpoint = Async::HTTP::Endpoint.new(@uri) 310 | 311 | if localhost 312 | endpoint = apply_local_certificate(endpoint) 313 | end 314 | 315 | @server = Async::HTTP::Server.new( 316 | @app, 317 | endpoint, 318 | scheme: @uri.scheme, 319 | protocol: Async::HTTP::Protocol::HTTP2, 320 | ) 321 | end 322 | 323 | def run(task: Async::Task.current) 324 | task.async do 325 | puts "\e[3m Starting server on #{@uri} \e[0m" 326 | 327 | @server.run.each(&:wait) 328 | ensure 329 | puts "\n\r\e[3;31m Stopped server \e[0m" 330 | end 331 | end 332 | 333 | private 334 | 335 | def apply_local_certificate(endpoint) 336 | require "localhost" 337 | require "async/io/ssl_endpoint" 338 | 339 | authority = Localhost::Authority.fetch(endpoint.hostname) 340 | 341 | context = authority.server_context 342 | context.alpn_select_cb = ->(protocols) do 343 | protocols.include?("h2") ? "h2" : nil 344 | end 345 | 346 | context.alpn_protocols = ["h2"] 347 | context.session_id_context = "rdom" 348 | 349 | Async::IO::SSLEndpoint.new(endpoint, ssl_context: context) 350 | end 351 | end 352 | end 353 | -------------------------------------------------------------------------------- /lib/vdom/style_sheet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mime-types" 4 | 5 | module VDOM 6 | StyleSheet = Data.define(:asset) do 7 | def self.mime_type = 8 | MIME::Types["text/css"].first 9 | 10 | def self.[](content) = 11 | new(Assets::Asset[content, mime_type]) 12 | 13 | def import_html = 14 | %{} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/vdom/text_diff.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -rbundler/setup -rpry 2 | # frozen_string_literal: true 3 | 4 | require "diff/lcs" 5 | require_relative "patches" 6 | 7 | module VDOM 8 | class TextDiff 9 | DOCUMENT_ENCODING = Encoding::UTF_16LE 10 | INTERNAL_ENCODING = Encoding::UTF_8 11 | # https://docs.ruby-lang.org/en/master/packed_data_rdoc.html#label-16-Bit+Integer+Directives 12 | PACKING = "v*" 13 | 14 | # This method is inspired by Diff::LCS.patch() 15 | def self.diff(node_id, str1, str2, &) 16 | str1 = str1.encode(DOCUMENT_ENCODING) 17 | str2 = str2.encode(DOCUMENT_ENCODING) 18 | seq1 = str1.unpack(PACKING) 19 | seq2 = str2.unpack(PACKING) 20 | 21 | ai = 0 22 | bj = 0 23 | 24 | Diff::LCS.diff(seq1, seq2).each do |changeset| 25 | ais = ai 26 | bjs = bj 27 | 28 | changeset.each do |change| 29 | case 30 | when change.deleting? 31 | delta = change.position - ai 32 | ai += delta.succ 33 | bj += delta 34 | when change.adding? 35 | delta = change.position - bj 36 | bj += delta.succ 37 | ai += delta 38 | end 39 | end 40 | 41 | adding = changeset.select(&:adding?) 42 | 43 | if adding.empty? 44 | start = bj 45 | ax = ai - ais 46 | bx = bj - bjs 47 | 48 | next yield Patches::DeleteData[ 49 | node_id, 50 | bj, 51 | ax - bx 52 | ] 53 | end 54 | 55 | deleting = changeset.select(&:deleting?) 56 | 57 | replacement = adding 58 | .map(&:element) 59 | .flatten 60 | .pack(PACKING, buffer: String.new(encoding: DOCUMENT_ENCODING)) 61 | .encode(INTERNAL_ENCODING) 62 | 63 | if deleting.empty? 64 | next yield Patches::InsertData[ 65 | node_id, 66 | bjs + ai - ais, 67 | replacement 68 | ] 69 | end 70 | 71 | yield Patches::ReplaceData[ 72 | node_id, 73 | adding.first.position, 74 | deleting.size, 75 | replacement 76 | ] 77 | end 78 | 79 | str2 80 | rescue Encoding::InvalidByteSequenceError => e 81 | Console.logger.error(self, "Handled #{e.inspect}") 82 | 83 | yield Patches::SetTextContent[ 84 | node_id, 85 | str2.encode(INTERNAL_ENCODING) 86 | ] 87 | 88 | str2 89 | end 90 | end 91 | end 92 | 93 | if __FILE__ == $0 94 | require "minitest/autorun" 95 | 96 | class VDOM::TextDiff::Test < Minitest::Test 97 | def test_diff 98 | assert_diffed("foobar", "") 99 | assert_diffed("", "foobar") 100 | assert_diffed("foo", "foobar") 101 | assert_diffed("foobar", "foo") 102 | assert_diffed("foobaz", "foobarbaz") 103 | assert_diffed("foobaz", "foobarbaz") 104 | assert_diffed("tjosannnnn", "tjohejannnn") 105 | end 106 | 107 | def test_diff_random 108 | 10.times do 109 | words = 110 | File.join(__dir__, "..", "..", "app", "words.txt") 111 | .then { File.read(_1) } 112 | .split.shuffle 113 | .first(20) 114 | .map(&:strip) + [""] 115 | 116 | words.reduce("") do |seq1, seq2| 117 | assert_diffed(seq1, seq2) 118 | end 119 | end 120 | end 121 | 122 | def assert_diffed(seq1, seq2) 123 | actual = diff_and_patch(seq1, seq2) 124 | assert_equal(seq2, actual) 125 | actual 126 | end 127 | 128 | def diff_and_patch(seq1, seq2) 129 | result = seq1.dup 130 | 131 | VDOM::TextDiff.diff(nil, seq1, seq2) do |patch| 132 | case patch 133 | in VDOM::Patches::InsertData[offset:, data:] 134 | result.insert(offset, data) 135 | in VDOM::Patches::ReplaceData[offset:, count:, data:] 136 | result[offset...(offset + count)] = data 137 | in VDOM::Patches::DeleteData[offset:, count:] 138 | result[offset...(offset + count)] = "" 139 | end 140 | end 141 | 142 | result 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/vdom/transform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "syntax_tree" 4 | require_relative "mutation_visitor" 5 | require_relative "xml_utils" 6 | 7 | module VDOM 8 | class Transform 9 | class FrozenStringLiteralsVisitor < SyntaxTree::Visitor 10 | def visit_program(node) 11 | node.copy(statements: visit(node.statements)) 12 | end 13 | 14 | def visit_statements(node) 15 | node.copy(body: [ 16 | SyntaxTree::Comment.new( 17 | value: "# frozen_string_literal: true", 18 | inline: false, 19 | location: node.location 20 | ), 21 | *node.body, 22 | ]) 23 | end 24 | end 25 | 26 | include SyntaxTree::DSL 27 | 28 | COLLECTIONS = { 29 | SyntaxTree::IVar => "state", 30 | SyntaxTree::GVar => "props", 31 | } 32 | 33 | def self.transform(source) 34 | transformer = new 35 | SyntaxTree.parse(source) 36 | .accept(transformer.state_and_props) 37 | .accept(transformer.heredoc_html) 38 | .then { transformer.wrap_in_class(_1) } 39 | .accept(transformer.frozen_strings) 40 | .then { SyntaxTree::Formatter.format(source, _1) } 41 | end 42 | 43 | def frozen_strings = FrozenStringLiteralsVisitor.new 44 | 45 | def wrap_in_class(program) 46 | statements = 47 | Statements([ 48 | ClassDeclaration( 49 | ConstPathRef( 50 | VarRef(Ident("self")), 51 | Const("Export") 52 | ), 53 | ConstPathRef( 54 | VarRef(Const("VDOM")), 55 | ConstPathRef( 56 | VarRef(Const("Component")), 57 | Const("Base") 58 | ) 59 | ), 60 | BodyStmt(program.statements, nil, nil, nil, nil), 61 | ) 62 | ]) 63 | program.copy(statements:) 64 | end 65 | 66 | def state_and_props 67 | MutationVisitor.new.tap do |visitor| 68 | visitor.mutate("VarRef[value: IVar | GVar]") do |node| 69 | aref(node.value) 70 | end 71 | 72 | visitor.mutate("Assign[target: VarField[value: GVar]]") do |assign| 73 | loc = assign.target.location 74 | raise "Can not write to props on line #{loc.start_line} col #{loc.start_column}" 75 | end 76 | 77 | visitor.mutate("OpAssign[target: VarField[value: IVar]]") do |assign| 78 | update(assign.copy(target: aref_field(assign.target.value))) 79 | end 80 | 81 | visitor.mutate("Assign[target: VarField[value: IVar]]") do |assign| 82 | update(assign.copy(target: aref_field(assign.target.value))) 83 | end 84 | end 85 | end 86 | 87 | def heredoc_html 88 | MutationVisitor.new.tap do |visitor| 89 | visitor.mutate("XStringLiteral | Heredoc[beginning: HeredocBeg[value: '<<~HTML']]") do |node| 90 | tokenizer = XMLUtils::Tokenizer.new 91 | 92 | node.parts.flat_map do |child| 93 | case child 94 | in SyntaxTree::TStringContent 95 | tokenizer.tokenize(child.value) 96 | in SyntaxTree::StringEmbExpr 97 | tokenizer.T(:statements, child.statements.accept(visitor)) 98 | end 99 | end 100 | 101 | parser = XMLUtils::Parser.new 102 | parser.parse(tokenizer.tokens.dup) 103 | 104 | statements = 105 | parser 106 | .tokens 107 | .map { xml_token_to_ast_node(_1) } 108 | .compact 109 | 110 | SyntaxTree::Formatter.format("", Statements(statements)) 111 | 112 | Statements(statements) 113 | end 114 | end 115 | end 116 | 117 | def xml_token_to_ast_node(token) 118 | case token 119 | in type: :tag, value: { name:, attrs:, children: } 120 | args = [ 121 | SymbolLiteral(Ident(name.to_sym)), 122 | *children.map { xml_token_to_ast_node(_1) }, 123 | unless attrs.empty? 124 | BareAssocHash( 125 | attrs.map { xml_token_to_ast_node(_1) } 126 | ) 127 | end 128 | ].compact 129 | 130 | ARef(VarRef(Const("H")), Args(args)) 131 | in type: :attr, value: { name:, value: } 132 | Assoc( 133 | StringLiteral([TStringContent(name)], '"'), 134 | xml_token_to_ast_node(value) 135 | ) 136 | in type: :attr_value, value: 137 | StringLiteral([TStringContent(value)], '"') 138 | in type: :var_ref, value: /\A@(.*)/ 139 | ARef( 140 | call_self("state"), 141 | Args([SymbolLiteral(Ident($~[1]))]), 142 | ) 143 | in type: :var_ref, value: /\A\$(.*)/ 144 | ARef( 145 | call_self("props"), 146 | Args([SymbolLiteral(Ident($~[1]))]), 147 | ) 148 | in type: :newline 149 | nil 150 | in type: :string, value: 151 | StringLiteral([TStringContent(value)], '"') 152 | in type: :statements, value: 153 | case value.body 154 | in [] 155 | nil 156 | in [first] 157 | first 158 | in [*many] 159 | Begin(BodyStmt(value)) 160 | end 161 | end 162 | end 163 | 164 | private 165 | 166 | def call_html(parts) 167 | call_self(:html, ArgParen(Args([StringLiteral(parts, '"')]))) 168 | end 169 | 170 | def call_self(method, args = nil) 171 | CallNode( 172 | VarRef(Kw("self")), 173 | Period("."), 174 | Ident(method), 175 | args 176 | ) 177 | end 178 | 179 | def update(nodes) 180 | MethodAddBlock( 181 | call_self("update"), 182 | BlockNode( 183 | Kw("{"), 184 | nil, 185 | Statements(Array(nodes)) 186 | ) 187 | ) 188 | end 189 | 190 | def aref(node) 191 | ARef( 192 | call_self(COLLECTIONS.fetch(node.class)), 193 | Args([SymbolLiteral(Ident(strip_var_prefix(node.value)))]), 194 | ) 195 | end 196 | 197 | def aref_field(node) 198 | ARefField( 199 | call_self(COLLECTIONS.fetch(node.class)), 200 | Args([SymbolLiteral(Ident(strip_var_prefix(node.value)))]), 201 | ) 202 | end 203 | 204 | def strip_var_prefix(str) 205 | str 206 | .delete_prefix("@") 207 | .delete_prefix("$") 208 | end 209 | end 210 | end 211 | 212 | if __FILE__ == $0 213 | source = <<~RUBY 214 | def render 215 | H[:div, 216 | H[:p, "Hello world"] 217 | ] 218 | end 219 | RUBY 220 | 221 | puts "\e[3m SOURCE: \e[0m" 222 | puts source 223 | puts "\e[3m TRANSFORMED: \e[0m" 224 | puts VDOM::Transform.transform(source) 225 | end 226 | -------------------------------------------------------------------------------- /lib/vdom/xml_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "strscan" 4 | 5 | module VDOM 6 | class XMLUtils 7 | Token = Data.define(:type, :value) do 8 | def to_s = inspect 9 | 10 | def inspect 11 | case self 12 | in type: :tag, value: { name:, attrs:, children: } 13 | str = [name.to_sym, *children, *attrs] 14 | .map(&:inspect) 15 | .reject(&:empty?) 16 | .join(", ") 17 | "h(#{str})" 18 | in type: :attr, value: { name:, value: } 19 | " #{name}: #{value.inspect}" 20 | in type: :var_ref, value: /\A@(.*)/ 21 | "self.state[:#{$~[1]}]" 22 | in type: :var_ref, value: /\A\$(.*)/ 23 | "self.props[:#{$~[1]}]" 24 | in type: :newline 25 | "" 26 | in type: :string, value: 27 | value.inspect 28 | in type: :statements, value: 29 | SyntaxTree::Formatter.format("", value) 30 | in type:, value: 31 | "[#{type} #{value.inspect}]" 32 | end 33 | end 34 | end 35 | 36 | class Tokenizer 37 | def T(type, value = nil) 38 | @tokens.push(Token[type, value]) 39 | end 40 | 41 | def initialize 42 | @tokens = [] 43 | @state = :any 44 | end 45 | 46 | attr_reader :tokens 47 | 48 | def tokenize(source) 49 | ss = StringScanner.new(source.lstrip) 50 | 51 | until ss.eos? 52 | new_state = 53 | case p(@state) 54 | in :any then tokenize_any(ss) 55 | in :string then tokenize_string(ss) 56 | in :tag then tokenize_tag(ss) 57 | in :attrs then tokenize_attrs(ss) 58 | in :attr_value then tokenize_attr_value(ss) 59 | end 60 | @state = new_state 61 | end 62 | end 63 | 64 | private 65 | 66 | def tokenize_any(ss) 67 | case 68 | when ss.scan(//) or raise "Expected tag to end!" 110 | T(:close_tag, tag_name) 111 | return :any 112 | end 113 | 114 | T(:open_tag_begin, tag_name) 115 | 116 | :attrs 117 | end 118 | 119 | def tokenize_attrs(ss) 120 | ss.skip(/\s+/) 121 | 122 | if ss.scan(/>/) 123 | T(:open_tag_end) 124 | return :any 125 | end 126 | 127 | if ss.scan(/\//) 128 | unless ss.scan(/>/) 129 | raise "Expected > after /" 130 | end 131 | 132 | T(:open_tag_end, self_closing: true) 133 | 134 | return :any 135 | end 136 | 137 | attr = ss.scan(/\w+/) 138 | 139 | unless attr 140 | return :attrs 141 | end 142 | 143 | T(:attr_name, attr) 144 | 145 | if ss.scan(/=/) 146 | T(:attr_assign) 147 | :attr_value 148 | else 149 | :attrs 150 | end 151 | end 152 | 153 | def tokenize_attr_value(ss) 154 | if var_ref = ss.scan(/[@$][\w_]+/) 155 | T(:var_ref, var_ref) 156 | return :attrs 157 | end 158 | 159 | if value_begin = ss.scan(/"/) 160 | if value = ss.scan_until(/"/)[0...-1] 161 | T(:attr_value, value) 162 | return :attrs 163 | end 164 | end 165 | 166 | raise "Expected value at #{ss.pos}" 167 | end 168 | end 169 | 170 | class Parser 171 | def initialize 172 | @tokens = [] 173 | end 174 | 175 | attr_reader :tokens 176 | 177 | def parse(tokens) 178 | @tokens.push( 179 | parse_any(tokens) 180 | ) until tokens.empty? 181 | 182 | self 183 | end 184 | 185 | private 186 | 187 | def parse_any(tokens, close_tag = nil) 188 | case p(token = tokens.shift) 189 | in type: :open_tag_begin, value: 190 | parse_tag(tokens, value) 191 | in type: :string 192 | token 193 | in type: :newline 194 | token 195 | in type: :close_tag 196 | token 197 | in type: :statements 198 | token 199 | in nil 200 | raise "Unexpected end of tokens" 201 | end 202 | end 203 | 204 | def parse_tag(tokens, name) 205 | attrs = [] 206 | 207 | while token = tokens.shift 208 | case token 209 | in type: :open_tag_end, value: { self_closing: true } 210 | return Token[:tag, { name:, attrs:, children: [] }] 211 | in type: :open_tag_end 212 | children = parse_children(tokens, name) 213 | return Token[:tag, { name:, attrs:, children: }] 214 | in type: :attr_name, value: 215 | attrs.push(parse_attr(tokens, value)) 216 | end 217 | end 218 | end 219 | 220 | def parse_children(tokens, close_tag) 221 | children = [] 222 | 223 | while token = parse_any(tokens, close_tag) 224 | case token 225 | in type: :close_tag, value: ^close_tag 226 | return children 227 | in type: :close_tag, value: 228 | raise "Expected close tag for #{close_tag} but got #{value}" 229 | else 230 | children.push(token) 231 | end 232 | end 233 | 234 | children 235 | end 236 | 237 | def parse_attr(tokens, name) 238 | while token = tokens.shift 239 | case token 240 | in type: :attr_assign 241 | next 242 | in type: :var_ref 243 | return Token[:attr, { name:, value: token }] 244 | in type: :attr_value 245 | return Token[:attr, { name:, value: token }] 246 | end 247 | end 248 | end 249 | end 250 | end 251 | end 252 | 253 | if __FILE__ == $0 254 | tokenizer = VDOM::XMLUtils::Tokenizer.new 255 | 256 | tokenizer.tokenize(<<~HTML) 257 |
    258 |

    asd: asdasd

    259 | HTML 260 | 261 | puts "Before:" 262 | puts tokenizer.tokens 263 | 264 | index = tokenizer.tokens.length 265 | 266 | tokenizer.tokenize(<<~HTML) 267 |
    268 | HTML 269 | 270 | puts "Added:" 271 | puts tokenizer.tokens.slice(index..-1) 272 | 273 | parser = VDOM::XMLUtils::Parser.new 274 | 275 | puts "Parsed" 276 | puts parser.parse(tokenizer.tokens.dup).tokens 277 | end 278 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aalin/rdom/112a48ec5ca4f2eb97df814b9c88787fe26f8cf2/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ruby VDOM demo 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 78 | 79 | 80 | 81 |

    Initializing...

    82 |
    83 | 84 | 85 | -------------------------------------------------------------------------------- /public/rdom.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_ENDPOINT = "/.rdom"; 2 | const SESSION_ID_HEADER = "x-rdom-session-id"; 3 | const STREAM_MIME_TYPE = "x-rdom/json-stream"; 4 | const CONNECTED_STATE = "--connected"; 5 | const CONNECTED_CLASS = "rdom-connected"; 6 | 7 | const STYLESHEETS = { 8 | root: createStylesheet(` 9 | :host { 10 | display: flow-root; 11 | box-sizing: border-box; 12 | content-visibility: auto; 13 | } 14 | *:not(:defined) { 15 | /* Hide elements until they are fully loaded */ 16 | display: none; 17 | } 18 | `), 19 | customElement: createStylesheet(` 20 | :host { 21 | display: contents; 22 | } 23 | `), 24 | boxSizing: createStylesheet(` 25 | *, *::before, *::after { 26 | box-sizing: border-box; 27 | } 28 | `), 29 | }; 30 | 31 | customElements.define( 32 | 'rdom-embed', 33 | class RDOMEmbedElement extends HTMLElement { 34 | #internals; 35 | 36 | constructor() { 37 | super(); 38 | this.attachShadow({ mode: "open" }); 39 | this.#internals = this.attachInternals(); 40 | this.#setConnectedState(false); 41 | 42 | this.shadowRoot.adoptedStyleSheets = [ 43 | STYLESHEETS.root, 44 | STYLESHEETS.boxSizing, 45 | ]; 46 | } 47 | 48 | async connectedCallback() { 49 | try { 50 | const endpoint = this.getAttribute("src") || DEFAULT_ENDPOINT; 51 | const res = await connect(endpoint); 52 | 53 | const output = initCallbackStream(endpoint, getSessionIdHeader(res)); 54 | 55 | this.#setConnectedState(true); 56 | 57 | await res.body 58 | .pipeThrough(new TextDecoderStream()) 59 | .pipeThrough(new JSONDecoderStream()) 60 | .pipeThrough(new PatchStream(endpoint, this.shadowRoot)) 61 | .pipeThrough(new JSONEncoderStream()) 62 | .pipeThrough(new TextEncoderStream()) 63 | .pipeTo(output); 64 | } finally { 65 | console.error("🔴 Disconnected!"); 66 | this.#setConnectedState(false); 67 | } 68 | } 69 | 70 | #setConnectedState(isConnected) { 71 | if (isConnected) { 72 | this.#internals.states?.add(CONNECTED_STATE); 73 | this.classList.add(CONNECTED_CLASS); 74 | } else { 75 | this.#internals.states?.delete(CONNECTED_STATE); 76 | this.classList.remove(CONNECTED_CLASS); 77 | } 78 | } 79 | } 80 | ); 81 | 82 | function getSessionIdHeader(res) { 83 | const sessionId = res.headers.get(SESSION_ID_HEADER); 84 | if (sessionId) return sessionId; 85 | throw new Error(`Could not read header: ${SESSION_ID_HEADER}`); 86 | } 87 | 88 | async function connect(endpoint) { 89 | console.info("🟡 Connecting to", endpoint); 90 | 91 | const res = await fetch(endpoint, { 92 | method: "GET", 93 | mode: "cors", 94 | headers: new Headers({ accept: STREAM_MIME_TYPE }), 95 | }); 96 | 97 | if (!res.ok) { 98 | alert("Connection failed!"); 99 | console.error(res); 100 | throw new Error("Res was not ok."); 101 | } 102 | 103 | const contentType = res.headers.get("content-type"); 104 | 105 | if (contentType !== STREAM_MIME_TYPE) { 106 | alert(`Unexpected content type: ${contentType}`); 107 | console.error(res); 108 | throw new Error(`Unexpected content type: ${contentType}`); 109 | } 110 | 111 | console.info("🟢 Connected to", endpoint); 112 | 113 | return res; 114 | } 115 | 116 | class JSONDecoderStream extends TransformStream { 117 | constructor() { 118 | // This transformer is based on this code: 119 | // https://rob-blackbourn.medium.com/beyond-eventsource-streaming-fetch-with-readablestream-5765c7de21a1#6c5e 120 | super({ 121 | start(controller) { 122 | controller.buf = ""; 123 | controller.pos = 0; 124 | }, 125 | 126 | transform(chunk, controller) { 127 | controller.buf += chunk; 128 | 129 | while (controller.pos < controller.buf.length) { 130 | if (controller.buf[controller.pos] === "\n") { 131 | const line = controller.buf.substring(0, controller.pos); 132 | controller.enqueue(JSON.parse(line)); 133 | controller.buf = controller.buf.substring(controller.pos + 1); 134 | controller.pos = 0; 135 | } else { 136 | controller.pos++; 137 | } 138 | } 139 | }, 140 | }); 141 | } 142 | } 143 | 144 | class JSONEncoderStream extends TransformStream { 145 | constructor() { 146 | super({ 147 | transform(chunk, controller) { 148 | controller.enqueue(JSON.stringify(chunk) + "\n"); 149 | }, 150 | }); 151 | } 152 | } 153 | 154 | const supportsRequestStreams = (() => { 155 | // https://developer.chrome.com/articles/fetch-streaming-requests/#feature-detection 156 | let duplexAccessed = false; 157 | 158 | const hasContentType = new Request("", { 159 | body: new ReadableStream(), 160 | method: "POST", 161 | get duplex() { 162 | duplexAccessed = true; 163 | return "half"; 164 | }, 165 | }).headers.has("Content-Type"); 166 | 167 | return duplexAccessed && !hasContentType; 168 | })(); 169 | 170 | function initCallbackStream(endpoint, sessionId) { 171 | if (!supportsRequestStreams) { 172 | return initCallbackStreamFetchFallback(endpoint, sessionId); 173 | } 174 | 175 | const { readable, writable } = new TransformStream(); 176 | 177 | fetch(endpoint, { 178 | method: "POST", 179 | headers: { 180 | "content-type": STREAM_MIME_TYPE, 181 | [SESSION_ID_HEADER]: sessionId, 182 | }, 183 | duplex: "half", 184 | mode: "cors", 185 | body: readable, 186 | }); 187 | 188 | return writable; 189 | } 190 | 191 | function initCallbackStreamFetchFallback(endpoint, sessionId) { 192 | return new WritableStream({ 193 | write(body, controller) { 194 | fetch(endpoint, { 195 | method: "POST", 196 | headers: new Headers({ 197 | "content-type": "application/json", 198 | [SESSION_ID_HEADER]: sessionId, 199 | }), 200 | mode: "cors", 201 | body: body, 202 | }); 203 | }, 204 | }); 205 | } 206 | 207 | class RAFQueue { 208 | constructor(onFlush) { 209 | this.onFlush = onFlush; 210 | this.queue = []; 211 | this.raf = null; 212 | } 213 | 214 | enqueue(msg) { 215 | this.queue.push(msg); 216 | this.raf ||= requestAnimationFrame(() => this.flush()); 217 | } 218 | 219 | flush() { 220 | this.raf = null; 221 | const queue = this.queue; 222 | if (queue.length === 0) return; 223 | this.queue = []; 224 | this.onFlush(queue); 225 | } 226 | } 227 | 228 | class PatchStream extends TransformStream { 229 | constructor(endpoint, root) { 230 | super({ 231 | start(controller) { 232 | controller.endpoint = endpoint; 233 | controller.root = root; 234 | controller.nodes = new Map(); 235 | controller.navigationPromise = null; 236 | 237 | controller.rafQueue = new RAFQueue(async (patches) => { 238 | console.debug("Applying", patches.length, "patches"); 239 | console.time("patch"); 240 | 241 | for (const patch of patches) { 242 | const [type, ...args] = patch; 243 | 244 | const patchFn = PatchFunctions[type]; 245 | 246 | if (!patchFn) { 247 | console.error("Patch not implemented:", type); 248 | continue; 249 | } 250 | 251 | try { 252 | await patchFn.apply(controller, args); 253 | } catch (e) { 254 | console.error(e); 255 | } 256 | } 257 | 258 | console.timeEnd("patch"); 259 | }); 260 | }, 261 | transform(patch, controller) { 262 | controller.rafQueue.enqueue(patch); 263 | }, 264 | flush(controller) {}, 265 | }); 266 | } 267 | } 268 | 269 | function startViewTransition() { 270 | if (!document.startViewTransition) { 271 | return Promise.resolve(); 272 | } 273 | 274 | return new Promise((resolve) => { 275 | document.startViewTransition(() => resolve()); 276 | }); 277 | } 278 | 279 | function setupNavigationListener(controller) { 280 | navigation.addEventListener("navigate", (e) => { 281 | console.log(e); 282 | 283 | if (!e.canIntercept || e.hashChange) { 284 | return; 285 | } 286 | 287 | controller.navigationPromise ||= new Promise(); 288 | 289 | e.intercept({ 290 | async handler() { 291 | e.signal.addEventListener("abort", () => { 292 | promise.reject(); 293 | controller.navigationPromise = null; 294 | }); 295 | 296 | await promise; 297 | controller.navigationPromise = null; 298 | }, 299 | }); 300 | }); 301 | } 302 | 303 | const PatchFunctions = { 304 | Event(name, payload = {}) { 305 | console.warn("Event", name, payload); 306 | 307 | switch (name) { 308 | case "startViewTransition": { 309 | return startViewTransition(); 310 | } 311 | default: { 312 | break; 313 | } 314 | } 315 | }, 316 | CreateRoot() { 317 | const root = document.createElement("rdom-root"); 318 | this.nodes.set(null, root); 319 | this.root.appendChild(root); 320 | // setupNavigationListener(this) 321 | }, 322 | DestroyRoot() { 323 | const root = this.nodes.get(null); 324 | if (!root) return; 325 | this.nodes.delete(null); 326 | root.remove(); 327 | }, 328 | CreateElement(id, type) { 329 | this.nodes.set(id, document.createElement(type)); 330 | }, 331 | InsertBefore(parentId, id, refId) { 332 | const parent = this.nodes.get(parentId); 333 | const child = this.nodes.get(id); 334 | const ref = refId && this.nodes.get(refId); 335 | parent.insertBefore(child, ref); 336 | }, 337 | RemoveChild(parentId, id) { 338 | const child = this.nodes.get(id); 339 | if (!child) return; 340 | 341 | const parent = this.nodes.get(parentId); 342 | if (!parent) return; 343 | 344 | if (child.parent == parent) { 345 | parent.removeChild(child); 346 | } 347 | }, 348 | RemoveNode(id) { 349 | const node = this.nodes.get(id); 350 | if (!node) return; 351 | if (node.remove) { 352 | node.remove(); 353 | } 354 | this.nodes.delete(id); 355 | }, 356 | DefineCustomElement(name, filename) { 357 | RDOMElement.fetchAndDefine( 358 | name, 359 | new URL(`${this.endpoint}/${filename}`, import.meta.url) 360 | ); 361 | }, 362 | AssignSlot(id, name, ids) { 363 | const node = this.nodes.get(id); 364 | if (!node) return; 365 | customElements.whenDefined(node.localName).then(() => { 366 | node.assignSlot( 367 | name, 368 | ids.map((id) => this.nodes.get(id)).filter(Boolean) 369 | ); 370 | }); 371 | }, 372 | CreateTextNode(id, content) { 373 | this.nodes.set(id, document.createTextNode(content)); 374 | }, 375 | SetTextContent(id, content) { 376 | this.nodes.get(id).textContent = content; 377 | }, 378 | ReplaceData(id, offset, count, data) { 379 | this.nodes.get(id).replaceData(offset, count, data); 380 | }, 381 | InsertData(id, offset, data) { 382 | this.nodes.get(id).insertData(offset, data); 383 | }, 384 | DeleteData(id, offset, count) { 385 | this.nodes.get(id).deleteData(offset, count); 386 | }, 387 | SetAttribute(parentId, refId, name, value) { 388 | const parent = this.nodes.get(parentId); 389 | if (!parent) return; 390 | customElements.whenDefined(parent.localName).then(() => { 391 | const node = parent.getRef(refId); 392 | if (!node) return; 393 | 394 | if (node instanceof HTMLInputElement) { 395 | switch (name) { 396 | case "value": { 397 | node.value = value; 398 | break; 399 | } 400 | case "checked": { 401 | node.checked = true; 402 | break; 403 | } 404 | case "indeterminate": { 405 | node.indeterminate = true; 406 | break; 407 | } 408 | } 409 | } 410 | 411 | if (name === "initial-value") { 412 | name = "value"; 413 | } else { 414 | name = name.replaceAll("_", ""); 415 | } 416 | 417 | node.setAttribute(name, value); 418 | }); 419 | }, 420 | RemoveAttribute(parentId, refId, name) { 421 | const parent = this.nodes.get(parentId); 422 | if (!parent) return; 423 | customElements.whenDefined(parent.localName).then(() => { 424 | parent.getRef(refId)?.removeAttribute(name); 425 | }); 426 | }, 427 | CreateDocumentFragment(id) { 428 | this.nodes.set(id, document.createDocumentFragment()); 429 | }, 430 | SetCSSProperty(parentId, refId, name, value) { 431 | const parent = this.nodes.get(parentId); 432 | if (!parent) return; 433 | customElements.whenDefined(parent.localName).then(() => { 434 | parent.getRef(refId)?.style?.setProperty(name, value); 435 | }); 436 | }, 437 | RemoveCSSProperty(parentId, refId, name) { 438 | const parent = this.nodes.get(parentId); 439 | if (!parent) return; 440 | customElements.whenDefined(parent.localName).then(() => { 441 | parent.getRef(refId)?.style?.removeProperty(name); 442 | }); 443 | }, 444 | SetHandler(parentId, refId, event, callbackId) { 445 | const parent = this.nodes.get(parentId); 446 | customElements.whenDefined(parent.localName).then(() => { 447 | const elem = parent.getRef(refId); 448 | 449 | this.nodes.set( 450 | callbackId, 451 | elem.addEventListener(event.replace(/^on/, ""), (e) => { 452 | e.preventDefault(); 453 | 454 | const payload = { 455 | type: e.type, 456 | target: e.target && { 457 | value: e.target.value, 458 | }, 459 | }; 460 | 461 | this.enqueue(["callback", callbackId, payload]); 462 | }) 463 | ); 464 | }); 465 | }, 466 | RemoveHandler(parentId, refId, event, callbackId) { 467 | this.nodes.delete(callbackId); 468 | const parent = this.nodes.get(parentId); 469 | if (!parent) return; 470 | customElements.whenDefined(parent.localName).then(() => { 471 | parent 472 | .getRef(refId) 473 | ?.removeEventListener( 474 | event.replace(/^on/, ""), 475 | this.nodes.get(callbackId) 476 | ); 477 | }); 478 | }, 479 | Ping(time) { 480 | this.enqueue(["pong", time]); 481 | }, 482 | }; 483 | 484 | class RDOMElement extends HTMLElement { 485 | static template = null; 486 | #slots = {}; 487 | #refs = {}; 488 | 489 | static async fetchAndDefine(name, url) { 490 | if (customElements.get(name)) return; 491 | const html = await fetchTemplate(url); 492 | const template = createTemplate(html, url); 493 | RDOMElement.define(name, template); 494 | } 495 | 496 | static define(name, template) { 497 | if (customElements.get(name)) return; 498 | 499 | customElements.define( 500 | name, 501 | class extends RDOMElement { 502 | static template = template; 503 | } 504 | ); 505 | } 506 | 507 | connectedCallback() { 508 | this.attachShadow({ 509 | mode: "open", 510 | slotAssignment: "manual", 511 | }); 512 | 513 | const { template, stylesheet } = this.constructor; 514 | 515 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 516 | 517 | for (const node of this.shadowRoot.querySelectorAll( 518 | "slot[data-rdom-slot]" 519 | )) { 520 | this.#slots[node.dataset.rdomSlot] = node; 521 | node.removeAttribute("data-rdom-slot"); 522 | } 523 | 524 | for (const node of this.shadowRoot.querySelectorAll("[data-rdom-ref]")) { 525 | this.#refs[node.dataset.rdomRef] = node; 526 | node.removeAttribute("data-rdom-ref"); 527 | } 528 | 529 | this.shadowRoot.adoptedStyleSheets = [ 530 | STYLESHEETS.customElement, 531 | STYLESHEETS.boxSizing, 532 | ]; 533 | } 534 | 535 | getRef(refId) { 536 | const ref = this.#refs[refId]; 537 | if (ref) return ref; 538 | console.error("Could not find ref", refId); 539 | } 540 | 541 | assignSlot(name, nodes) { 542 | const slot = this.#slots[name]; 543 | if (!slot) { 544 | throw new Error(`No slot with name ${name}`); 545 | } 546 | slot.assign(...nodes); 547 | } 548 | } 549 | 550 | RDOMElement.define( 551 | 'rdom-root', 552 | createTemplate('') 553 | ) 554 | 555 | function createStylesheet(source) { 556 | const styles = new CSSStyleSheet(); 557 | styles.replace(source); 558 | return styles; 559 | } 560 | 561 | async function fetchTemplate(url) { 562 | const res = await fetch(url, { 563 | headers: new Headers({ accept: "text/html" }), 564 | }); 565 | return res.text(); 566 | } 567 | 568 | function createTemplate(html, baseUrl = undefined) { 569 | const template = document 570 | .createRange() 571 | .createContextualFragment(``).firstElementChild; 572 | for (const link of template.content.querySelectorAll("link")) { 573 | link.setAttribute("href", new URL(link.getAttribute("href"), baseUrl)); 574 | } 575 | return template; 576 | } 577 | --------------------------------------------------------------------------------