packages feed

emanote (empty) → 0.8.0.0

raw patch · 102 files changed

+7133/−0 lines, 102 filesdep +aesondep +aeson-extradep +aeson-opticsbinary-added

Dependencies added: aeson, aeson-extra, aeson-optics, async, base, blaze-html, bytestring, commonmark, commonmark-extensions, commonmark-pandoc, commonmark-simple, containers, data-default, dependent-sum, directory, ema, filepath, filepattern, fsnotify, hedgehog, heist-emanote, heist-extra, hspec, hspec-hedgehog, ixset-typed, lvar, map-syntax, megaparsec, monad-logger, monad-logger-extras, mtl, neat-interpolation, optics-core, optics-th, optparse-applicative, pandoc, pandoc-link-context, pandoc-types, parsec, path-tree, process-extras, profunctors, relude, shower, some, stm, tagged, tagtree, tailwind, text, time, tomland, unionmount, unliftio, unordered-containers, uri-encode, url-slug, uuid, which, with-utf8, xmlhtml, yaml

Files

+ LICENSE view
@@ -0,0 +1,661 @@+                    GNU AFFERO GENERAL PUBLIC LICENSE+                       Version 3, 19 November 2007++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>+ Everyone is permitted to copy and distribute verbatim copies+ of this license document, but changing it is not allowed.++                            Preamble++  The GNU Affero General Public License is a free, copyleft license for+software and other kinds of works, specifically designed to ensure+cooperation with the community in the case of network server software.++  The licenses for most software and other practical works are designed+to take away your freedom to share and change the works.  By contrast,+our General Public Licenses are intended to guarantee your freedom to+share and change all versions of a program--to make sure it remains free+software for all its users.++  When we speak of free software, we are referring to freedom, not+price.  Our General Public Licenses are designed to make sure that you+have the freedom to distribute copies of free software (and charge for+them if you wish), that you receive source code or can get it if you+want it, that you can change the software or use pieces of it in new+free programs, and that you know you can do these things.++  Developers that use our General Public Licenses protect your rights+with two steps: (1) assert copyright on the software, and (2) offer+you this License which gives you legal permission to copy, distribute+and/or modify the software.++  A secondary benefit of defending all users' freedom is that+improvements made in alternate versions of the program, if they+receive widespread use, become available for other developers to+incorporate.  Many developers of free software are heartened and+encouraged by the resulting cooperation.  However, in the case of+software used on network servers, this result may fail to come about.+The GNU General Public License permits making a modified version and+letting the public access it on a server without ever releasing its+source code to the public.++  The GNU Affero General Public License is designed specifically to+ensure that, in such cases, the modified source code becomes available+to the community.  It requires the operator of a network server to+provide the source code of the modified version running there to the+users of that server.  Therefore, public use of a modified version, on+a publicly accessible server, gives the public access to the source+code of the modified version.++  An older license, called the Affero General Public License and+published by Affero, was designed to accomplish similar goals.  This is+a different license, not a version of the Affero GPL, but Affero has+released a new version of the Affero GPL which permits relicensing under+this license.++  The precise terms and conditions for copying, distribution and+modification follow.++                       TERMS AND CONDITIONS++  0. Definitions.++  "This License" refers to version 3 of the GNU Affero General Public License.++  "Copyright" also means copyright-like laws that apply to other kinds of+works, such as semiconductor masks.++  "The Program" refers to any copyrightable work licensed under this+License.  Each licensee is addressed as "you".  "Licensees" and+"recipients" may be individuals or organizations.++  To "modify" a work means to copy from or adapt all or part of the work+in a fashion requiring copyright permission, other than the making of an+exact copy.  The resulting work is called a "modified version" of the+earlier work or a work "based on" the earlier work.++  A "covered work" means either the unmodified Program or a work based+on the Program.++  To "propagate" a work means to do anything with it that, without+permission, would make you directly or secondarily liable for+infringement under applicable copyright law, except executing it on a+computer or modifying a private copy.  Propagation includes copying,+distribution (with or without modification), making available to the+public, and in some countries other activities as well.++  To "convey" a work means any kind of propagation that enables other+parties to make or receive copies.  Mere interaction with a user through+a computer network, with no transfer of a copy, is not conveying.++  An interactive user interface displays "Appropriate Legal Notices"+to the extent that it includes a convenient and prominently visible+feature that (1) displays an appropriate copyright notice, and (2)+tells the user that there is no warranty for the work (except to the+extent that warranties are provided), that licensees may convey the+work under this License, and how to view a copy of this License.  If+the interface presents a list of user commands or options, such as a+menu, a prominent item in the list meets this criterion.++  1. Source Code.++  The "source code" for a work means the preferred form of the work+for making modifications to it.  "Object code" means any non-source+form of a work.++  A "Standard Interface" means an interface that either is an official+standard defined by a recognized standards body, or, in the case of+interfaces specified for a particular programming language, one that+is widely used among developers working in that language.++  The "System Libraries" of an executable work include anything, other+than the work as a whole, that (a) is included in the normal form of+packaging a Major Component, but which is not part of that Major+Component, and (b) serves only to enable use of the work with that+Major Component, or to implement a Standard Interface for which an+implementation is available to the public in source code form.  A+"Major Component", in this context, means a major essential component+(kernel, window system, and so on) of the specific operating system+(if any) on which the executable work runs, or a compiler used to+produce the work, or an object code interpreter used to run it.++  The "Corresponding Source" for a work in object code form means all+the source code needed to generate, install, and (for an executable+work) run the object code and to modify the work, including scripts to+control those activities.  However, it does not include the work's+System Libraries, or general-purpose tools or generally available free+programs which are used unmodified in performing those activities but+which are not part of the work.  For example, Corresponding Source+includes interface definition files associated with source files for+the work, and the source code for shared libraries and dynamically+linked subprograms that the work is specifically designed to require,+such as by intimate data communication or control flow between those+subprograms and other parts of the work.++  The Corresponding Source need not include anything that users+can regenerate automatically from other parts of the Corresponding+Source.++  The Corresponding Source for a work in source code form is that+same work.++  2. Basic Permissions.++  All rights granted under this License are granted for the term of+copyright on the Program, and are irrevocable provided the stated+conditions are met.  This License explicitly affirms your unlimited+permission to run the unmodified Program.  The output from running a+covered work is covered by this License only if the output, given its+content, constitutes a covered work.  This License acknowledges your+rights of fair use or other equivalent, as provided by copyright law.++  You may make, run and propagate covered works that you do not+convey, without conditions so long as your license otherwise remains+in force.  You may convey covered works to others for the sole purpose+of having them make modifications exclusively for you, or provide you+with facilities for running those works, provided that you comply with+the terms of this License in conveying all material for which you do+not control copyright.  Those thus making or running the covered works+for you must do so exclusively on your behalf, under your direction+and control, on terms that prohibit them from making any copies of+your copyrighted material outside their relationship with you.++  Conveying under any other circumstances is permitted solely under+the conditions stated below.  Sublicensing is not allowed; section 10+makes it unnecessary.++  3. Protecting Users' Legal Rights From Anti-Circumvention Law.++  No covered work shall be deemed part of an effective technological+measure under any applicable law fulfilling obligations under article+11 of the WIPO copyright treaty adopted on 20 December 1996, or+similar laws prohibiting or restricting circumvention of such+measures.++  When you convey a covered work, you waive any legal power to forbid+circumvention of technological measures to the extent such circumvention+is effected by exercising rights under this License with respect to+the covered work, and you disclaim any intention to limit operation or+modification of the work as a means of enforcing, against the work's+users, your or third parties' legal rights to forbid circumvention of+technological measures.++  4. Conveying Verbatim Copies.++  You may convey verbatim copies of the Program's source code as you+receive it, in any medium, provided that you conspicuously and+appropriately publish on each copy an appropriate copyright notice;+keep intact all notices stating that this License and any+non-permissive terms added in accord with section 7 apply to the code;+keep intact all notices of the absence of any warranty; and give all+recipients a copy of this License along with the Program.++  You may charge any price or no price for each copy that you convey,+and you may offer support or warranty protection for a fee.++  5. Conveying Modified Source Versions.++  You may convey a work based on the Program, or the modifications to+produce it from the Program, in the form of source code under the+terms of section 4, provided that you also meet all of these conditions:++    a) The work must carry prominent notices stating that you modified+    it, and giving a relevant date.++    b) The work must carry prominent notices stating that it is+    released under this License and any conditions added under section+    7.  This requirement modifies the requirement in section 4 to+    "keep intact all notices".++    c) You must license the entire work, as a whole, under this+    License to anyone who comes into possession of a copy.  This+    License will therefore apply, along with any applicable section 7+    additional terms, to the whole of the work, and all its parts,+    regardless of how they are packaged.  This License gives no+    permission to license the work in any other way, but it does not+    invalidate such permission if you have separately received it.++    d) If the work has interactive user interfaces, each must display+    Appropriate Legal Notices; however, if the Program has interactive+    interfaces that do not display Appropriate Legal Notices, your+    work need not make them do so.++  A compilation of a covered work with other separate and independent+works, which are not by their nature extensions of the covered work,+and which are not combined with it such as to form a larger program,+in or on a volume of a storage or distribution medium, is called an+"aggregate" if the compilation and its resulting copyright are not+used to limit the access or legal rights of the compilation's users+beyond what the individual works permit.  Inclusion of a covered work+in an aggregate does not cause this License to apply to the other+parts of the aggregate.++  6. Conveying Non-Source Forms.++  You may convey a covered work in object code form under the terms+of sections 4 and 5, provided that you also convey the+machine-readable Corresponding Source under the terms of this License,+in one of these ways:++    a) Convey the object code in, or embodied in, a physical product+    (including a physical distribution medium), accompanied by the+    Corresponding Source fixed on a durable physical medium+    customarily used for software interchange.++    b) Convey the object code in, or embodied in, a physical product+    (including a physical distribution medium), accompanied by a+    written offer, valid for at least three years and valid for as+    long as you offer spare parts or customer support for that product+    model, to give anyone who possesses the object code either (1) a+    copy of the Corresponding Source for all the software in the+    product that is covered by this License, on a durable physical+    medium customarily used for software interchange, for a price no+    more than your reasonable cost of physically performing this+    conveying of source, or (2) access to copy the+    Corresponding Source from a network server at no charge.++    c) Convey individual copies of the object code with a copy of the+    written offer to provide the Corresponding Source.  This+    alternative is allowed only occasionally and noncommercially, and+    only if you received the object code with such an offer, in accord+    with subsection 6b.++    d) Convey the object code by offering access from a designated+    place (gratis or for a charge), and offer equivalent access to the+    Corresponding Source in the same way through the same place at no+    further charge.  You need not require recipients to copy the+    Corresponding Source along with the object code.  If the place to+    copy the object code is a network server, the Corresponding Source+    may be on a different server (operated by you or a third party)+    that supports equivalent copying facilities, provided you maintain+    clear directions next to the object code saying where to find the+    Corresponding Source.  Regardless of what server hosts the+    Corresponding Source, you remain obligated to ensure that it is+    available for as long as needed to satisfy these requirements.++    e) Convey the object code using peer-to-peer transmission, provided+    you inform other peers where the object code and Corresponding+    Source of the work are being offered to the general public at no+    charge under subsection 6d.++  A separable portion of the object code, whose source code is excluded+from the Corresponding Source as a System Library, need not be+included in conveying the object code work.++  A "User Product" is either (1) a "consumer product", which means any+tangible personal property which is normally used for personal, family,+or household purposes, or (2) anything designed or sold for incorporation+into a dwelling.  In determining whether a product is a consumer product,+doubtful cases shall be resolved in favor of coverage.  For a particular+product received by a particular user, "normally used" refers to a+typical or common use of that class of product, regardless of the status+of the particular user or of the way in which the particular user+actually uses, or expects or is expected to use, the product.  A product+is a consumer product regardless of whether the product has substantial+commercial, industrial or non-consumer uses, unless such uses represent+the only significant mode of use of the product.++  "Installation Information" for a User Product means any methods,+procedures, authorization keys, or other information required to install+and execute modified versions of a covered work in that User Product from+a modified version of its Corresponding Source.  The information must+suffice to ensure that the continued functioning of the modified object+code is in no case prevented or interfered with solely because+modification has been made.++  If you convey an object code work under this section in, or with, or+specifically for use in, a User Product, and the conveying occurs as+part of a transaction in which the right of possession and use of the+User Product is transferred to the recipient in perpetuity or for a+fixed term (regardless of how the transaction is characterized), the+Corresponding Source conveyed under this section must be accompanied+by the Installation Information.  But this requirement does not apply+if neither you nor any third party retains the ability to install+modified object code on the User Product (for example, the work has+been installed in ROM).++  The requirement to provide Installation Information does not include a+requirement to continue to provide support service, warranty, or updates+for a work that has been modified or installed by the recipient, or for+the User Product in which it has been modified or installed.  Access to a+network may be denied when the modification itself materially and+adversely affects the operation of the network or violates the rules and+protocols for communication across the network.++  Corresponding Source conveyed, and Installation Information provided,+in accord with this section must be in a format that is publicly+documented (and with an implementation available to the public in+source code form), and must require no special password or key for+unpacking, reading or copying.++  7. Additional Terms.++  "Additional permissions" are terms that supplement the terms of this+License by making exceptions from one or more of its conditions.+Additional permissions that are applicable to the entire Program shall+be treated as though they were included in this License, to the extent+that they are valid under applicable law.  If additional permissions+apply only to part of the Program, that part may be used separately+under those permissions, but the entire Program remains governed by+this License without regard to the additional permissions.++  When you convey a copy of a covered work, you may at your option+remove any additional permissions from that copy, or from any part of+it.  (Additional permissions may be written to require their own+removal in certain cases when you modify the work.)  You may place+additional permissions on material, added by you to a covered work,+for which you have or can give appropriate copyright permission.++  Notwithstanding any other provision of this License, for material you+add to a covered work, you may (if authorized by the copyright holders of+that material) supplement the terms of this License with terms:++    a) Disclaiming warranty or limiting liability differently from the+    terms of sections 15 and 16 of this License; or++    b) Requiring preservation of specified reasonable legal notices or+    author attributions in that material or in the Appropriate Legal+    Notices displayed by works containing it; or++    c) Prohibiting misrepresentation of the origin of that material, or+    requiring that modified versions of such material be marked in+    reasonable ways as different from the original version; or++    d) Limiting the use for publicity purposes of names of licensors or+    authors of the material; or++    e) Declining to grant rights under trademark law for use of some+    trade names, trademarks, or service marks; or++    f) Requiring indemnification of licensors and authors of that+    material by anyone who conveys the material (or modified versions of+    it) with contractual assumptions of liability to the recipient, for+    any liability that these contractual assumptions directly impose on+    those licensors and authors.++  All other non-permissive additional terms are considered "further+restrictions" within the meaning of section 10.  If the Program as you+received it, or any part of it, contains a notice stating that it is+governed by this License along with a term that is a further+restriction, you may remove that term.  If a license document contains+a further restriction but permits relicensing or conveying under this+License, you may add to a covered work material governed by the terms+of that license document, provided that the further restriction does+not survive such relicensing or conveying.++  If you add terms to a covered work in accord with this section, you+must place, in the relevant source files, a statement of the+additional terms that apply to those files, or a notice indicating+where to find the applicable terms.++  Additional terms, permissive or non-permissive, may be stated in the+form of a separately written license, or stated as exceptions;+the above requirements apply either way.++  8. Termination.++  You may not propagate or modify a covered work except as expressly+provided under this License.  Any attempt otherwise to propagate or+modify it is void, and will automatically terminate your rights under+this License (including any patent licenses granted under the third+paragraph of section 11).++  However, if you cease all violation of this License, then your+license from a particular copyright holder is reinstated (a)+provisionally, unless and until the copyright holder explicitly and+finally terminates your license, and (b) permanently, if the copyright+holder fails to notify you of the violation by some reasonable means+prior to 60 days after the cessation.++  Moreover, your license from a particular copyright holder is+reinstated permanently if the copyright holder notifies you of the+violation by some reasonable means, this is the first time you have+received notice of violation of this License (for any work) from that+copyright holder, and you cure the violation prior to 30 days after+your receipt of the notice.++  Termination of your rights under this section does not terminate the+licenses of parties who have received copies or rights from you under+this License.  If your rights have been terminated and not permanently+reinstated, you do not qualify to receive new licenses for the same+material under section 10.++  9. Acceptance Not Required for Having Copies.++  You are not required to accept this License in order to receive or+run a copy of the Program.  Ancillary propagation of a covered work+occurring solely as a consequence of using peer-to-peer transmission+to receive a copy likewise does not require acceptance.  However,+nothing other than this License grants you permission to propagate or+modify any covered work.  These actions infringe copyright if you do+not accept this License.  Therefore, by modifying or propagating a+covered work, you indicate your acceptance of this License to do so.++  10. Automatic Licensing of Downstream Recipients.++  Each time you convey a covered work, the recipient automatically+receives a license from the original licensors, to run, modify and+propagate that work, subject to this License.  You are not responsible+for enforcing compliance by third parties with this License.++  An "entity transaction" is a transaction transferring control of an+organization, or substantially all assets of one, or subdividing an+organization, or merging organizations.  If propagation of a covered+work results from an entity transaction, each party to that+transaction who receives a copy of the work also receives whatever+licenses to the work the party's predecessor in interest had or could+give under the previous paragraph, plus a right to possession of the+Corresponding Source of the work from the predecessor in interest, if+the predecessor has it or can get it with reasonable efforts.++  You may not impose any further restrictions on the exercise of the+rights granted or affirmed under this License.  For example, you may+not impose a license fee, royalty, or other charge for exercise of+rights granted under this License, and you may not initiate litigation+(including a cross-claim or counterclaim in a lawsuit) alleging that+any patent claim is infringed by making, using, selling, offering for+sale, or importing the Program or any portion of it.++  11. Patents.++  A "contributor" is a copyright holder who authorizes use under this+License of the Program or a work on which the Program is based.  The+work thus licensed is called the contributor's "contributor version".++  A contributor's "essential patent claims" are all patent claims+owned or controlled by the contributor, whether already acquired or+hereafter acquired, that would be infringed by some manner, permitted+by this License, of making, using, or selling its contributor version,+but do not include claims that would be infringed only as a+consequence of further modification of the contributor version.  For+purposes of this definition, "control" includes the right to grant+patent sublicenses in a manner consistent with the requirements of+this License.++  Each contributor grants you a non-exclusive, worldwide, royalty-free+patent license under the contributor's essential patent claims, to+make, use, sell, offer for sale, import and otherwise run, modify and+propagate the contents of its contributor version.++  In the following three paragraphs, a "patent license" is any express+agreement or commitment, however denominated, not to enforce a patent+(such as an express permission to practice a patent or covenant not to+sue for patent infringement).  To "grant" such a patent license to a+party means to make such an agreement or commitment not to enforce a+patent against the party.++  If you convey a covered work, knowingly relying on a patent license,+and the Corresponding Source of the work is not available for anyone+to copy, free of charge and under the terms of this License, through a+publicly available network server or other readily accessible means,+then you must either (1) cause the Corresponding Source to be so+available, or (2) arrange to deprive yourself of the benefit of the+patent license for this particular work, or (3) arrange, in a manner+consistent with the requirements of this License, to extend the patent+license to downstream recipients.  "Knowingly relying" means you have+actual knowledge that, but for the patent license, your conveying the+covered work in a country, or your recipient's use of the covered work+in a country, would infringe one or more identifiable patents in that+country that you have reason to believe are valid.++  If, pursuant to or in connection with a single transaction or+arrangement, you convey, or propagate by procuring conveyance of, a+covered work, and grant a patent license to some of the parties+receiving the covered work authorizing them to use, propagate, modify+or convey a specific copy of the covered work, then the patent license+you grant is automatically extended to all recipients of the covered+work and works based on it.++  A patent license is "discriminatory" if it does not include within+the scope of its coverage, prohibits the exercise of, or is+conditioned on the non-exercise of one or more of the rights that are+specifically granted under this License.  You may not convey a covered+work if you are a party to an arrangement with a third party that is+in the business of distributing software, under which you make payment+to the third party based on the extent of your activity of conveying+the work, and under which the third party grants, to any of the+parties who would receive the covered work from you, a discriminatory+patent license (a) in connection with copies of the covered work+conveyed by you (or copies made from those copies), or (b) primarily+for and in connection with specific products or compilations that+contain the covered work, unless you entered into that arrangement,+or that patent license was granted, prior to 28 March 2007.++  Nothing in this License shall be construed as excluding or limiting+any implied license or other defenses to infringement that may+otherwise be available to you under applicable patent law.++  12. No Surrender of Others' Freedom.++  If conditions are imposed on you (whether by court order, agreement or+otherwise) that contradict the conditions of this License, they do not+excuse you from the conditions of this License.  If you cannot convey a+covered work so as to satisfy simultaneously your obligations under this+License and any other pertinent obligations, then as a consequence you may+not convey it at all.  For example, if you agree to terms that obligate you+to collect a royalty for further conveying from those to whom you convey+the Program, the only way you could satisfy both those terms and this+License would be to refrain entirely from conveying the Program.++  13. Remote Network Interaction; Use with the GNU General Public License.++  Notwithstanding any other provision of this License, if you modify the+Program, your modified version must prominently offer all users+interacting with it remotely through a computer network (if your version+supports such interaction) an opportunity to receive the Corresponding+Source of your version by providing access to the Corresponding Source+from a network server at no charge, through some standard or customary+means of facilitating copying of software.  This Corresponding Source+shall include the Corresponding Source for any work covered by version 3+of the GNU General Public License that is incorporated pursuant to the+following paragraph.++  Notwithstanding any other provision of this License, you have+permission to link or combine any covered work with a work licensed+under version 3 of the GNU General Public License into a single+combined work, and to convey the resulting work.  The terms of this+License will continue to apply to the part which is the covered work,+but the work with which it is combined will remain governed by version+3 of the GNU General Public License.++  14. Revised Versions of this License.++  The Free Software Foundation may publish revised and/or new versions of+the GNU Affero General Public License from time to time.  Such new versions+will be similar in spirit to the present version, but may differ in detail to+address new problems or concerns.++  Each version is given a distinguishing version number.  If the+Program specifies that a certain numbered version of the GNU Affero General+Public License "or any later version" applies to it, you have the+option of following the terms and conditions either of that numbered+version or of any later version published by the Free Software+Foundation.  If the Program does not specify a version number of the+GNU Affero General Public License, you may choose any version ever published+by the Free Software Foundation.++  If the Program specifies that a proxy can decide which future+versions of the GNU Affero General Public License can be used, that proxy's+public statement of acceptance of a version permanently authorizes you+to choose that version for the Program.++  Later license versions may give you additional or different+permissions.  However, no additional obligations are imposed on any+author or copyright holder as a result of your choosing to follow a+later version.++  15. Disclaimer of Warranty.++  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++  16. Limitation of Liability.++  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF+SUCH DAMAGES.++  17. Interpretation of Sections 15 and 16.++  If the disclaimer of warranty and limitation of liability provided+above cannot be given local legal effect according to their terms,+reviewing courts shall apply local law that most closely approximates+an absolute waiver of all civil liability in connection with the+Program, unless a warranty or assumption of liability accompanies a+copy of the Program in return for a fee.++                     END OF TERMS AND CONDITIONS++            How to Apply These Terms to Your New Programs++  If you develop a new program, and you want it to be of the greatest+possible use to the public, the best way to achieve this is to make it+free software which everyone can redistribute and change under these terms.++  To do so, attach the following notices to the program.  It is safest+to attach them to the start of each source file to most effectively+state the exclusion of warranty; and each file should have at least+the "copyright" line and a pointer to where the full notice is found.++    <one line to give the program's name and a brief idea of what it does.>+    Copyright (C) <year>  <name of author>++    This program is free software: you can redistribute it and/or modify+    it under the terms of the GNU Affero General Public License as published+    by the Free Software Foundation, either version 3 of the License, or+    (at your option) any later version.++    This program is distributed in the hope that it will be useful,+    but WITHOUT ANY WARRANTY; without even the implied warranty of+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+    GNU Affero General Public License for more details.++    You should have received a copy of the GNU Affero General Public License+    along with this program.  If not, see <https://www.gnu.org/licenses/>.++Also add information on how to contact you by electronic and paper mail.++  If your software can interact with users remotely through a computer+network, you should also make sure that it provides a way for users to+get its source.  For example, if your program is a web application, its+interface could display a "Source" link that leads users to an archive+of the code.  There are many ways you could offer source, and different+solutions will be better for different programs; see section 13 for the+specific requirements.++  You should also get your employer (if you work as a programmer) or school,+if any, to sign a "copyright disclaimer" for the program, if necessary.+For more information on this, and how to apply and follow the GNU AGPL, see+<https://www.gnu.org/licenses/>.
+ README.md view
@@ -0,0 +1,38 @@+# emanote++[![AGPL](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://en.wikipedia.org/wiki/Affero_General_Public_License)+[![built with nix](https://img.shields.io/badge/Built_With-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5)](https://builtwithnix.org)+[![Matrix](https://img.shields.io/matrix/ema:matrix.org)](https://app.element.io/#/room/#ema:matrix.org "Chat on Matrix")++Spiritual successor to [neuron](https://neuron.zettel.page), based on [Ema](https://ema.srid.ca).++Create beautiful websites -- such as personal webpage, blog, wiki, Zettelkasten, notebook, knowledge-base, documentation, etc. from future-proof plain-text notes and arbitrary data -- with live preview that updates in real-time.++## Installing and using++https://emanote.srid.ca/start/install++### Examples++https://emanote.srid.ca/examples++## Developing++Emanote is written in Haskell. Thanks to Nix, this repository is pre-configured to provide a delightful development experience with full IDE support in Visual Studio Code. Follow these steps:++- [Install Nix](https://nixos.org/download.html) & [enable Flakes](https://nixos.wiki/wiki/Flakes#Installing_flakes)+- Run `nix develop -c haskell-language-server` to sanity check your environment (Expect it to download and build a bunch of things the first time)+- Open the repository [as single-folder workspace](https://code.visualstudio.com/docs/editor/workspaces#_singlefolder-workspaces) in Visual Studio Code+    - Install the [workspace recommended](https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions) extensions+    - <kbd>Ctrl+Shift+P</kbd> to run command "Nix-Env: Select Environment" and select `shell.nix`. The extension will ask you to reload VSCode at the end.+- Press <kbd>Ctrl+Shift+B</kbd> in VSCode, or run `bin/run` in terminal, to launch the dev version Emanote on `./docs`, then navigate to http://localhost:9010/+  - Changing the Haskell sources will recompile and reload this instance automatically.++All but the final step need to be done only once. See [architecture.md](docs/architecture.md) for a high-level overview of the codebase.++## Discussion++To discuss the emanote project, [join Matrix][matrix] or post in [GitHub Discussions][ghdiscuss].++[matrix]: https://matrix.to/#/#ema:matrix.org+[ghdiscuss]: https://github.com/srid/emanote/discussions
+ default/_emanote-bin/compile-css view
@@ -0,0 +1,4 @@+#!/usr/bin/env bash+set -xe++echo "Deprecation Warning: this script should not be used anymore"
+ default/_emanote-live-server/tailwind/2.2.2/tailwind.min.css view

file too large to diff

+ default/_emanote-static/emanote-logo.svg view
@@ -0,0 +1,92 @@+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 504.124 504.124" style="enable-background:new 0 0 504.124 504.124;" xml:space="preserve">
+<path style="fill:#EA0C5C;" d="M482.951,396.006c0,8.531-6.908,15.447-15.447,15.447H95.461c-8.515,0-15.439-6.916-15.439-15.447
+	v-64.37c0-8.531,6.924-15.447,15.439-15.447h372.043c8.539,0,15.447,6.916,15.447,15.447L482.951,396.006L482.951,396.006z"/>
+<path style="fill:#BC0852;" d="M467.512,316.197c8.539,0,15.447,6.916,15.447,15.447v64.37c0,8.531-6.908,15.447-15.447,15.447
+	H95.461"/>
+<path style="fill:#FF7171;" d="M80.022,331.643c0-8.531,6.924-15.447,15.439-15.447h372.043c8.539,0,15.447,6.916,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M399.991,396.699c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.552,0-2.82-1.26-2.82-2.812V330.95
+		c0-1.56,1.268-2.82,2.82-2.82l0,0c1.544,0,2.812,1.26,2.812,2.82V396.699z"/>
+	<path style="fill:#F2DB06;" d="M417.84,396.699c0,1.552-1.26,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812V330.95
+		c0-1.56,1.26-2.82,2.82-2.82l0,0c1.552,0,2.812,1.26,2.812,2.82V396.699z"/>
+	<path style="fill:#F2DB06;" d="M435.689,396.699c0,1.552-1.252,2.812-2.82,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812V330.95
+		c0-1.56,1.26-2.82,2.804-2.82l0,0c1.568,0,2.82,1.26,2.82,2.82V396.699z"/>
+</g>
+<path style="fill:#038462;" d="M424.102,302.089c0,8.523-6.916,15.447-15.447,15.447H36.613c-8.531,0-15.447-6.924-15.447-15.447
+	v-64.37c0-8.539,6.916-15.447,15.447-15.447h372.043c8.531,0,15.447,6.908,15.447,15.447V302.089z"/>
+<path style="fill:#047769;" d="M408.655,222.272c8.531,0,15.447,6.908,15.447,15.447v64.37c0,8.523-6.916,15.447-15.447,15.447
+	H36.613"/>
+<path style="fill:#2EAF8D;" d="M21.174,237.719c0-8.539,6.916-15.447,15.447-15.447h372.035c8.531,0,15.447,6.908,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M341.134,302.775c0,1.552-1.26,2.812-2.82,2.812l0,0c-1.544,0-2.796-1.26-2.796-2.812v-65.757
+		c0-1.552,1.252-2.812,2.796-2.812l0,0c1.56,0,2.82,1.26,2.82,2.812V302.775z"/>
+	<path style="fill:#F2DB06;" d="M358.984,302.775c0,1.552-1.26,2.812-2.828,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812v-65.757
+		c0-1.552,1.26-2.812,2.804-2.812l0,0c1.568,0,2.828,1.26,2.828,2.812V302.775z"/>
+	<path style="fill:#F2DB06;" d="M376.833,302.775c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812v-65.757
+		c0-1.552,1.26-2.812,2.82-2.812l0,0c1.544,0,2.812,1.26,2.812,2.812V302.775z"/>
+</g>
+<path style="fill:#115989;" d="M424.102,488.678c0,8.523-6.916,15.447-15.447,15.447H36.613c-8.531,0-15.447-6.924-15.447-15.447
+	v-64.37c0-8.531,6.916-15.447,15.447-15.447h372.043c8.531,0,15.447,6.916,15.447,15.447V488.678z"/>
+<path style="fill:#003B5B;" d="M408.655,408.861c8.531,0,15.447,6.916,15.447,15.447v64.37c0,8.523-6.916,15.447-15.447,15.447
+	H36.613"/>
+<path style="fill:#158ACC;" d="M21.174,424.308c0-8.531,6.916-15.447,15.447-15.447h372.035c8.531,0,15.447,6.916,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M341.134,489.371c0,1.552-1.26,2.812-2.82,2.812l0,0c-1.544,0-2.796-1.26-2.796-2.812v-65.757
+		c0-1.536,1.252-2.812,2.796-2.812l0,0c1.56,0,2.82,1.276,2.82,2.812V489.371z"/>
+	<path style="fill:#F2DB06;" d="M358.984,489.371c0,1.552-1.26,2.812-2.828,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812v-65.757
+		c0-1.536,1.26-2.812,2.804-2.812l0,0c1.568,0,2.828,1.276,2.828,2.812V489.371z"/>
+	<path style="fill:#F2DB06;" d="M376.833,489.371c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812v-65.757
+		c0-1.536,1.26-2.812,2.82-2.812l0,0c1.544,0,2.812,1.276,2.812,2.812V489.371z"/>
+</g>
+<circle style="fill:#FFAA00;" cx="252.062" cy="131.853" r="92.987"/>
+<path style="fill:#FF8500;" d="M252.062,38.882c51.358,0,92.987,41.622,92.987,92.979s-41.63,92.979-92.987,92.979"/>
+<path style="fill:#54391E;" d="M252.566,57.038c-4.151,0-7.507-3.363-7.507-7.507c0-17.258-14.052-31.319-31.327-31.319
+	c-4.151,0-7.507-3.356-7.507-7.515c0-4.151,3.356-7.507,7.507-7.507c25.553,0,46.34,20.787,46.34,46.34
+	C260.081,53.675,256.725,57.038,252.566,57.038z"/>
+<path style="fill:#382413;" d="M213.741,17.275c-0.819,0-1.575,0.221-2.332,0.473c0.756,0.244,1.512,0.473,2.332,0.473
+	c17.266,0,31.327,14.052,31.327,31.319c0,4.143,3.356,7.507,7.507,7.507c2.725,0,5.01-1.52,6.333-3.694
+	C254.197,32.722,235.757,17.275,213.741,17.275z"/>
+<path style="fill:#7EC441;" d="M305.799,3.9c-12.768-8.271-34.068-3.119-50.105,12.902c-16.03,16.037-13.383,29.562-3.718,40.921
+	L305.799,3.9z"/>
+<path style="fill:#559E1B;" d="M305.696,3.79c8.263,12.761,3.119,34.068-12.91,50.105c-16.03,16.03-29.554,13.375-40.921,3.718
+	L305.696,3.79z"/>
+<g>
+	<path style="fill:#F4C951;" d="M236.285,55.187c7.593,7.593-9.051,17.912-25.687,34.54s-26.947,33.264-34.54,25.679
+		c-7.57-7.578-0.236-27.199,16.384-43.827C209.078,54.943,228.691,47.609,236.285,55.187z"/>
+	<circle style="fill:#F4C951;" cx="177.129" cy="129.971" r="4.072"/>
+	<circle style="fill:#F4C951;" cx="176.814" cy="147.828" r="1.252"/>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
+ default/_emanote-static/fonts/Work_Sans/WorkSans-Italic-VariableFont_wght.ttf view

binary file changed (absent → 335056 bytes)

+ default/_emanote-static/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf view

binary file changed (absent → 359628 bytes)

+ default/_emanote-static/inverted-tree.css view
@@ -0,0 +1,118 @@+/* Adapted from https://neuron.zettel.page/uplink-tree+   Originally from https://codepen.io/philippkuehn/pen/QbrOaN+*/++body .tree.flipped {+  -webkit-transform: rotate(180deg);+  -moz-transform: rotate(180deg);+  -ms-transform: rotate(180deg);+  -o-transform: rotate(180deg);+  transform: rotate(180deg);+}+body .tree {+  overflow: auto;+  /* See more-head.tpl */+  font-family: 'WorkSans', sans-serif;+  font-variation-settings: 'wght' 475;++  font-size: 0.8em;+}+body .tree ul.root {+  padding-top: 0px;+  margin-top: 0px;+}+body .tree ul {+  position: relative;+  padding: 1em 0px 0px 0px;+  white-space: nowrap;+  margin: 0px auto 0px auto;+  text-align: center;+}+body .tree ul::after {+  content: "";+  display: table;+  clear: both;+}+body .tree ul:last-child {+  padding-bottom: 0.1em;+}+body .tree li {+  display: inline-block;+  vertical-align: top;+  text-align: center;+  list-style-type: none;+  position: relative;+  padding: 1em 0.5em 0em 0.5em;+}+body .tree li::before {+  content: "";+  position: absolute;+  top: 0px;+  right: 50%;+  border-top: solid 2px #cccccc;+  width: 60%;+  height: 1.19999em;+}+body .tree li::after {+  content: "";+  position: absolute;+  top: 0px;+  right: 50%;+  border-top: solid 2px #cccccc;+  width: 50%;+  height: 1.19999em;+}+body .tree li::after {+  right: auto;+  left: 50%;+  border-left: solid 2px #cccccc;+}+body .tree li:only-child {+  padding-top: 0em;+}+body .tree li:only-child::after {+  display: none;+}+body .tree li:only-child::before {+  display: none;+}+body .tree li:first-child::before {+  border-style: none;+  border-width: 0px;+}+body .tree li:first-child::after {+  border-radius: 5px 0px 0px 0px;+}+body .tree li:last-child::after {+  border-style: none;+  border-width: 0px;+}+body .tree li:last-child::before {+  border-right: solid 2px #cccccc;+  border-radius: 0px 5px 0px 0px;+}+body .tree ul ul::before {+  content: "";+  position: absolute;+  top: 0px;+  left: 50%;+  border-left: solid 2px #cccccc;+  width: 0px;+  height: 1.19999em;+}+body .tree li div.forest-link {+  border: solid 2px #cccccc;+  padding: 0.2em 0.29999em 0.2em 0.29999em;+  text-decoration: none;+  display: inline-block;+  border-radius: 5px 5px 5px 5px;+  position: relative;+  top: 2px;+}+body .tree.flipped li div.forest-link {+  -webkit-transform: rotate(180deg);+  -moz-transform: rotate(180deg);+  -ms-transform: rotate(180deg);+  -o-transform: rotate(180deg);+  transform: rotate(180deg);+}
+ default/_emanote-static/stork/flat.css view
@@ -0,0 +1,191 @@+.stork-wrapper-flat {+  position: relative;+  font-family: inherit;+  box-sizing: border-box;+  font-size: 1em;++  --stork-blue-2: #a5d8ff;+  --stork-blue-3: #74c0fc;+  --stork-blue-4: #4dabf7;+  --stork-blue-5: #339af0;+  --stork-blue-7: #1c7ed6;+  --stork-gray-8: #343a40;+  --stork-gray-9: #212529;+  --stork-yellow-2: #ffec99;++  --stork-border-color: hsl(0, 0%, 80%);+  --stork-background-color: hsla(0, 0%, 97%);+  --stork-text-color: var(--stork-gray-9);++  --stork-input-height: 2.4em;+}++.stork-wrapper-flat *,+.stork-wrapper-flat *:before,+.stork-wrapper-flat *:after {+  box-sizing: border-box;+}++.stork-wrapper-flat .stork-input {+  width: 100%;+  height: var(--stork-input-height);+  font-size: 1em;+  padding: 0.4em 0.8em;+  position: relative;+  border: 2px solid var(--stork-border-color);+  border-radius: calc(var(--stork-input-height) / 2);+  background-color: var(--stork-background-color);+  color: var(--stork-text-color);+  font-family: inherit;+}++.stork-wrapper-flat .stork-input:focus {+  outline: none;+}++.stork-wrapper-flat .stork-progress {+  position: absolute;+  display: block;+  content: "";+  bottom: 1px;+  background-color: var(--stork-blue-5);+  box-shadow: 0 0 8px var(--stork-blue-4);+  height: 1px;+  transition: width 0.25s ease, opacity 0.4s ease 0.4s;+  margin-left: calc(var(--stork-input-height) / 2);+  max-width: calc(100% - var(--stork-input-height));+}++.stork-wrapper-flat .stork-output {+  position: absolute;+  width: 100%;+  margin-top: 0.5em;+  border-radius: 6px;+  display: flex;+  flex-direction: column;+  z-index: 100;+  color: var(--stork-text-color);+  font-weight: 400;+  font-family: inherit;+}++.stork-wrapper-flat .stork-attribution a:link,+.stork-wrapper-flat .stork-attribution a:visited {+  color: var(--stork-blue-7);+}++.stork-wrapper-flat .stork-output-visible {+  border: 2px solid var(--stork-border-color);+  background: var(--stork-background-color);+}++.stork-wrapper-flat .stork-message {+  width: 100%;+  padding: 0.5em 1em;+  color: var(--stork-text-color);+}++.stork-wrapper-flat .stork-attribution {+  width: 100%;+  padding: 0.5em 1em;+  font-size: 0.8em;+  color: var(--stork-text-color);+}++.stork-wrapper-flat .stork-results {+  margin: 0;+  padding: 0;+  width: 100%;+  list-style-type: none;+  max-height: 25em;+  overflow-y: scroll;+  border-top: 1px solid var(--stork-border-color);+  border-bottom: 1px solid var(--stork-border-color);+}++.stork-wrapper-flat .stork-result:not(:last-child) {+  border-bottom: 1px solid var(--stork-border-color);+}++.stork-wrapper-flat .stork-result.selected {+  background: var(--stork-blue-2);+}++.stork-wrapper-flat .stork-result a:link {+  padding: 1em;+  display: block;+  color: currentcolor;+  text-decoration: none;+}++.stork-wrapper-flat .stork-result p {+  margin: 0;+}++.stork-wrapper-flat .stork-title {+  font-weight: bold;+  font-size: 0.95em;+  margin: 0;+  color: var(--stork-text-color);++  /* Flexbox container for the title and the score, when debugging */+  display: flex;+  justify-content: space-between;+}++.stork-wrapper-flat .stork-excerpt-container {+  margin-top: 0.75em;+}++.stork-wrapper-flat .stork-excerpt {+  font-size: 0.8em;+  line-height: 1;+  margin: 0;+  color: var(--stork-gray-8);++  /* Flexbox container for the title and the score, when debugging */+  display: flex;+  justify-content: space-between;+}++.stork-wrapper-flat .stork-excerpt:not(:last-of-type) {+  margin-bottom: 0.6em;+}++.stork-wrapper-flat .stork-highlight {+  background-color: var(--stork-yellow-2);+  padding: 0 0.1em;+}++.stork-wrapper-flat .stork-error {+  outline: 2px solid #c92a2a;+}++.stork-wrapper-flat .stork-close-button {+  position: absolute;+  right: 0;+  margin: 0.5em 0.5em;+  height: 1.4em;+  width: 1.4em;+  padding: 0px;+  background: hsl(0, 0%, 85%);+  border: 1px solid hsla(0, 0%, 70%);+  font-size: 1em;+  color: hsl(0, 0%, 50%);+  border-radius: 50%;+  line-height: 1;+}++.stork-wrapper-flat .stork-close-button svg {+  width: 11px;+  height: 11px;+}++.stork-wrapper-flat .stork-close-button:hover {+  background: hsla(0, 0%, 78%);+  cursor: pointer;+}++.stork-wrapper-flat .stork-close-button:active {+  background: hsla(0, 0%, 65%);+}
+ default/_emanote-static/stork/stork.js view
@@ -0,0 +1,2 @@+var stork;(()=>{"use strict";var e={214:(e,t,n)=>{let r;n.r(t),n.d(t,{default:()=>_,wasm_register_index:()=>v,wasm_search:()=>y,wasm_stork_version:()=>w});const o=new Array(32).fill(void 0);function s(e){return o[e]}o.push(void 0,null,!0,!1);let i=o.length;let a=0,l=null;function u(){return null!==l&&l.buffer===r.memory.buffer||(l=new Uint8Array(r.memory.buffer)),l}let c=new TextEncoder("utf-8");const d="function"==typeof c.encodeInto?function(e,t){return c.encodeInto(e,t)}:function(e,t){const n=c.encode(e);return t.set(n),{read:e.length,written:n.length}};function h(e,t,n){if(void 0===n){const n=c.encode(e),r=t(n.length);return u().subarray(r,r+n.length).set(n),a=n.length,r}let r=e.length,o=t(r);const s=u();let i=0;for(;i<r;i++){const t=e.charCodeAt(i);if(t>127)break;s[o+i]=t}if(i!==r){0!==i&&(e=e.slice(i)),o=n(o,r,r=i+3*e.length);const t=u().subarray(o+i,o+r);i+=d(e,t).written}return a=i,o}let f=null;function g(){return null!==f&&f.buffer===r.memory.buffer||(f=new Int32Array(r.memory.buffer)),f}let p=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});function m(e,t){return p.decode(u().subarray(e,e+t))}function v(e,t){try{const d=r.__wbindgen_add_to_stack_pointer(-16);var n=h(e,r.__wbindgen_malloc,r.__wbindgen_realloc),o=a,s=function(e,t){const n=t(1*e.length);return u().set(e,n/1),a=e.length,n}(t,r.__wbindgen_malloc),i=a;r.wasm_register_index(d,n,o,s,i);var l=g()[d/4+0],c=g()[d/4+1];return m(l,c)}finally{r.__wbindgen_add_to_stack_pointer(16),r.__wbindgen_free(l,c)}}function y(e,t){try{const c=r.__wbindgen_add_to_stack_pointer(-16);var n=h(e,r.__wbindgen_malloc,r.__wbindgen_realloc),o=a,s=h(t,r.__wbindgen_malloc,r.__wbindgen_realloc),i=a;r.wasm_search(c,n,o,s,i);var l=g()[c/4+0],u=g()[c/4+1];return m(l,u)}finally{r.__wbindgen_add_to_stack_pointer(16),r.__wbindgen_free(l,u)}}function w(){try{const n=r.__wbindgen_add_to_stack_pointer(-16);r.wasm_stork_version(n);var e=g()[n/4+0],t=g()[n/4+1];return m(e,t)}finally{r.__wbindgen_add_to_stack_pointer(16),r.__wbindgen_free(e,t)}}p.decode();const _=async function e(t){void 0===t&&(t=new URL("stork_bg.wasm",function(e){const t=n.p;let r="";return(!t||t.indexOf("://")<0)&&(r+=window.location.protocol+"//"+window.location.host),r+=t||"/",r+e}("node_modules/stork-search/stork.js")));const l={wbg:{}};l.wbg.__wbg_new_693216e109162396=function(){return function(e){i===o.length&&o.push(o.length+1);const t=i;return i=o[t],o[t]=e,t}(new Error)},l.wbg.__wbg_stack_0ddaca5d1abfb52f=function(e,t){var n=h(s(t).stack,r.__wbindgen_malloc,r.__wbindgen_realloc),o=a;g()[e/4+1]=o,g()[e/4+0]=n},l.wbg.__wbg_error_09919627ac0992f5=function(e,t){try{console.error(m(e,t))}finally{r.__wbindgen_free(e,t)}},l.wbg.__wbindgen_object_drop_ref=function(e){!function(e){const t=s(e);(function(e){e<36||(o[e]=i,i=e)})(e)}(e)},("string"==typeof t||"function"==typeof Request&&t instanceof Request||"function"==typeof URL&&t instanceof URL)&&(t=fetch(t));const{instance:u,module:c}=await async function(e,t){if("function"==typeof Response&&e instanceof Response){if("function"==typeof WebAssembly.instantiateStreaming)try{return await WebAssembly.instantiateStreaming(e,t)}catch(t){if("application/wasm"==e.headers.get("Content-Type"))throw t;console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",t)}const n=await e.arrayBuffer();return await WebAssembly.instantiate(n,t)}{const n=await WebAssembly.instantiate(e,t);return n instanceof WebAssembly.Instance?{instance:n,module:e}:n}}(await t,l);return r=u.exports,e.__wbindgen_wasm_module=c,r}},914:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.calculateOverriddenConfig=t.defaultConfig=void 0;var r=n(445),o=n(466);t.defaultConfig={showProgress:!0,printIndexInfo:!1,showScores:!1,showCloseButton:!0,minimumQueryLength:3,forceOverwrite:!1,resultNoun:{singular:"file",plural:"files"},onQueryUpdate:void 0,onResultSelected:void 0,onResultsHidden:void 0,onInputCleared:void 0,transformResultUrl:function(e){return e}},t.calculateOverriddenConfig=function(e){var n=(0,o.difference)(Object.keys(e),Object.keys(t.defaultConfig));if(n.length>0){var s=(0,o.plural)(n.length,"key","keys"),i=JSON.stringify(n);return new r.default("Invalid ".concat(s," in config object: ").concat(i))}for(var a=Object.assign({},t.defaultConfig),l=0,u=Object.keys(t.defaultConfig);l<u.length;l++){var c=u[l],d=e[c];void 0!==d&&(a[c]=d)}return a}},227:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.existsBeyondContainerBounds=t.setText=t.clear=t.add=t.create=void 0,t.create=function(e,t){var n=document.createElement(e);return t.classNames&&n.setAttribute("class",t.classNames.join(" ")),n},t.add=function(e,t,n){n.insertAdjacentElement(t,e)},t.clear=function(e){for(;e&&e.firstChild;)e.removeChild(e.firstChild)},t.setText=function(e,t){var n=document.createTextNode(t);e&&e.firstChild?e.replaceChild(n,e.firstChild):e&&e.appendChild(n)},t.existsBeyondContainerBounds=function(e,t){var n=e.getBoundingClientRect(),r=t.getBoundingClientRect();return n.bottom>r.bottom||n.top<r.top}},934:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Entity=void 0;var r=n(139),o=n(771),s=n(214),i=n(445),a=function(){function e(e,t,n){var r=this;this._state="initialized",this.downloadProgress=0,this.results=[],this.totalResultCount=0,this.eventListenerFunctions={},this.highlightedResult=0,this.resultsVisible=!1,this.hoverSelectEnabled=!0,this.setDownloadProgress=function(e){r.state="loading",r.downloadProgress=e,r.config.showProgress&&r.render()},this.name=e,this.url=t,this.config=n}return Object.defineProperty(e.prototype,"state",{get:function(){return this._state},set:function(e){this._state=e,this.render()},enumerable:!1,configurable:!0}),e.prototype.getCurrentMessage=function(){if(!this.domManager)return null;var e=this.domManager.getQuery();return"error"===this.state?"Error! Check the browser console.":"ready"!=this.state?"Loading...":(null==e?void 0:e.length)<this.config.minimumQueryLength?"Filtering...":this.results?0===this.totalResultCount?"No ".concat(this.config.resultNoun.plural," found."):1===this.totalResultCount?"1 ".concat(this.config.resultNoun.singular," found."):"".concat(this.totalResultCount," ").concat(this.config.resultNoun.plural," found."):null},e.prototype.generateRenderConfig=function(){return{results:this.results,resultsVisible:!0,showScores:this.config.showScores,message:this.getCurrentMessage(),showProgress:this.config.showProgress,progress:this.downloadProgress,state:this.state}},e.prototype.render=function(){this.domManager&&this.domManager.render(this.generateRenderConfig())},e.prototype.registerIndex=function(e){var t=this;return new Promise((function(n,r){var o=JSON.parse((0,s.wasm_register_index)(t.name,e));o.error?r(new i.default(o.error)):(t.config.printIndexInfo&&console.log(o),t.state="ready",n(o))}))},e.prototype.attachToDom=function(){this.domManager=new o.EntityDom(this.name,this),this.render()},e.prototype.injestSearchData=function(e){var t=this;this.results=e.results,this.totalResultCount=e.total_hit_count,this.highlightedResult=0;var n=e.url_prefix||"";this.results.map((function(e){var r="",o=e.excerpts.map((function(e){return e.internal_annotations})).filter((function(e){return!!e}))[0];if(o&&o[0]){var s=o[0];"string"==typeof s.a&&(r+=s.a)}e.excerpts&&e.excerpts[0]&&e.excerpts[0].internal_annotations&&e.excerpts[0].internal_annotations[0]&&e.excerpts[0].internal_annotations[0].a&&"string"==typeof e.excerpts[0].internal_annotations[0].a&&(r=e.excerpts[0].internal_annotations[0].a),e.entry.url=t.config.transformResultUrl("".concat(n).concat(e.entry.url).concat(r))})),this.render()},e.prototype.getSanitizedResults=function(){var e=this.results;return e.map((function(e){delete e.title_highlight_ranges,e.excerpts.map((function(e){delete e.highlight_ranges,delete e.internal_annotations}))})),e},e.prototype.setDownloadError=function(){this.state="error"},e.prototype.performSearch=function(e){if("ready"===this.state){if(e.length<this.config.minimumQueryLength)return this.results=[],void this.render();try{var t=(0,r.resolveSearch)(this.name,e);if(!t)return;this.injestSearchData(t),this.config.onQueryUpdate&&this.config.onQueryUpdate(e,this.getSanitizedResults())}catch(e){console.error(e)}}else this.render()},e}();t.Entity=a},771:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EntityDom=void 0;var r=n(227),o=n(9),s={results:[],resultsVisible:!1,showScores:!1,message:null,showProgress:!1,progress:1,state:"ready"},i=function(){function e(e,t){var n,o,i=this;this.scrollAnchorPoint="end",this.entity=t;var a=[{selector:'input[data-stork="'.concat(e,'"]'),elementName:"input"},{selector:'div[data-stork="'.concat(e,'-output"]'),elementName:"output"}].map((function(t){var n=document.querySelector(t.selector);if(!n)throw new Error('Could not register search box "'.concat(e,'": ').concat(t.elementName," element not found. Make sure an element matches the query selector `").concat(t.selector,"`"));return n})),l=a[0],u=a[1];this.elements={input:l,output:u,list:(0,r.create)("ul",{classNames:["stork-results"]}),attribution:(0,r.create)("div",{classNames:["stork-attribution"]}),progress:(0,r.create)("div",{classNames:["stork-progress"]}),message:(0,r.create)("div",{classNames:["stork-message"]}),closeButton:(0,r.create)("button",{classNames:["stork-close-button"]})},this.elements.input.removeEventListener("input",this.entity.eventListenerFunctions.inputInputEvent),this.elements.input.removeEventListener("keydown",this.entity.eventListenerFunctions.inputKeydownEvent),this.entity.eventListenerFunctions={inputInputEvent:function(e){i.handleInputEvent(e)},inputKeydownEvent:function(e){i.handleKeyDownEvent(e)}},this.elements.input.addEventListener("input",this.entity.eventListenerFunctions.inputInputEvent),this.elements.input.addEventListener("keydown",this.entity.eventListenerFunctions.inputKeydownEvent),null===(n=this.elements.list)||void 0===n||n.addEventListener("mousemove",(function(){i.hoverSelectEnabled=!0})),this.elements.attribution.innerHTML='Powered by <a href="https://stork-search.net">Stork</a>',this.elements.closeButton.innerHTML='\n<svg height="0.8em" viewBox="0 0 23 24" xmlns="http://www.w3.org/2000/svg">\n<g fill="none" fill-rule="evenodd" stroke-linecap="round">\n<g transform="translate(-700 -149)" stroke="currentcolor" stroke-width="4">\n<line id="a" x1="702.5" x2="720" y1="152.5" y2="170"/>\n<line transform="translate(711 161) rotate(-90) translate(-711 -161)" x1="702.5" x2="720" y1="152.5" y2="170"/>\n</g>\n</g>\n</svg>',this.entity.config.showProgress&&(0,r.add)(this.elements.progress,"afterend",this.elements.input),null===(o=this.elements.closeButton)||void 0===o||o.addEventListener("click",(function(){i.elements.input.value="",i.elements.input.focus(),i.render(s);var e=[i.entity.config.onInputCleared,i.entity.config.onResultsHidden],t=e[0],n=e[1];t&&t(),n&&n()}))}return e.prototype.clearDom=function(){var e;(0,r.clear)(this.elements.output),(0,r.clear)(this.elements.list),null===(e=this.elements.closeButton)||void 0===e||e.remove(),this.elements.output.classList.remove("stork-output-visible")},e.prototype.render=function(e){var t,n=this,s=this.elements.input.value;if(this.clearDom(),this.lastRenderState=e,e.showProgress){var i=function(){switch(e.state){case"ready":case"error":return 1;case"initialized":case"loading":return.9*e.progress+.05}}();i<1?(this.elements.progress.style.width="".concat(100*i,"%"),this.elements.progress.style.opacity="1"):(this.elements.progress.style.width="100%",this.elements.progress.style.opacity="0")}if("error"===e.state&&this.elements.input.classList.add("stork-error"),this.getQuery().length>0&&e.resultsVisible&&(this.elements.output.classList.add("stork-output-visible"),(0,r.add)(this.elements.message,"beforeend",this.elements.output)),e.message&&(0,r.setText)(this.elements.message,e.message),(null===(t=e.results)||void 0===t?void 0:t.length)>0&&e.resultsVisible){(0,r.add)(this.elements.list,"beforeend",this.elements.output);for(var a=function(t){var s=e.results[t],i={selected:t===l.highlightedResult,showScores:e.showScores},a=(0,o.resultToListItem)(s,i);(0,r.add)(a,"beforeend",l.elements.list),a.addEventListener("mousemove",(function(){n.hoverSelectEnabled&&t!==n.highlightedResult&&n.changeHighlightedResult({to:t,shouldScrollTo:!1})})),a.addEventListener("mouseleave",(function(){n.hoverSelectEnabled&&t===n.highlightedResult&&n.changeHighlightedResult({to:-1,shouldScrollTo:!1})})),a.addEventListener("click",(function(e){e.preventDefault(),n.selectResult()}))},l=this,u=0;u<e.results.length;u++)a(u);(0,r.add)(this.elements.attribution,"beforeend",this.elements.output)}((null==s?void 0:s.length)||0)>0&&this.entity.config.showCloseButton&&(0,r.add)(this.elements.closeButton,"afterend",this.elements.input)},e.prototype.selectResult=function(){if(null!=this.highlightedResult){var e=this.entity.results[this.highlightedResult];this.entity.config.onResultSelected?Promise.resolve(this.entity.config.onResultSelected(this.getQuery(),e)).finally((function(){window.location.assign(e.entry.url)})):window.location.assign(e.entry.url)}},e.prototype.changeHighlightedResult=function(e){var t,n=this.highlightedResult,o=Math.max(-1,Math.min(this.entity.results.length-1,e.to));this.highlightedResult=o,this.scrollAnchorPoint=(n||0)<o?"end":"start";for(var s=null,i=0;i<this.entity.results.length;i++){var a=null===(t=this.elements.list)||void 0===t?void 0:t.children[i];if(a){var l="selected";i==o?(a.classList.add(l),s=a):a.classList.remove(l)}}return e.shouldScrollTo&&(this.hoverSelectEnabled=!1,s&&(0,r.existsBeyondContainerBounds)(s,this.elements.list)&&s.scrollIntoView({behavior:"smooth",block:this.scrollAnchorPoint,inline:"nearest"})),o},e.prototype.handleKeyDownEvent=function(e){switch(e.keyCode){case 40:if(null==this.highlightedResult)this.changeHighlightedResult({to:0,shouldScrollTo:!0});else{var t=Math.min(this.highlightedResult+1,this.entity.results.length-1);this.changeHighlightedResult({to:t,shouldScrollTo:!0})}break;case 38:null!=this.highlightedResult&&(t=Math.max(0,this.highlightedResult-1),this.changeHighlightedResult({to:t,shouldScrollTo:!0}));break;case 13:this.selectResult();break;case 27:if(this.lastRenderState.resultsVisible)this.render(s),(n=this.entity.config.onResultsHidden)&&n();else if(this.elements.input.value.length>0){var n;this.elements.input.value="",this.render(s),(n=this.entity.config.onInputCleared)&&n()}break;default:return}},e.prototype.handleInputEvent=function(e){this.entity.performSearch(e.target.value)},e.prototype.getQuery=function(){return this.elements.input.value},e}();t.EntityDom=i},129:function(e,t,n){var r=this&&this.__assign||function(){return r=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e},r.apply(this,arguments)};Object.defineProperty(t,"__esModule",{value:!0}),t.debug=t.entityIsReady=t.attachToDom=t.register=void 0;var o=n(934),s=n(914),i=n(794),a=n(258),l=n(445),u={};t.register=function(e,t,n){return new Promise((function(r,c){var d=(0,s.calculateOverriddenConfig)(n);if(d instanceof l.default)c(d);else{u[e]&&!d.forceOverwrite&&c(new l.default("You're registering an index named `".concat(e,"`, but that already exists. If this is expected, set forceOverwrite to true in your Javascript config to allow overwriting indexes.")));var h=new o.Entity(e,t,d);u[e]=h,(0,i.loadIndexFromUrl)(t,{progress:function(e){h.setDownloadProgress(e)},load:function(e){(0,a.runAfterWasmLoaded)((function(){h.registerIndex(new Uint8Array(e)).then(r).catch(c)}),(function(){h.state="error"}))},error:function(){h.setDownloadError(),c()}})}}))},t.attachToDom=function(e){if(!u[e])throw new Error("Index ".concat(e," has not been registered!"));u[e].attachToDom()},t.entityIsReady=function(e){var t;return"ready"===(null===(t=u[e])||void 0===t?void 0:t.state)},t.debug=function(){return{entities:r({},u),entitiesCount:u.length}}},794:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.loadIndexFromUrl=void 0,t.loadIndexFromUrl=function(e,t){var n=new XMLHttpRequest;n.addEventListener("load",(function(e){var n=e.target,r=n.status,o=n.response;0!==r?r<200||r>299?t.error():t.load(o):t.progress(e.loaded/e.total)})),n.addEventListener("error",(function(){t.error()})),n.addEventListener("progress",(function(e){t.progress(e.loaded/e.total)})),n.responseType="arraybuffer",n.open("GET",e),n.send()}},480:function(e,t,n){var r=this&&this.__assign||function(){return r=Object.assign||function(e){for(var t,n=1,r=arguments.length;n<r;n++)for(var o in t=arguments[n])Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o]);return e},r.apply(this,arguments)};Object.defineProperty(t,"__esModule",{value:!0}),t.debug=t.register=t.search=t.attach=t.downloadIndex=t.initialize=void 0;var o=n(129),s=n(258),i=n(139),a=n(445),l=n(34),u=n(214);function c(e){return void 0===e&&(e=null),(0,s.loadWasm)(e).then((function(){}))}function d(e,t,n){return void 0===n&&(n={}),new Promise((function(r,s){var i=(0,l.validateIndexParams)(e,t);i?s(i):(0,o.register)(e,t,n).then(r).catch(s)}))}function h(e){try{(0,o.attachToDom)(e)}catch(e){throw new a.default(e.message)}}t.initialize=c,t.downloadIndex=d,t.attach=h,t.register=function(e,t,n){void 0===n&&(n={});var r=c(),o=d(e,t,n);return h(e),Promise.all([r,o]).then()},t.search=function(e,t){if(!e||!t)throw new a.default("Make sure to call stork.search() with two arguments: the index name and the search query.");if(!(0,o.entityIsReady)(e))throw new a.default("Couldn't find index. Make sure the stork.downloadIndex() promise has resolved before calling stork.search().");return(0,i.resolveSearch)(e,t)},t.debug=function(){return r(r(r({},(0,s.debug)()),(0,o.debug)()),{jsStorkVersion:"1.5.0",wasmStorkVersion:u.wasm_stork_version})}},112:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.highlight=void 0,t.highlight=function(e,t){function n(e,t,n){return e.substr(0,t)+n+e.substr(t)}for(var r=0,o=0,s=t;o<s.length;o++){var i=s[o],a='<mark class="stork-highlight">',l="</mark>";e=n(e,i.beginning+r,a),r+=a.length,e=n(e,i.end+r,l),r+=l.length}return e}},9:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.resultToListItem=void 0;var r=n(112);t.resultToListItem=function(e,t){var n=document.createElement("template");return n.innerHTML='\n<li class="stork-result'.concat(t.selected?" selected":"",'">\n  <a href="').concat(e.entry.url,'">\n    <div class="stork-title">\n      <p>').concat((0,r.highlight)(e.entry.title,e.title_highlight_ranges||[]),"</p>\n      ").concat(t.showScores?"<code><b>".concat(e.score,"</b></code>"):"","\n    </div>\n    ").concat(e.excerpts.length>0?'<div class="stork-excerpt-container" />':"","\n    ").concat(e.excerpts.map((function(e){return'<div class="stork-excerpt"><p>\n        ...'.concat((0,r.highlight)(e.text,e.highlight_ranges||[]),"...\n        </p>\n        ").concat(t.showScores?"<code>".concat(e.score,"</code>"):"","\n        </div>")})).join(""),"\n        ").concat(e.excerpts.length>0?"</div>":"","\n  </a>\n</li>"),n.content.firstElementChild}},139:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.resolveSearch=void 0;var r=n(214),o=n(445);t.resolveSearch=function(e,t){var n=null,s=null;try{n=(0,r.wasm_search)(e,t),s=JSON.parse(n)}catch(e){throw new o.default("Could not parse data from wasm_search. If you see this, please file a bug: https://jil.im/storkbug "+n)}if(!s)throw new o.default("Data was an empty object");if(s.error)throw new o.default("Could not perform search: the WASM binary failed to return search results.\n    You might not be serving your search index properly.\n    If you think this is an error, please file a bug: https://jil.im/storkbug\n    \n    The WASM binary came back with:\n    ".concat(s.error));return s}},445:function(e,t){var n,r=this&&this.__extends||(n=function(e,t){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])},n(e,t)},function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}n(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0});var o=function(e){function t(t){var n=e.call(this,t)||this;return n.name="StorkError",n}return r(t,e),t}(Error);t.default=o},466:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.plural=t.difference=t.htmlToElement=void 0,t.htmlToElement=function(e){var t=document.createElement("template");return e=e.trim(),t.innerHTML=e,t.content.firstChild},t.difference=function(e,t){var n=new Set(e),r=new Set(t),o=new Set(Array.from(n).filter((function(e){return!r.has(e)})));return Array.from(o)},t.plural=function(e,t,n){return 1==e?t:n}},34:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.validateIndexParams=void 0;var r=n(445);t.validateIndexParams=function(e,t){return"string"!=typeof e?new r.default("Index registration name must be a string."):"string"!=typeof t?new r.default("URL must be a string."):null}},258:(e,t,n)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.debug=t.loadWasm=t.runAfterWasmLoaded=void 0;var r=n(214),o=n(445),s="https://files.stork-search.net/releases/v".concat("1.5.0","/stork.wasm"),i=null,a=null,l=[],u=[];t.loadWasm=function(e){if(void 0===e&&(e=null),a)return a;var t=e||s;i=t;var n=(0,r.default)(t).then((function(){return c(),t})).catch((function(){throw d(),new o.default("Error while loading WASM at ".concat(t))}));return a=n,n},t.runAfterWasmLoaded=function(e,t){return a?(a.then((function(){return e()})).catch((function(){return t()})),a):(l.push(e),u.push(t),null)};var c=function(){l.forEach((function(e){e()})),l=[]},d=function(){u.forEach((function(e){e()})),u=[]};t.debug=function(){return{wasmSourceUrl:i,wasmLoadPromise:a,queueLength:l.length}}}},t={};function n(r){var o=t[r];if(void 0!==o)return o.exports;var s=t[r]={exports:{}};return e[r].call(s.exports,s,s.exports,n),s.exports}n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;n.g.importScripts&&(e=n.g.location+"");var t=n.g.document;if(!e&&t&&(t.currentScript&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");r.length&&(e=r[r.length-1].src)}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),n.p=e})();var r=n(480);stork=r})();+//# sourceMappingURL=stork.js.map
+ default/_emanote-static/stork/stork.wasm view

binary file changed (absent → 351489 bytes)

+ default/favicon.svg view
@@ -0,0 +1,92 @@+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 504.124 504.124" style="enable-background:new 0 0 504.124 504.124;" xml:space="preserve">
+<path style="fill:#EA0C5C;" d="M482.951,396.006c0,8.531-6.908,15.447-15.447,15.447H95.461c-8.515,0-15.439-6.916-15.439-15.447
+	v-64.37c0-8.531,6.924-15.447,15.439-15.447h372.043c8.539,0,15.447,6.916,15.447,15.447L482.951,396.006L482.951,396.006z"/>
+<path style="fill:#BC0852;" d="M467.512,316.197c8.539,0,15.447,6.916,15.447,15.447v64.37c0,8.531-6.908,15.447-15.447,15.447
+	H95.461"/>
+<path style="fill:#FF7171;" d="M80.022,331.643c0-8.531,6.924-15.447,15.439-15.447h372.043c8.539,0,15.447,6.916,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M399.991,396.699c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.552,0-2.82-1.26-2.82-2.812V330.95
+		c0-1.56,1.268-2.82,2.82-2.82l0,0c1.544,0,2.812,1.26,2.812,2.82V396.699z"/>
+	<path style="fill:#F2DB06;" d="M417.84,396.699c0,1.552-1.26,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812V330.95
+		c0-1.56,1.26-2.82,2.82-2.82l0,0c1.552,0,2.812,1.26,2.812,2.82V396.699z"/>
+	<path style="fill:#F2DB06;" d="M435.689,396.699c0,1.552-1.252,2.812-2.82,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812V330.95
+		c0-1.56,1.26-2.82,2.804-2.82l0,0c1.568,0,2.82,1.26,2.82,2.82V396.699z"/>
+</g>
+<path style="fill:#038462;" d="M424.102,302.089c0,8.523-6.916,15.447-15.447,15.447H36.613c-8.531,0-15.447-6.924-15.447-15.447
+	v-64.37c0-8.539,6.916-15.447,15.447-15.447h372.043c8.531,0,15.447,6.908,15.447,15.447V302.089z"/>
+<path style="fill:#047769;" d="M408.655,222.272c8.531,0,15.447,6.908,15.447,15.447v64.37c0,8.523-6.916,15.447-15.447,15.447
+	H36.613"/>
+<path style="fill:#2EAF8D;" d="M21.174,237.719c0-8.539,6.916-15.447,15.447-15.447h372.035c8.531,0,15.447,6.908,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M341.134,302.775c0,1.552-1.26,2.812-2.82,2.812l0,0c-1.544,0-2.796-1.26-2.796-2.812v-65.757
+		c0-1.552,1.252-2.812,2.796-2.812l0,0c1.56,0,2.82,1.26,2.82,2.812V302.775z"/>
+	<path style="fill:#F2DB06;" d="M358.984,302.775c0,1.552-1.26,2.812-2.828,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812v-65.757
+		c0-1.552,1.26-2.812,2.804-2.812l0,0c1.568,0,2.828,1.26,2.828,2.812V302.775z"/>
+	<path style="fill:#F2DB06;" d="M376.833,302.775c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812v-65.757
+		c0-1.552,1.26-2.812,2.82-2.812l0,0c1.544,0,2.812,1.26,2.812,2.812V302.775z"/>
+</g>
+<path style="fill:#115989;" d="M424.102,488.678c0,8.523-6.916,15.447-15.447,15.447H36.613c-8.531,0-15.447-6.924-15.447-15.447
+	v-64.37c0-8.531,6.916-15.447,15.447-15.447h372.043c8.531,0,15.447,6.916,15.447,15.447V488.678z"/>
+<path style="fill:#003B5B;" d="M408.655,408.861c8.531,0,15.447,6.916,15.447,15.447v64.37c0,8.523-6.916,15.447-15.447,15.447
+	H36.613"/>
+<path style="fill:#158ACC;" d="M21.174,424.308c0-8.531,6.916-15.447,15.447-15.447h372.035c8.531,0,15.447,6.916,15.447,15.447"/>
+<g>
+	<path style="fill:#F2DB06;" d="M341.134,489.371c0,1.552-1.26,2.812-2.82,2.812l0,0c-1.544,0-2.796-1.26-2.796-2.812v-65.757
+		c0-1.536,1.252-2.812,2.796-2.812l0,0c1.56,0,2.82,1.276,2.82,2.812V489.371z"/>
+	<path style="fill:#F2DB06;" d="M358.984,489.371c0,1.552-1.26,2.812-2.828,2.812l0,0c-1.544,0-2.804-1.26-2.804-2.812v-65.757
+		c0-1.536,1.26-2.812,2.804-2.812l0,0c1.568,0,2.828,1.276,2.828,2.812V489.371z"/>
+	<path style="fill:#F2DB06;" d="M376.833,489.371c0,1.552-1.268,2.812-2.812,2.812l0,0c-1.56,0-2.82-1.26-2.82-2.812v-65.757
+		c0-1.536,1.26-2.812,2.82-2.812l0,0c1.544,0,2.812,1.276,2.812,2.812V489.371z"/>
+</g>
+<circle style="fill:#FFAA00;" cx="252.062" cy="131.853" r="92.987"/>
+<path style="fill:#FF8500;" d="M252.062,38.882c51.358,0,92.987,41.622,92.987,92.979s-41.63,92.979-92.987,92.979"/>
+<path style="fill:#54391E;" d="M252.566,57.038c-4.151,0-7.507-3.363-7.507-7.507c0-17.258-14.052-31.319-31.327-31.319
+	c-4.151,0-7.507-3.356-7.507-7.515c0-4.151,3.356-7.507,7.507-7.507c25.553,0,46.34,20.787,46.34,46.34
+	C260.081,53.675,256.725,57.038,252.566,57.038z"/>
+<path style="fill:#382413;" d="M213.741,17.275c-0.819,0-1.575,0.221-2.332,0.473c0.756,0.244,1.512,0.473,2.332,0.473
+	c17.266,0,31.327,14.052,31.327,31.319c0,4.143,3.356,7.507,7.507,7.507c2.725,0,5.01-1.52,6.333-3.694
+	C254.197,32.722,235.757,17.275,213.741,17.275z"/>
+<path style="fill:#7EC441;" d="M305.799,3.9c-12.768-8.271-34.068-3.119-50.105,12.902c-16.03,16.037-13.383,29.562-3.718,40.921
+	L305.799,3.9z"/>
+<path style="fill:#559E1B;" d="M305.696,3.79c8.263,12.761,3.119,34.068-12.91,50.105c-16.03,16.03-29.554,13.375-40.921,3.718
+	L305.696,3.79z"/>
+<g>
+	<path style="fill:#F4C951;" d="M236.285,55.187c7.593,7.593-9.051,17.912-25.687,34.54s-26.947,33.264-34.54,25.679
+		c-7.57-7.578-0.236-27.199,16.384-43.827C209.078,54.943,228.691,47.609,236.285,55.187z"/>
+	<circle style="fill:#F4C951;" cx="177.129" cy="129.971" r="4.072"/>
+	<circle style="fill:#F4C951;" cx="176.814" cy="147.828" r="1.252"/>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
+ default/index.md view
@@ -0,0 +1,7 @@+# My Emanote Site++Welcome to [Emanote](https://emanote.srid.ca/).++Your notes should be visible in the sidebar (to the left), or in the [index](-/all) page (see sidebar & footer). To get rid of this message, add a file named `index.md` (or `index.org`).++#emanote/default-layer
+ default/index.yaml view
@@ -0,0 +1,128 @@+# These variables are made globally available in the HTML templates.+# Make sure that they do not conflict with HTML tag names.+# You can override them on a per-note or per-directory basis by creating the+# associated .yaml file.++# Order is used by Emanote to determine the sidebar order of notes.+order: 0++tags: []++# TODO: How to design this when generating RSS feeds?+template:+  # Which template to use to render notes. The filepath without the .tpl extension.+  name: /templates/layouts/book+  # Layout specific settings+  layout:+    # For base.tpl+    base:+      # The class to apply for <body> element when using base.tpl (used by note and book layouts)+      bodyClass: bg-gray-400 overflow-y-scroll+    note:+      containerClass: container mx-auto max-w-prose+  error:+    containerClass: container mx-auto max-w-prose+  uptree:+    # Class to apply for uptree's nodes+    nodeClass: text-gray-900+  # List of available colors: https://v2.tailwindcss.com/docs/customizing-colors#default-color-palette+  theme: blue+  # Value of the <base> tag.+  baseUrl: /+  # Change this to 'pretty' if you want URLs without the '.html' suffix.+  urlStrategy: direct+  iconUrl: ${ema:homeUrl}favicon.svg+  sidebar:+    # Whether this node in the sidebar tree should remain collapsed by default+    # (unless a route in it is active)+    collapsed: true++pandoc:+  # Rewrite the class specified in Pandoc's Div and Span nodes. You can specify the class using+  # https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/attributes.md+  #+  # This feature is useful when managing a "library" of CSS styling to use on+  # Markdown text, especially when using Tailwind.+  rewriteClass:+    # How to style inline tags. Tag-specific styles can be applied as well.+    emanote:inline-tag: font-bold bg-gray-100 py-0.5 px-2 rounded-lg+    # This style is used by `#a/red/tag` in addition to the generic style above.+    # As this class comes *after* the generic class, it can override the styles+    # in the generic class.+    emanote:inline-tag:a/red/tag: bg-red-100+    emanote:placeholder-message: text-gray-400 border-t-2 inline-block pt-0.5+    emanote:error: text-l bg-red-100 p-2 border-2 border-black m-2 font-mono+    emanote:error:aside: font-mono align-top text-xs mr-1 tracking-tighter opacity-50 hover:opacity-100+    # You can also add your own class -> style mappings. We provide a sample below.+    sticky-note: px-3 py-1 rounded shadow bg-yellow-100 mx-2 transform -skew-y-1 scale-95 hover:scale-100 hover:border-yellow-400 hover:shadow-lg border-t-8 border-yellow-200 mb-8 mt-8+    note: p-2 mb-3 rounded shadow bg-gray-100 w-full float-none md:float-right md:w-1/2 md:clear-both+    highlight-block: px-3 py-1 mb-3 rounded bg-${theme}-100 hover:border-${theme}-400 hover:shadow border-t-8 border-${theme}-200+    highlight-inline: bg-yellow-200 px-2 py-0.5 rounded-xl+    center: flex justify-center items-center mx-auto++# Put page-specific metadata here. Override them in Markdown frontmatter or+# per-folder YAML as necessary.+page:+  siteTitle: My Emanote Site+  # Desription is expected to be set on a per-page basis.+  # By default, the first paragraph is used for description.+  # Used for https://ogp.me/+  description: ""++  # Image URL for previews.+  # By default, the first image URL is used. Note: wikilink are not yet+  # supported.+  # Used for https://ogp.me/+  image: ""++  # Put anything that should go in <head> of these routes here:+  # You can reference other metadata keys using <snippet var=".." />+  # Or use the JS behaviour library below.+  headHtml: |+    <meta name="generator" content="Emanote" />++# Builtin JS behaviour library. Use them in `page.headHtml` of your .yaml or .md+# frontmatter.+js:+  # Syntax highlighting using prism.js+  prism: |+    <!-- Prism.js (doesn't work great in live server) -->+    <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/themes/prism-tomorrow.min.css" rel="stylesheet" />+    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/prism.min.js"></script>+    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/plugins/autoloader/prism-autoloader.min.js"></script>+  # Syntax highlighting using highlight.js+  highlightjs: |+    <!-- highlight.js -->+    <with var="js">+    <link rel="stylesheet"+      href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${value:highlightjs-ver}/styles/hybrid.min.css">+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${value:highlightjs-ver}/highlight.min.js"></script>+    <!-- Include languages that Emanote itself uses -->+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${value:highlightjs-ver}/languages/haskell.min.js"></script>+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${value:highlightjs-ver}/languages/nix.min.js"></script>+    <script>hljs.highlightAll();</script>+    </with>+  highlightjs-ver: 11.6.0 # Ref: https://cdnjs.com/libraries/highlight.js+  # Diagrams using mermaid.js+  mermaid: |+    <!-- mermaid.js -->+    <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>+    <script>+      mermaid.initialize({startOnLoad:false});+      mermaid.init(undefined,document.querySelectorAll(".mermaid"));+    </script>+  mathjax: |+    <script>+      window.MathJax = {+        startup: {+          ready: () => {+            MathJax.startup.defaultReady();+          }+        }+      };+    </script>+    <script async="" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>++emanote:+  # Whether to automatically treat folder notes as a folgezettel parent of its contents+  folder-folgezettel: true
+ default/templates/base.tpl view
@@ -0,0 +1,51 @@+<!DOCTYPE HTML>+<html lang="en">++<head>+  <meta charset="UTF-8">+  <meta name="viewport" content="width=device-width, initial-scale=1">+  <title>+    <ema:titleFull />+  </title>+  <ema:metadata>+    <with var="page">+      <meta property="og:description" content="${value:description}" />+      <meta property="og:site_name" content="${value:siteTitle}" />+      <meta property="og:image" content="${value:image}" />+      <meta property="og:type" content="website" />+      <meta property="og:title" content="${ema:title}" />+    </with>+    <with var="template">+      <base href="${value:baseUrl}" />+      <link href="${value:iconUrl}" rel="icon" />+    </with>+    <snippet var="page.headHtml" />+  </ema:metadata>+  <tailwindCssShim />++  <!-- Heist error element -->+  <style>+    strong.error {+      color: lightcoral;+      font-size: 90%;+      font-family: monospace;+    }+  </style>+  <apply template="/templates/hooks/more-head" />++  <head-main />+  <apply template="components/stork/stork-search-head" />+</head>++<!-- DoNotFormat -->+<bind tag="theme"><ema:metadata><value var="template.theme" /></ema:metadata></bind>+<bind tag="iconSize">w-4 h-4 flex-shrink-0</bind>+<bind tag="bodyClass"><ema:metadata><value var="template.layout.base.bodyClass" /></ema:metadata></bind>+<!-- DoNotFormat -->++<body class="${bodyClass}">+  <body-main />+  <apply template="components/stork/stork-search" />+</body>++</html>
+ default/templates/components/backlinks.tpl view
@@ -0,0 +1,18 @@+<ema:note:backlinks:nodaily>+  <div class="flex-1 p-4 mt-8 bg-gray-100 rounded">+    <header class="mb-2 text-xl font-semibold text-gray-500">Links to this page</header>+    <ul class="space-y-1">+      <backlink>+        <li>+          <a class="text-${theme}-600 mavenLinkBold hover:bg-${theme}-50"+            href="${backlink:note:url}">+            <backlink:note:title />+          </a>+          <backlink:note:contexts>+            <apply template="context" />+          </backlink:note:contexts>+        </li>+      </backlink>+    </ul>+  </div>+</ema:note:backlinks:nodaily>
+ default/templates/components/breadcrumbs.tpl view
@@ -0,0 +1,47 @@+<nav id="breadcrumbs" class="w-full text-gray-700 md:hidden">+  <div class="flex justify-left">+    <div class="w-full px-2 py-2 bg-gray-50">+      <ul class="flex flex-wrap text-lg">+        <li class="inline-flex items-center">+          <ema:metadata>+            <with var="template">+              <img style="width: 1rem;" src="${value:iconUrl}" />+            </with>+          </ema:metadata>+        </li>+        <ema:breadcrumbs>+          <each-crumb>+            <li class="inline-flex items-center">+              <a class="px-1 font-bold" href="${crumb:url}">+                <crumb:title />+              </a>+              <svg fill="currentColor" viewBox="0 0 20 20" class="w-auto h-5 text-gray-400">+                <path fill-rule="evenodd"+                  d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"+                  clip-rule="evenodd"></path>+              </svg>+            </li>+          </each-crumb>+        </ema:breadcrumbs>+      </ul>+    </div>+    <button class="inline px-2 py-1 bg-gray-50 outline-none cursor-pointer focus:outline-none"+      title="Search (Ctrl+K)" type="button" onclick="window.emanote.stork.toggleSearch()">+      <apply template="stork/stork-icon" />+    </button>+    <button+      class="inline px-2 py-1 text-white bg-${theme}-600 outline-none cursor-pointer focus:outline-none"+      title="Toggle sidebar" type="button" onclick="toggleHidden('sidebar')">+      <svg xmlns="http://www.w3.org/2000/svg" class="w-4" fill="none" viewBox="0 0 24 24"+        stroke="currentColor">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M4 6h16M4 12h16M4 18h16" />+      </svg>+    </button>+    <script>+      function toggleHidden(elemId) {+        document.getElementById(elemId).classList.toggle("hidden");+      }+    </script>+  </div>+</nav>
+ default/templates/components/checkbox-checked.tpl view
@@ -0,0 +1,4 @@+<svg xmlns='http://www.w3.org/2000/svg' class='inline h-4 w-4 mb-1 text-gray-600 stroke-current fill-current' viewBox='0 0 28 28'><desc>☑</desc><rect stroke-width='3' x='2' y='2' width='24' height='24' rx='3'/><path stroke='white' fill='none' stroke-linejoin='round' stroke-width='3.5' d='M7 14 l6 5L22 8'/></svg>+<span>+  <apply-content />+</span>
+ default/templates/components/checkbox-unchecked.tpl view
@@ -0,0 +1,4 @@+<svg xmlns='http://www.w3.org/2000/svg' class='inline h-4 w-4 mb-1 text-gray-600 stroke-current' fill='none' viewBox='0 0 28 28'><desc>☐</desc><rect stroke-width='3' x='2' y='2' width='24' height='24' rx='3'/></svg>+<span>+  <apply-content />+</span>
+ default/templates/components/context.tpl view
@@ -0,0 +1,24 @@+<div class="mb-4 overflow-auto text-sm text-gray-500">+  <context>+    <div class="pl-2 mt-2 border-l-2 border-${theme}-200 hover:border-${theme}-500">+      <context:body>+        <PandocLink class="text-gray-600">+          <Internal class="font-bold hover:bg-gray-50" />+          <External class="hover:underline" target="_blank" rel="noopener" />+        </PandocLink>+        <OrderedList class="ml-4 space-y-1 list-decimal list-inside" />+        <BulletList class="ml-4 space-y-1 list-decimal list-inside" />+        <Task:Checked>+          <apply template="/templates/components/checkbox-checked">+            <inlines />+          </apply>+        </Task:Checked>+        <Task:Unchecked>+          <apply template="/templates/components/checkbox-unchecked">+            <inlines />+          </apply>+        </Task:Unchecked>+      </context:body>+    </div>+  </context>+</div>
+ default/templates/components/footer.tpl view
@@ -0,0 +1,46 @@+<footer class="flex items-center justify-center mt-2 mb-8 space-x-4 text-center text-gray-800">+  <bind tag="iconClass">w-6 h-6 hover:text-${theme}-700</bind>+  <div>+    <a href="${ema:homeUrl}" title="Go to Home page">+      <svg xmlns="http://www.w3.org/2000/svg" class="${iconClass}" fill="none" viewBox="0 0 24 24"+        stroke="currentColor">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />+      </svg>+    </a>+  </div>+  <div>+    <a href="${ema:indexUrl}" title="View Index">+      <svg class="${iconClass}" fill="none" stroke="currentColor" viewBox="0 0 24 24"+        xmlns="http://www.w3.org/2000/svg">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4">+        </path>+      </svg>+    </a>+  </div>+  <div>+    <a href="https://emanote.srid.ca" target="_blank" title="Generated by Emanote ${ema:version}">+      <img class="${iconClass}" src="${ema:emanoteStaticLayerUrl}/emanote-logo.svg" />+    </a>+  </div>+  <div>+    <a href="${ema:tagIndexUrl}" title="View tags">+      <svg class="${iconClass}" fill="none" stroke="currentColor" viewBox="0 0 24 24"+        xmlns="http://www.w3.org/2000/svg">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z">+        </path>+      </svg>+    </a>+  </div>+  <div>+    <a href="${ema:taskIndexUrl}" title="View tasks">+      <svg xmlns="http://www.w3.org/2000/svg" class="${iconClass}" fill="none" viewBox="0 0 24 24"+        stroke="currentColor">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />+      </svg>+    </a>+  </div>+</footer>
+ default/templates/components/metadata.tpl view
@@ -0,0 +1,16 @@+<ema:metadata>+  <section+    class="flex flex-wrap items-end justify-center my-4 space-x-2 space-y-2 font-mono text-sm">+    <with var="tags">+      <!-- FIXME: The use of -/tags is wrong, because we should use routeUrl using Ema's encoder +        Perhaps Emanote should inject tagMetas with urls.+      -->+      <a title="Tag" class="px-1 bg-gray-100 rounded hover:bg-gray-50 hover:text-${theme}-500"+        href="-/tags/${value}${ema:urlStrategySuffix}">+        <!-- DoNotFormat -->+        #<value />+        <!-- DoNotFormat -->+      </a>+    </with>+  </section>+</ema:metadata>
+ default/templates/components/note-body.tpl view
@@ -0,0 +1,5 @@+<article class="overflow-auto">+  <apply template="/templates/hooks/before-note" />+  <apply template="/templates/components/pandoc" />+  <apply template="/templates/hooks/after-note" />+</article>
+ default/templates/components/note-title.tpl view
@@ -0,0 +1,6 @@+<h1+  class="flex items-end justify-center mb-4 p-3 bg-${theme}-100 text-5xl font-extrabold text-black rounded">+  <a class="z-40 tracking-tighter ">+    <ema:note:title />+  </a>+</h1>
+ default/templates/components/note-uptree-recurse.tpl view
@@ -0,0 +1,16 @@+<li>++  <div class="${nodeClass} forest-link">+    <a href="${node:url}">+      <node:text />+    </a>+  </div>++  <tree:open>+    <ul>+      <children>+        <apply template="note-uptree-recurse" />+      </children>+    </ul>+  </tree:open>+</li>
+ default/templates/components/note-uptree.tpl view
@@ -0,0 +1,17 @@+<!-- DoNotFormat -->+<bind tag="nodeClass"><ema:metadata><value var="template.uptree.nodeClass" /></ema:metadata></bind>+<!-- DoNotFormat -->++<nav id="uptree" class="flipped tree" style="transform-origin: 50%;">+  <ul class="root">+    <li>+      <ema:note:uptree:nonempty>+        <ul>+          <ema:note:uptree>+            <apply template="note-uptree-recurse" />+          </ema:note:uptree>+        </ul>+      </ema:note:uptree:nonempty>+    </li>+  </ul>+</nav>
+ default/templates/components/pandoc.tpl view
@@ -0,0 +1,108 @@+<ema:note:pandoc>+  <Para>+    <p class="mb-3">+      <inlines />+    </p>+  </Para>+  <Task:Checked>+    <!-- FIXME: Fix list styling to use flexbox, so task lists don't botch them up -->+    <apply template="/templates/components/checkbox-checked">+      <inlines />+    </apply>+  </Task:Checked>+  <Task:Unchecked>+    <apply template="/templates/components/checkbox-unchecked">+      <inlines />+    </apply>+  </Task:Unchecked>+  <Cite>+    <cite>+      <inlines />+    </cite>+  </Cite>+  <BlockQuote>+    <blockquote+      class="py-0.5 px-4 mb-3 italic border-l-4 bg-gray-50 text-gray-600 border-gray-400 quote">+      <blocks />+    </blockquote>+  </BlockQuote>+  <DefinitionList>+    <dl class="flex flex-col mb-3">+      <DefinitionList:Items>+        <div class="my-1">+          <dt class="font-bold text-l">+            <DefinitionList:Item:Term />+          </dt>+          <DefinitionList:Item:DescList>+            <div class="flex flex-col pl-1">+              <dd class="pl-2 my-1 text-gray-700 border-l-2">+                <DefinitionList:Item:Desc />+              </dd>+            </div>+          </DefinitionList:Item:DescList>+        </div>+      </DefinitionList:Items>+    </dl>+  </DefinitionList>+  <Note:Ref>+    <sup class="px-0.5">+      <a class="text-${theme}-600 hover:underline" href="${ema:note:url}#fn${footnote:idx}">+        <footnote:idx />+      </a>+    </sup>+  </Note:Ref>+  <Note:List>+    <div title="Footnotes"+      class="pt-2 mt-8 space-y-1 text-gray-500 transform scale-x-90 border-t-2">+      <header class="font-semibold">Footnotes</header>+      <footnote>+        <div id="fn${footnote:idx}">+          <header class="italic">+            <footnote:idx />.+          </header>+          <div class="inline-block mb-2 ml-4">+            <footnote:content />+          </div>+        </div>+      </footnote>+    </div>+  </Note:List>++  <BulletList>+    <ul class="my-3 ml-6 space-y-1 list-disc">+      <BulletList:Items>+        <li>+          <BulletList:Item />+        </li>+      </BulletList:Items>+    </ul>+  </BulletList>+  <OrderedList>+    <ul class="my-3 ml-6 space-y-1 list-decimal list-inside">+      <OrderedList:Items>+        <li>+          <OrderedList:Item />+        </li>+      </OrderedList:Items>+    </ul>+  </OrderedList>+  <HorizontalRule>+    <hr class="mb-3" />+  </HorizontalRule>+  <!-- TODO: Expand the above kind of overriding (full DOM control) to other AST nodes (below) -->+  <PandocLink class="text-${theme}-600">+    <Internal class="mavenLinkBold hover:underline" />+    <External class="hover:underline" target="_blank" rel="noopener" />+  </PandocLink>+  <CodeBlock class="py-0.5 mb-3 text-sm" />+  <Code class="py-0.5 px-0.5 bg-gray-100" />+  <Header>+    <h1 class="pb-2 mb-2 text-5xl font-bold text-center" />+    <h2 class="inline-block mt-6 mb-4 text-4xl font-bold text-gray-700 border-b-2" />+    <h3 class="mt-6 mb-2 text-3xl font-bold text-gray-700" />+    <h4 class="mt-6 mb-2 text-2xl font-bold text-gray-700" />+    <h5 class="mt-6 mb-2 text-xl font-bold text-gray-700" />+    <h6 class="mt-6 mb-2 text-xl font-bold text-gray-700" />+  </Header>++</ema:note:pandoc>
+ default/templates/components/sidebar-tree.tpl view
@@ -0,0 +1,80 @@+<!-- Variable bindings for this tree-->+<node:active>+  <bind tag="link-class">font-bold text-${theme}-600 hover:underline</bind>+  <else />+  <tree:open>+    <has-children>+      <bind tag="link-class">font-bold hover:underline</bind>+      <else />+      <bind tag="link-class">hover:underline</bind>+    </has-children>+    <else />+    <bind tag="link-class">hover:underline</bind>+  </tree:open>+</node:active>++<has-children>+  <bind tag="icon">+    <tree:open>+      <svg xmlns="http://www.w3.org/2000/svg" class="${iconSize} inline text-gray-700"+        viewBox="0 0 20 20" fill="currentColor">+        <path fill-rule="evenodd"+          d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z"+          clip-rule="evenodd" />+        <path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />+      </svg>+      <else />+      <svg xmlns="http://www.w3.org/2000/svg" class="${iconSize} inline text-gray-500"+        viewBox="0 0 20 20" fill="currentColor">+        <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />+      </svg>+    </tree:open>+  </bind>+  <else />+  <bind tag="icon">+    <node:active>+      <svg class="${iconSize} inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"+        xmlns="http://www.w3.org/2000/svg">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">+        </path>+      </svg>+      <else />+      <svg class="${iconSize} inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"+        xmlns="http://www.w3.org/2000/svg">+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+          d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z">+        </path>+      </svg>+    </node:active>+  </bind>+</has-children>++<!-- Rendering of this tree -->+<div class="pl-2">+  <!-- Node's rootLabel-->+  <div class="flex items-center my-2 space-x-2 justify-left">+    <icon />+    <a class="${link-class} truncate" title="${node:text}" href="${node:url}">+      <node:text />+    </a>+    <tree:open>+      <else />+      <node:terminal>+        <else />+        <span class="text-gray-300" title="${tree:childrenCount} children inside">+          <tree:childrenCount />+        </span>+      </node:terminal>+    </tree:open>+  </div>++  <!-- Node's children forest, displayed only on active trees+    TODO: Use <details> to toggle visibility?+  -->+  <tree:open>+    <children>+      <apply template="sidebar-tree" />+    </children>+  </tree:open>+</div>
+ default/templates/components/stork/stork-icon.tpl view
@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" style="width: 1rem;" class="hover:text-${theme}-700" f+  fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">+  <path stroke-linecap="round" stroke-linejoin="round"+    d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />+</svg>
+ default/templates/components/stork/stork-search-head.tpl view
@@ -0,0 +1,64 @@+<link rel="stylesheet" href="${ema:emanoteStaticLayerUrl}/stork/flat.css" />+<!-- Custom Stork-search styling for Emanote -->+<style>+  #stork-search-container {+    z-index: 1000;+    background-color: rgb(15 23 42/.8);+  }++  .stork-overflow-hidden-important {+    overflow: hidden !important;+  }+</style>+++<script src="${ema:emanoteStaticLayerUrl}/stork/stork.js"></script>+<ema:metadata>+  <with var="template">+    <script data-emanote-base-url="${value:baseUrl}">+      window.emanote = {};+      window.emanote.stork = {+        searchShown: false,+        toggleSearch: function () {+          document.getElementById('stork-search-container').classList.toggle('hidden');+          window.emanote.stork.searchShown = document.body.classList.toggle('stork-overflow-hidden-important');+          if (window.emanote.stork.searchShown) {+            document.getElementById('stork-search-input').focus();+          }+        },+        clearSearch: function () {+          document.getElementById('stork-search-container').classList.add('hidden');+          document.body.classList.remove('stork-overflow-hidden-important');+          window.emanote.stork.searchShown = false;+        },++        init: function () {+          const indexName = 'emanote-search'; // used to match input[data-stork] attribute value+          const baseUrl = document.currentScript.getAttribute('data-emanote-base-url') || '/';+          const indexUrl = baseUrl + '-/stork.st';+          if (document.readyState !== 'complete') {+            window.addEventListener('load', function () {+              stork.initialize(baseUrl + '_emanote-static/stork/stork.wasm');+              stork.register(indexName, indexUrl);+            });++            document.addEventListener('keydown', event => {+              if (window.emanote.stork.searchShown && event.key === 'Escape') {+                window.emanote.stork.clearSearch();+                event.preventDefault();+              } else if ((event.key == 'k' || event.key == 'K') && (event.ctrlKey || event.metaKey)) {+                window.emanote.stork.toggleSearch();+                event.preventDefault();+              }+            });+          } else {+            // Override existing on Ema's hot-reload+            stork.register(indexName, indexUrl, { forceOverwrite: true });+          }+        }+      };++      window.emanote.stork.init();+    </script>+  </with>+</ema:metadata>
+ default/templates/components/stork/stork-search.tpl view
@@ -0,0 +1,12 @@+<div id="stork-search-container"+  class="hidden fixed w-screen h-screen inset-0 backdrop-filter backdrop-blur-sm">+  <div class="fixed w-screen h-screen inset-0" onclick="window.emanote.stork.toggleSearch()"></div>++  <div class="container mx-auto p-10 mt-10">+    <div class="stork-wrapper-flat container mx-auto">+      <input id="stork-search-input" data-stork="emanote-search" class="stork-input"+        placeholder="Search (Ctrl+K) ..." />+      <div data-stork="emanote-search-output" class="stork-output"></div>+    </div>+  </div>+</div>
+ default/templates/components/timeline.tpl view
@@ -0,0 +1,19 @@+<ema:note:backlinks:daily>+  <div class="p-4 mt-8 bg-${theme}-50 rounded flex-1">+    <header class="mb-2 text-xl font-semibold text-gray-500"+      title="Links to this page from daily notes">🗓️ Timeline</header>+    <ul class="space-y-1">+      <backlink>+        <li>+          <a class="header-font font-light text-gray-700 hover:text-${theme}-500"+            href="${backlink:note:url}">+            <backlink:note:title />+          </a>+          <backlink:note:contexts>+            <apply template="context" />+          </backlink:note:contexts>+        </li>+      </backlink>+    </ul>+  </div>+</ema:note:backlinks:daily>
+ default/templates/error.tpl view
@@ -0,0 +1,39 @@+<!-- DoNotFormat -->+<bind tag="containerClass"><ema:metadata><value var="template.error.containerClass" /></ema:metadata></bind>+<!-- DoNotFormat -->++<apply template="base">+  <bind tag="body-main">+    <div class="${containerClass}">+      <div class="mt-2 md:mt-4">+        <div class="md:shadow-2xl md:mb-8">+          <div class="flex-1 w-full overflow-x-auto bg-white">+            <main class="px-4 py-4 bg-red-100">+              <h1+                class="flex items-end justify-center mb-4 p-3 bg-red-500 text-5xl font-extrabold text-gray-100 rounded">+                <a class="z-40 tracking-tighter ">+                  <ema:note:title />+                </a>+              </h1>+              <div class="flex items-center justify-center font-bold text-lg">+                Your notebook has an issue.+              </div>+              <div class="bg-gray-50 p-2 my-4">+                <apply template="/templates/components/pandoc" />+              </div>+              <div class="flex items-center justify-center text-xl">+                <div>Fix it (this page will reload), or <a class="font-bold underline" href="/">go+                    to /</a>.+                </div>+              </div>+              <div class="transform scale-95 opacity-50 hover:opacity-100">+                <apply template="components/timeline" />+                <apply template="components/backlinks" />+              </div>+            </main>+          </div>+        </div>+      </div>+    </div>+  </bind>+</apply>
+ default/templates/experimental.tpl view
@@ -0,0 +1,5 @@+<div class="flex justify-center items-center mb-2">+  <a target=_blank class="italic underline"+    href="https://github.com/srid/emanote/discussions/${dnum}">Experimental+    feature</a> !+</div>
+ default/templates/filters/embed-image.tpl view
@@ -0,0 +1,1 @@+<img class="inline mb-3" src="${ema:url}" alt="${ema:alt}" style="width: inherit;" />
+ default/templates/filters/embed-note.tpl view
@@ -0,0 +1,11 @@+<section title="Embedded note" class="p-4 mx-2 mb-2 bg-white border-2 rounded-lg shadow-inner">+  <header+    class="flex items-center justify-center text-2xl italic bg-${theme}-50 rounded py-1 px-2 mb-3">+    <a href="${ema:note:url}">+      <ema:note:title />+    </a>+  </header>+  <div>+    <apply template="/templates/components/pandoc" />+  </div>+</section>
+ default/templates/filters/embed-pdf.tpl view
@@ -0,0 +1,4 @@+<object class="mb-3 embedded-pdf w-full" type="application/pdf" data="${ema:url}"
+  style="height: 800px">
+  <a href="${ema:url}">Open pdf</a>
+</object>
+ default/templates/filters/embed-video.tpl view
@@ -0,0 +1,6 @@+<video class="mb-3" autoplay="" loop="" muted="">+  <source src="${ema:url}">+  Your browser doesn't support HTML5 video. Here is a <a class="text-${theme}-600 hover:underline"+    href="${ema:url}">link to the+    video</a> instead.+</video>
+ default/templates/filters/query-default.tpl view
@@ -0,0 +1,23 @@+<nav>+  <div class="mb-8">+    <header data-nosnippet class="pb-2 mb-2 font-semibold text-gray-600">+      <query />+    </header>+    <ul>+      <result>+        <li>+          <div class="flex flex-wrap my-2">+            <ema:note:metadata>+              <span data-nosnippet class="mr-2 text-right text-gray-600">+              </span>+            </ema:note:metadata>+            <a class="flex-1 text-${theme}-600 mavenLinkBold border-l-2 pl-2 hover:underline"+              href="${ema:note:url}">+              <ema:note:title />+            </a>+          </div>+        </li>+      </result>+    </ul>+  </div>+</nav>
+ default/templates/filters/query-timeline.tpl view
@@ -0,0 +1,25 @@+<!-- TODO: DRY (same as query-default.tpl, except for date column)-->+<nav>+  <div class="mb-8">+    <header data-nosnippet class="pb-2 mb-2 font-semibold text-gray-600">+      <query />+    </header>+    <ul>+      <result>+        <li>+          <div class="flex flex-wrap my-2">+            <ema:note:metadata>+              <span data-nosnippet class="mr-2 text-right text-gray-600">+                <value var="date" />+              </span>+            </ema:note:metadata>+            <a class="flex-1 text-${theme}-600 mavenLinkBold border-l-2 pl-2 hover:underline"+              href="${ema:note:url}">+              <ema:note:title />+            </a>+          </div>+        </li>+      </result>+    </ul>+  </div>+</nav>
+ default/templates/hooks/after-note.tpl view
@@ -0,0 +1,1 @@+<!-- What goes in this file will appear below the note body-->
+ default/templates/hooks/before-note.tpl view
@@ -0,0 +1,1 @@+<!-- What goes in this file will appear on top of note body-->
+ default/templates/hooks/more-head.tpl view
@@ -0,0 +1,49 @@+<!-- What goes in this file will appear on near the end of <head>-->+<link rel="preload"+  href="${ema:emanoteStaticLayerUrl}/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf" as="font"+  type="font/ttf" crossorigin>++<style>+  @font-face {+    font-family: 'WorkSans';+    /* FIXME: This ought to be: ${ema:emanoteStaticLayerUrl}/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf */+    src: url(_emanote-static/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf) format("truetype");+    font-display: swap;+  }++  body {+    font-family: 'WorkSans', sans-serif;+    font-variation-settings: 'wght' 350;+  }++  a.mavenLinkBold {+    font-variation-settings: 'wght' 400;+  }++  strong {+    font-variation-settings: 'wght' 500;+  }++  h1,+  h2,+  h3,+  h4,+  h5,+  h6,+  header,+  .header-font {+    font-family: 'WorkSans', sans-serif;+  }++  h1 {+    font-variation-settings: 'wght' 500;+  }++  h2 {+    font-variation-settings: 'wght' 400;+  }++  h3 {+    font-variation-settings: 'wght' 300;+  }+</style>
+ default/templates/hooks/note-end.tpl view
@@ -0,0 +1,1 @@+<!-- What goes in this file will at the very end of the main div -->
+ default/templates/layouts/book.tpl view
@@ -0,0 +1,82 @@+<apply template="base">+  <bind tag="head-main"></bind>+  <bind tag="body-main">+    <div class="container mx-auto">++      <apply template="components/breadcrumbs" />++      <div id="container"+        class="flex flex-nowrap flex-col md:flex-row bg-gray-50 md:mt-8 md:shadow-2xl md:mb-8">+        <!-- Sidebar column -->+        <nav id="sidebar"+          class="flex-shrink hidden leading-relaxed md:block md:sticky md:top-0 md:h-full md:w-48 xl:w-64">+          <div class="px-2 py-2 text-gray-800">+            <div id="indexing-links" class="flex flex-row float-right p-2 space-x-2 text-gray-500">+              <a href="${ema:tagIndexUrl}" title="View tags">+                <svg style="width: 1rem;" class="hover:text-${theme}-700" fill="none"+                  stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+                    d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z">+                  </path>+                </svg>+              </a>+              <a href="${ema:indexUrl}" title="Expand full tree">+                <svg style="width: 1rem;" class="hover:text-${theme}-700" fill="none"+                  stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+                    d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4">+                  </path>+                </svg>+              </a>+              <a title="Search (Ctrl+K)" class="cursor-pointer"+                onclick="window.emanote.stork.toggleSearch()">+                <apply template="components/stork/stork-icon" />+              </a>+            </div>++            <div id="site-logo" class="pl-2">+              <div class="flex items-center my-2 space-x-2 justify-left">+                <a href="${ema:homeUrl}" title="Go to Home">+                  <ema:metadata>+                    <with var="template">+                      <!-- The style width attribute here is to prevent huge+                      icon from displaying at those rare occasions when Tailwind+                      hasn't kicked in immediately on page load +                      -->+                      <img style="width: 1rem;"+                        class="transition transform hover:scale-110 hover:opacity-80"+                        src="${value:iconUrl}" />+                    </with>+                  </ema:metadata>+                </a>+                <a class="font-bold truncate" title="Go to Home" href="${ema:homeUrl}">+                  Home+                </a>+              </div>+            </div>++            <ema:route-tree>+              <apply template="components/sidebar-tree" />+            </ema:route-tree>++          </div>+        </nav>++        <!-- Main body column -->+        <div class="flex-1 w-full overflow-x-auto bg-white">+          <main class="px-4 py-4">+            <apply template="components/note-title" />+            <apply template="components/note-body" />+            <div class="flex flex-col lg:flex-row lg:space-x-2">+              <apply template="components/timeline" />+              <apply template="components/backlinks" />+            </div>+            <apply template="components/metadata" />+            <apply template="/templates/hooks/note-end" />+          </main>+        </div>+      </div>+      <apply template="components/footer" />+    </div>+  </bind>+</apply>
+ default/templates/layouts/note.tpl view
@@ -0,0 +1,41 @@+<!-- TODO: Main navigation links (Home, index, tags, ...) -->++<!-- DoNotFormat -->+<bind tag="containerClass"><ema:metadata><value var="template.layout.note.containerClass" /></ema:metadata></bind>+<!-- DoNotFormat -->++<apply template="base">+  <bind tag="head-main">+    <link rel="stylesheet" href="${ema:emanoteStaticLayerUrl}/inverted-tree.css" />+  </bind>+  <bind tag="body-main">+    <div class="${containerClass}">+      <div class="mt-2 md:mt-4">+        <apply template="components/note-uptree" />++        <div class="relative md:shadow-2xl md:mb-8">++          <div class="absolute -top-6 right-1 md:right-0 flex flex-row items-center justify-center">+            <a title="Search (Ctrl+K)" class="cursor-pointer"+              onclick="window.emanote.stork.toggleSearch()">+              <apply template="components/stork/stork-icon" />+            </a>+          </div>+          <div class="flex-1 w-full overflow-x-auto bg-white">+            <main class="px-4 py-4">+              <apply template="components/note-title" />+              <apply template="components/note-body" />+              <div class="flex flex-col lg:flex-row lg:space-x-2">+                <apply template="components/timeline" />+                <apply template="components/backlinks" />+              </div>+              <apply template="components/metadata" />+              <apply template="/templates/hooks/note-end" />+            </main>+          </div>+        </div>+        <apply template="components/footer" />+      </div>+    </div>+  </bind>+</apply>
+ default/templates/special/base.tpl view
@@ -0,0 +1,18 @@+<apply template="/templates/base">+  <bind tag="body-main">+    <div class="container mx-auto ">+      <main class="flex-col items-center justify-center mx-2">+        <h1 class="pb-2 mt-2 mb-2 text-6xl text-center">+          <ema:title />+        </h1>+        <apply template="experimental">+          <bind tag="dnum">50</bind>+        </apply>+        <div class="pb-2 mx-auto my-4 lg:max-w-screen-md ">+          <special-main />+        </div>+      </main>+      <apply template="components/footer" />+    </div>+  </bind>+</apply>
+ default/templates/special/index.tpl view
@@ -0,0 +1,9 @@+<apply template="/templates/special/base">+  <bind tag="special-main">+    <div class="pt-1 pb-2 pl-4 bg-gray-200">+      <ema:route-tree>+        <apply template="components/sidebar-tree" />+      </ema:route-tree>+    </div>+  </bind>+</apply>
+ default/templates/special/tagindex.tpl view
@@ -0,0 +1,75 @@+<apply template="/templates/special/base">+  <bind tag="special-main">+    <div class="bg-gray-200 pb-2">+      <nav id="tagcrumbs" class="w-full pl-2 bg-gray-100">+        <div class="flex items-center justify-left">+          <div class="w-full px-2 py-2 ">+            <ul class="flex flex-wrap text-lg">+              <ema:tagcrumbs>+                <ema:each-crumb>+                  <li class="inline-flex items-center">+                    <a class="px-1 font-mono font-semibold text-${theme}-600 "+                      href="${ema:tagcrumb:url}">+                      <ema:tagcrumb:title />+                    </a>+                    <svg fill="currentColor" viewBox="0 0 20 20" class="w-auto h-5 ">+                      <path fill-rule="evenodd"+                        d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"+                        clip-rule="evenodd"></path>+                    </svg>+                  </li>+                </ema:each-crumb>+              </ema:tagcrumbs>+              <li class="inline-flex items-center text-black">+                <a class="px-1 font-mono">+                  <ema:tag:title />+                </a>+              </li>+            </ul>+          </div>+        </div>+      </nav>+      <div class="flex flex-col pl-2 my-4 ml-2 space-y-2">+        <ema:childTags>+          <ema:each-childTag>+            <div>+              <svg class="${iconSize} inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"+                xmlns="http://www.w3.org/2000/svg">+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+                  d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z">+                </path>+              </svg>+              <span class="">+                <a href="${ema:childTag:url}" class="font-mono font-bold hover:text-${theme}-700">+                  <ema:childTag:title />+                </a>+                <span class="font-mono text-sm text-gray-400">+                  <!-- DoNotFormat -->+                      (<ema:childTag:count-tag />+<ema:childTag:count-note />)+                      <!-- DoNotFormat -->+                </span>+              </span>+            </div>+          </ema:each-childTag>+        </ema:childTags>+        <ema:notes>+          <ema:each-note>+            <div>+              <svg class="${iconSize} inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"+                xmlns="http://www.w3.org/2000/svg">+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"+                  d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z">+                </path>+              </svg>++              <!-- TODO: DRY -->+              <a class="text-${theme}-600 hover:underline truncate" href="${ema:note:url}">+                <ema:note:title />+              </a>+            </div>+          </ema:each-note>+        </ema:notes>+      </div>+    </div>+  </bind>+</apply>
+ default/templates/special/tasks.tpl view
@@ -0,0 +1,51 @@+<apply template="/templates/special/base">+  <bind tag="special-main">+    <div class="w-full bg-gray-300">+      <div class="w-full px-3 py-1">+        <ema:taskGroups>+          <taskGroup>+            <section class="mt-4 mb-6">+              <div class="mt-1 mb-2 py-0.5 px-1 rounded flex flex-row-reverse">+                <t:note:breadcrumbs>+                  <each-crumb>+                    <li class="inline-flex items-center">+                      <a class="text-gray-900 opacity-50 hover:opacity-100" href="${crumb:url}">+                        <crumb:title />+                      </a>+                      <span class="text-gray-700 px-2">\</span>+                    </li>+                  </each-crumb>+                </t:note:breadcrumbs>++                <div class="flex-1">+                  <a class="font-bold text-${theme}-700  hover:underline" href="${t:note:url}">+                    <t:note:title />+                  </a>+                </div>+              </div>+              <div class="">+                <t:tasks>+                  <task>+                    <div+                      class="bg-white py-1.5 px-2 my-1 rounded shadow border-2 hover:border-${theme}-600">+                      <apply template="/templates/components/checkbox-unchecked">+                        <task:description>+                          <PandocLink class="text-gray-600">+                            <Internal class="font-bold hover:bg-gray-50" />+                            <External class="hover:underline" target="_blank" rel="noopener" />+                          </PandocLink>+                          <OrderedList class="ml-4 space-y-1 list-decimal list-inside" />+                          <BulletList class="ml-4 space-y-1 list-decimal list-inside" />+                        </task:description>+                      </apply>+                    </div>+                  </task>+                </t:tasks>+              </div>+            </section>+          </taskGroup>+        </ema:taskGroups>+      </div>+    </div>+  </bind>+</apply>
+ emanote.cabal view
@@ -0,0 +1,214 @@+cabal-version:      2.4+name:               emanote+version:            0.8.0.0+license:            AGPL-3.0-only+copyright:          2022 Sridhar Ratnakumar+maintainer:         srid@srid.ca+author:             Sridhar Ratnakumar+category:           Web+synopsis:           Emanate a structured view of your plain-text notes+description:+  Create beautiful websites -- such as personal webpage, blog, wiki, Zettelkasten, notebook, knowledge-base, documentation, etc. from future-proof plain-text notes and arbitrary data -- with live preview that updates in real-time.++-- A URL where users can report bugs.+bug-reports:        https://github.com/EmaApps/emanote/issues+extra-source-files:+  LICENSE+  README.md++data-dir:           default+data-files:+  _emanote-bin/compile-css+  _emanote-live-server/**/*.css+  _emanote-static/**/*.ttf+  _emanote-static/*.css+  _emanote-static/*.svg+  _emanote-static/stork/flat.css+  _emanote-static/stork/stork.js+  _emanote-static/stork/stork.wasm+  favicon.svg+  index.md+  index.yaml+  templates/**/*.tpl++common haskell-common+  ghc-options:+    -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns+    -Wmissing-deriving-strategies -Wunused-foralls -Wunused-foralls+    -fprint-explicit-foralls -fprint-explicit-kinds++  default-extensions:+    NoImplicitPrelude+    NoStarIsType+    BangPatterns+    ConstraintKinds+    DataKinds+    DeriveDataTypeable+    DeriveFoldable+    DeriveFunctor+    DeriveGeneric+    DeriveLift+    DeriveTraversable+    DerivingStrategies+    DerivingVia+    EmptyCase+    EmptyDataDecls+    EmptyDataDeriving+    ExistentialQuantification+    ExplicitForAll+    FlexibleContexts+    FlexibleInstances+    GADTSyntax+    GeneralisedNewtypeDeriving+    ImportQualifiedPost+    KindSignatures+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    NumericUnderscores+    OverloadedStrings+    PolyKinds+    PostfixOperators+    RankNTypes+    ScopedTypeVariables+    StandaloneDeriving+    StandaloneKindSignatures+    TupleSections+    TypeApplications+    TypeFamilies+    TypeOperators+    ViewPatterns++common library-common+  import:           haskell-common+  default-language: Haskell2010++  -- TODO: We could use the ghcid flag trick in neuron.cabal to avoid rebuilds.+  hs-source-dirs:   src+  build-depends:+    , aeson+    , aeson-extra+    , aeson-optics+    , async+    , base                   >=4.14    && <5+    , blaze-html+    , bytestring+    , commonmark+    , commonmark-extensions+    , commonmark-pandoc+    , commonmark-simple+    , containers+    , data-default+    , dependent-sum+    , directory+    , ema                    >=0.8.2+    , filepath+    , filepattern+    , fsnotify+    , hedgehog+    , heist-emanote          >=1.2.1+    , heist-extra+    , hspec+    , hspec-hedgehog+    , ixset-typed            >=0.5.1.0+    , lvar+    , map-syntax+    , megaparsec+    , monad-logger+    , monad-logger-extras+    , mtl+    , neat-interpolation+    , optics-core+    , optics-th+    , optparse-applicative+    , pandoc+    , pandoc-link-context    >=1.4.0+    , pandoc-types+    , parsec+    , path-tree              >=0.2+    , process-extras+    , profunctors+    , relude                 >=1.0+    , shower+    , some+    , stm+    , tagged+    , tagtree+    , tailwind               >=0.3+    , text+    , time+    , tomland+    , unionmount             >=0.2+    , unliftio+    , unordered-containers+    , uri-encode+    , url-slug+    , uuid+    , which+    , with-utf8+    , xmlhtml+    , yaml++  hs-source-dirs:   src++library+  import:          library-common+  exposed-modules:+    Emanote+    Emanote.CLI+    Emanote.Model+    Emanote.Model.Calendar+    Emanote.Model.Graph+    Emanote.Model.Link.Rel+    Emanote.Model.Link.RelSpec+    Emanote.Model.Link.Resolve+    Emanote.Model.Meta+    Emanote.Model.Note+    Emanote.Model.Note.Filter+    Emanote.Model.Query+    Emanote.Model.QuerySpec+    Emanote.Model.SData+    Emanote.Model.StaticFile+    Emanote.Model.Stork+    Emanote.Model.Stork.Index+    Emanote.Model.Task+    Emanote.Model.Title+    Emanote.Model.Type+    Emanote.Pandoc.BuiltinFilters+    Emanote.Pandoc.Link+    Emanote.Pandoc.Markdown.Parser+    Emanote.Pandoc.Markdown.Syntax.HashTag+    Emanote.Pandoc.Markdown.Syntax.Highlight+    Emanote.Pandoc.Markdown.Syntax.WikiLink+    Emanote.Pandoc.Renderer+    Emanote.Pandoc.Renderer.Embed+    Emanote.Pandoc.Renderer.Query+    Emanote.Pandoc.Renderer.Url+    Emanote.Prelude+    Emanote.Route+    Emanote.Route.Ext+    Emanote.Route.ModelRoute+    Emanote.Route.R+    Emanote.Route.SiteRoute+    Emanote.Route.SiteRoute.Class+    Emanote.Route.SiteRoute.Type+    Emanote.Source.Dynamic+    Emanote.Source.Loc+    Emanote.Source.Patch+    Emanote.Source.Pattern+    Emanote.View+    Emanote.View.Common+    Emanote.View.Export+    Emanote.View.LiveServerFiles+    Emanote.View.TagIndex+    Emanote.View.TaskIndex+    Emanote.View.Template+    Paths_emanote+    Spec++  autogen-modules: Paths_emanote++executable emanote+  import:      library-common+  main-is:     Main.hs+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+ src/Emanote.hs view
@@ -0,0 +1,160 @@+{-# LANGUAGE RecordWildCards #-}+{-# OPTIONS_GHC -Wno-orphans #-}++module Emanote+  ( run,+    defaultEmanoteConfig,+  )+where++import Control.Monad.Logger (LogLevel (LevelError), runStderrLoggingT, runStdoutLoggingT)+import Control.Monad.Logger.Extras (Logger (Logger), logToStderr, runLoggerLoggingT)+import Control.Monad.Writer.Strict (MonadWriter (tell), WriterT (runWriterT))+import Data.Default (def)+import Data.Dependent.Sum (DSum ((:=>)))+import Data.Map.Strict qualified as Map+import Ema+  ( EmaSite (..),+    IsRoute (..),+    fromPrism_,+    runSiteWithCli,+    toPrism_,+  )+import Ema.CLI qualified+import Ema.Dynamic (Dynamic (Dynamic))+import Emanote.CLI qualified as CLI+import Emanote.Model.Link.Rel (ResolvedRelTarget (..))+import Emanote.Model.Type (modelCompileTailwind)+import Emanote.Model.Type qualified as Model+import Emanote.Pandoc.Renderer+import Emanote.Pandoc.Renderer.Embed qualified as PF+import Emanote.Pandoc.Renderer.Query qualified as PF+import Emanote.Pandoc.Renderer.Url qualified as PF+import Emanote.Prelude (log, logE, logW)+import Emanote.Route.ModelRoute (LMLRoute, lmlRouteCase)+import Emanote.Route.SiteRoute.Class (emanoteGeneratableRoutes, emanoteRouteEncoder)+import Emanote.Route.SiteRoute.Type (SiteRoute)+import Emanote.Source.Dynamic (EmanoteConfig (..), emanoteSiteInput)+import Emanote.View.Common (generatedCssFile)+import Emanote.View.Export qualified as Export+import Emanote.View.Template qualified as View+import Optics.Core ((%), (.~), (^.))+import Relude+import System.FilePath ((</>))+import UnliftIO (MonadUnliftIO)+import Web.Tailwind qualified as Tailwind++instance IsRoute SiteRoute where+  type RouteModel SiteRoute = Model.ModelEma+  routePrism = toPrism_ . emanoteRouteEncoder+  routeUniverse = emanoteGeneratableRoutes++instance EmaSite SiteRoute where+  type SiteArg SiteRoute = EmanoteConfig+  siteInput = emanoteSiteInput+  siteOutput = View.emanoteSiteOutput++defaultEmanoteConfig :: CLI.Cli -> EmanoteConfig+defaultEmanoteConfig cli =+  EmanoteConfig cli id defaultEmanotePandocRenderers False++run :: EmanoteConfig -> IO ()+run cfg@EmanoteConfig {..} = do+  case CLI.cmd _emanoteConfigCli of+    CLI.Cmd_Ema emaCli ->+      Ema.runSiteWithCli @SiteRoute emaCli cfg+        >>= postRun cfg+    CLI.Cmd_Export -> do+      Dynamic (unModelEma -> model0, _) <-+        flip runLoggerLoggingT oneOffLogger $+          siteInput @SiteRoute (Ema.CLI.action def) cfg+      putLBSLn $ Export.renderGraphExport model0+  where+    -- A logger suited for running one-off commands.+    oneOffLogger =+      logToStderr+        & allowLogLevelFrom LevelError+      where+        allowLogLevelFrom :: LogLevel -> Logger -> Logger+        allowLogLevelFrom minLevel (Logger f) = Logger $ \loc src level msg ->+          if level >= minLevel+            then f loc src level msg+            else pass++postRun :: EmanoteConfig -> (Model.ModelEma, DSum Ema.CLI.Action Identity) -> IO ()+postRun EmanoteConfig {..} = \case+  (unModelEma -> model0, Ema.CLI.Generate outPath :=> Identity genPaths) -> do+    when (model0 ^. modelCompileTailwind) $+      compileTailwindCss (outPath </> generatedCssFile) genPaths+    checkBrokenLinks _emanoteConfigCli $ Export.modelRels model0+    checkBadMarkdownFiles $ Model.modelNoteErrors model0+  _ ->+    pass++unModelEma :: Model.ModelEma -> Model.Model+unModelEma m = Model.withRoutePrism (fromPrism_ $ routePrism @SiteRoute m) m++checkBadMarkdownFiles :: Map LMLRoute [Text] -> IO ()+checkBadMarkdownFiles noteErrs = runStderrLoggingT $ do+  forM_ (Map.toList noteErrs) $ \(noteRoute, errs) -> do+    logW $ "Bad markdown file: " <> show noteRoute+    forM_ errs $ \err -> do+      logE $ "  - " <> err+  unless (null noteErrs) $ do+    logE "Errors found."+    exitFailure++checkBrokenLinks :: CLI.Cli -> Map LMLRoute [Export.Link] -> IO ()+checkBrokenLinks cli modelRels = runStderrLoggingT $ do+  ((), res :: Sum Int) <- runWriterT $+    forM_ (Map.toList modelRels) $ \(noteRoute, rels) ->+      forM_ (sortNub rels) $ \(Export.Link urt rrt) ->+        case rrt of+          RRTFound _ -> pass+          RRTMissing -> do+            logW $ "Broken link: " <> show (lmlRouteCase noteRoute) <> " -> " <> show urt+            tell 1+          RRTAmbiguous ls -> do+            logW $ "Ambiguous link: " <> show (lmlRouteCase noteRoute) <> " -> " <> show urt <> " ambiguities: " <> show ls+            tell 1+  if res == 0+    then do+      log "No broken links detected."+    else unless (CLI.allowBrokenLinks cli) $ do+      logE $ "Found " <> show (getSum res) <> " broken links! Emanote generated the site, but the generated site has broken links."+      log "(Tip: use `--allow-broken-links` to ignore this check.)"+      exitFailure++compileTailwindCss :: MonadUnliftIO m => FilePath -> [FilePath] -> m ()+compileTailwindCss cssPath genPaths = do+  runStdoutLoggingT $ do+    log $ "Running Tailwind CSS v3 compiler to generate: " <> toText cssPath+    Tailwind.runTailwind $+      def+        & Tailwind.tailwindConfig % Tailwind.tailwindConfigContent .~ genPaths+        & Tailwind.tailwindOutput .~ cssPath+        & Tailwind.tailwindMode .~ Tailwind.Production++defaultEmanotePandocRenderers :: EmanotePandocRenderers Model.Model LMLRoute+defaultEmanotePandocRenderers =+  let blockRenderers =+        PandocRenderers+          [ PF.embedInlineWikiLinkResolvingSplice, -- embedInlineWikiLinkResolvingSplice should be first to recognize inline Link elements first+            PF.urlResolvingSplice+          ]+          [ PF.embedBlockWikiLinkResolvingSplice,+            PF.embedBlockRegularLinkResolvingSplice,+            PF.queryResolvingSplice+          ]+      inlineRenderers =+        PandocRenderers+          [ PF.embedInlineWikiLinkResolvingSplice, -- embedInlineWikiLinkResolvingSplice should be first to recognize inline Link elements first+            PF.urlResolvingSplice+          ]+          mempty+      linkInlineRenderers =+        PandocRenderers+          [ PF.plainifyWikiLinkSplice+          ]+          mempty+   in EmanotePandocRenderers {..}
+ src/Emanote/CLI.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE RecordWildCards #-}++module Emanote.CLI+  ( Cli (..),+    Cmd (..),+    parseCli,+    cliParser,+  )+where++import Data.Text qualified as T+import Data.Version (showVersion)+import Ema.CLI qualified+import Options.Applicative hiding (action)+import Paths_emanote qualified+import Relude+import UnliftIO.Directory (getCurrentDirectory)++data Cli = Cli+  { layers :: NonEmpty FilePath,+    test :: Bool,+    allowBrokenLinks :: Bool,+    cmd :: Cmd+  }++data Cmd+  = Cmd_Ema Ema.CLI.Cli+  | Cmd_Export++cliParser :: FilePath -> Parser Cli+cliParser cwd = do+  layers <- pathList (one cwd)+  test <- switch (long "test" <> help "Run tests")+  allowBrokenLinks <- switch (long "allow-broken-links" <> help "Report but do not fail on broken links")+  cmd <-+    fmap Cmd_Ema Ema.CLI.cliParser+      <|> subparser (command "export" (info (pure Cmd_Export) (progDesc "Export metadata JSON")))+  pure Cli {..}+  where+    pathList defaultPath = do+      option pathListReader $+        mconcat+          [ long "layers",+            short 'L',+            metavar "LAYERS",+            value defaultPath,+            help "List of (semicolon delimited) notebook folders to 'union mount', with the left-side folders being overlaid on top of the right-side ones. The default layer is implicitly included at the end of this list."+          ]+    pathListReader :: ReadM (NonEmpty FilePath)+    pathListReader =+      maybeReader $ \paths ->+        nonEmpty $ fmap toString $ T.split (== ';') . toText $ paths++parseCli' :: FilePath -> ParserInfo Cli+parseCli' cwd =+  info+    (versionOption <*> cliParser cwd <**> helper)+    ( fullDesc+        <> progDesc "Emanote - A spiritual successor to Neuron"+        <> header "Emanote"+    )+  where+    versionOption =+      infoOption+        (showVersion Paths_emanote.version)+        (long "version" <> help "Show version")++parseCli :: IO Cli+parseCli =+  execParser . parseCli' =<< getCurrentDirectory
+ src/Emanote/Model.hs view
@@ -0,0 +1,3 @@+module Emanote.Model (module X) where++import Emanote.Model.Type as X
+ src/Emanote/Model/Calendar.hs view
@@ -0,0 +1,46 @@+-- | A primitive module to eventually pave way towards first-class "calendar"+-- (daily notes, etc.) support in Emanote; either built-in or as plugin.+module Emanote.Model.Calendar where++import Data.Time.Calendar (Day, fromGregorianValid)+import Emanote.Model.Note qualified as N+import Emanote.Model.Title (Title)+import Emanote.Model.Type (Model, modelLookupTitle)+import Emanote.Route (LMLRoute)+import Emanote.Route qualified as R+import Relude+import Text.Megaparsec qualified as M+import Text.Megaparsec.Char qualified as M++-- HACK: This is so that calendar backlinks are sorted properly.+backlinkSortKey :: Model -> LMLRoute -> Down Title+backlinkSortKey model =+  Down . flip modelLookupTitle model++-- HACK: Until we have a proper search support. This sorts query results for+-- timeline+noteSortKey :: N.Note -> (Down (Maybe Text), LMLRoute)+noteSortKey note =+  (Down $ N.lookupMeta @Text (one "date") note, N._noteRoute note)++isDailyNote :: LMLRoute -> Bool+isDailyNote =+  isJust . parseRouteDay++parseRouteDay :: LMLRoute -> Maybe Day+parseRouteDay =+  M.parseMaybe parse . R.withLmlRoute R.routeBaseName+  where+    parse :: M.Parsec Void Text Day+    parse = do+      let asInt = maybe (fail "Not an int") pure . readMaybe+      -- Year+      year <- asInt =<< replicateM 4 M.digitChar+      void $ M.string "-"+      -- Month+      month <- asInt =<< replicateM 2 M.digitChar+      void $ M.string "-"+      -- Day+      day <- asInt =<< replicateM 2 M.digitChar+      maybe (fail "Not a date") pure $+        fromGregorianValid year (fromInteger month) (fromInteger day)
+ src/Emanote/Model/Graph.hs view
@@ -0,0 +1,115 @@+module Emanote.Model.Graph where++import Data.IxSet.Typed ((@+), (@=))+import Data.IxSet.Typed qualified as Ix+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Tree (Forest, Tree (Node))+import Emanote.Model.Calendar qualified as Calendar+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Link.Resolve qualified as Resolve+import Emanote.Model.Meta (lookupRouteMeta)+import Emanote.Model.Note qualified as MN+import Emanote.Model.Type (Model, modelRels, resolveLmlRoute)+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Route qualified as R+import Emanote.Route.ModelRoute (ModelRoute)+import Optics.Operators as Lens ((^.))+import Relude hiding (empty)+import Text.Pandoc.Definition qualified as B++-- TODO: Do breadth-first instead of depth-first+modelFolgezettelAncestorTree :: ModelRoute -> Model -> Forest R.LMLRoute+modelFolgezettelAncestorTree r0 model =+  fst $ usingState mempty $ go r0+  where+    go :: MonadState (Set ModelRoute) m => ModelRoute -> m (Forest R.LMLRoute)+    go r = do+      let folgezettelBacklinks =+            backlinkRels r model+              & filter (isFolgezettel . (^. Rel.relTo))+              <&> (^. Rel.relFrom)+          -- Handle reverse folgezettel links here+          folgezettelFrontlinks =+            frontlinkRels r model+              & mapMaybe (lookupWikiLink <=< selectReverseFolgezettel . (^. Rel.relTo))+          -- Folders are automatically made a folgezettel+          folgezettelFolder =+            maybeToList $ do+              lmlR <- leftToMaybe (R.modelRouteCase r)+              guard $ lookupRouteMeta True ("emanote" :| ["folder-folgezettel"]) lmlR model+              parentLmlRoute model lmlR+          folgezettelParents =+            mconcat+              [ folgezettelBacklinks,+                folgezettelFrontlinks,+                folgezettelFolder+              ]+      fmap catMaybes . forM folgezettelParents $ \parentR -> do+        let parentModelR = R.ModelRoute_LML parentR+        gets (parentModelR `Set.member`) >>= \case+          True -> pure Nothing+          False -> do+            modify $ Set.insert parentModelR+            sub <- go parentModelR+            pure $ Just $ Node parentR sub+    isFolgezettel = \case+      Rel.URTWikiLink (WL.WikiLinkBranch, _wl) ->+        True+      _ ->+        False+    selectReverseFolgezettel :: Rel.UnresolvedRelTarget -> Maybe WL.WikiLink+    selectReverseFolgezettel = \case+      Rel.URTWikiLink (WL.WikiLinkTag, wl) -> Just wl+      _ -> Nothing+    lookupWikiLink :: WL.WikiLink -> Maybe R.LMLRoute+    lookupWikiLink wl = do+      note <- leftToMaybe <=< getFound $ Resolve.resolveWikiLinkMustExist model wl+      pure $ note ^. MN.noteRoute+    getFound :: Rel.ResolvedRelTarget a -> Maybe a+    getFound = \case+      Rel.RRTFound x -> Just x+      _ -> Nothing++-- | Return the route to parent folder (unless indexRoute is passed).+--+--  This will return the existing note (.org or .md) if possible. Otherwise+--  fallback to .md even if missing.+parentLmlRoute :: Model -> R.LMLRoute -> Maybe R.LMLRoute+parentLmlRoute model r = do+  pr <- do+    let lmlR = R.lmlRouteCase r+    -- Root index do not have a parent folder.+    guard $ lmlR /= Left R.indexRoute && lmlR /= Right R.indexRoute+    -- Consider the index route as parent folder for all+    -- top-level notes.+    pure $ fromMaybe R.indexRoute $ R.withLmlRoute R.routeParent r+  pure $ resolveLmlRoute model . coerce $ pr++modelLookupBacklinks :: ModelRoute -> Model -> [(R.LMLRoute, NonEmpty [B.Block])]+modelLookupBacklinks r model =+  sortOn (Calendar.backlinkSortKey model . fst) $+    groupNE $+      backlinkRels r model <&> \rel ->+        (rel ^. Rel.relFrom, rel ^. Rel.relCtx)+  where+    groupNE :: forall a b. Ord a => [(a, b)] -> [(a, NonEmpty b)]+    groupNE =+      Map.toList . foldl' f Map.empty+      where+        f :: Map a (NonEmpty b) -> (a, b) -> Map a (NonEmpty b)+        f m (x, y) =+          case Map.lookup x m of+            Nothing -> Map.insert x (one y) m+            Just ys -> Map.insert x (ys <> one y) m++backlinkRels :: ModelRoute -> Model -> [Rel.Rel]+backlinkRels r model =+  let allPossibleLinks = Rel.unresolvedRelsTo r+   in Ix.toList $ (model ^. modelRels) @+ allPossibleLinks++frontlinkRels :: ModelRoute -> Model -> [Rel.Rel]+frontlinkRels r model =+  maybeToMonoid $ do+    lmlR <- leftToMaybe $ R.modelRouteCase r+    pure $ Ix.toList $ (model ^. modelRels) @= lmlR
+ src/Emanote/Model/Link/Rel.hs view
@@ -0,0 +1,144 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.Link.Rel where++import Data.Aeson (ToJSON)+import Data.IxSet.Typed (Indexable (..), IxSet, ixFun, ixList)+import Data.IxSet.Typed qualified as Ix+import Data.List.NonEmpty qualified as NEL+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Emanote.Model.Note (Note, noteDoc, noteRoute)+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Route (LMLRoute, ModelRoute)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute.Type qualified as SR+import Optics.Operators as Lens ((^.))+import Optics.TH (makeLenses)+import Relude+import System.FilePath (normalise, (</>))+import Text.Pandoc.Definition qualified as B+import Text.Pandoc.LinkContext qualified as LC++-- | A relation from one note to anywhere in the model.+--+-- Target will remain unresolved in the `Rel`, and can be resolved at a latter+-- time (eg: during rendering).+data Rel = Rel+  { -- The note containing this relation+    _relFrom :: LMLRoute,+    -- The target of the relation (can be a note or anything)+    _relTo :: UnresolvedRelTarget,+    -- | The relation context in LML+    _relCtx :: [B.Block]+  }+  deriving stock (Eq, Ord, Show)++-- | A link target that has not been resolved (using model) yet.+--+-- Resolving this may or may not result in a resource in the model. The ADT+-- constructors capture the different possible types of links the user is+-- allowed to link to.+data UnresolvedRelTarget+  = URTWikiLink (WL.WikiLinkType, WL.WikiLink)+  | URTResource ModelRoute+  | URTVirtual SR.VirtualRoute+  deriving stock (Eq, Show, Ord, Generic)+  deriving anyclass (ToJSON)++type RelIxs = '[LMLRoute, UnresolvedRelTarget]++type IxRel = IxSet RelIxs Rel++instance Indexable RelIxs Rel where+  indices =+    ixList+      (ixFun $ one . _relFrom)+      (ixFun $ one . _relTo)++makeLenses ''Rel++noteRels :: Note -> IxRel+noteRels note =+  extractLinks . LC.queryLinksWithContext $ note ^. noteDoc+  where+    extractLinks :: Map Text (NonEmpty ([(Text, Text)], [B.Block])) -> IxRel+    extractLinks m =+      Ix.fromList $+        flip concatMap (Map.toList m) $ \(url, instances) -> do+          flip mapMaybe (toList instances) $ \(attrs, ctx) -> do+            let parentR = R.withLmlRoute R.routeParent $ note ^. noteRoute+            (target, _manchor) <- parseUnresolvedRelTarget parentR attrs url+            pure $ Rel (note ^. noteRoute) target ctx++unresolvedRelsTo :: ModelRoute -> [UnresolvedRelTarget]+unresolvedRelsTo r =+  let wls = either (R.withLmlRoute WL.allowedWikiLinks) WL.allowedWikiLinks $ R.modelRouteCase r+   in (URTWikiLink <$> toList wls)+        <> [URTResource r]++-- | Parse a relative URL string for later resolution.+--+-- TODO: Need tests for this function.+parseUnresolvedRelTarget :: Maybe (R.R 'R.Folder) -> [(Text, Text)] -> Text -> Maybe (UnresolvedRelTarget, Maybe WL.Anchor)+parseUnresolvedRelTarget baseDir attrs url = do+  (wlRes, manchor) <- WL.delineateLink attrs url+  res <- case wlRes of+    Left wl ->+      pure $ URTWikiLink wl+    Right fp ->+      fmap URTVirtual (SR.decodeVirtualRoute fp)+        <|> fmap+          URTResource+          ( fp+              & relocateRelUrlUnder (R.encodeRoute <$> baseDir)+              & R.mkModelRouteFromFilePath+          )+  pure (res, manchor)++relocateRelUrlUnder :: Maybe FilePath -> FilePath -> FilePath+relocateRelUrlUnder mbase fp =+  normalizeIgnoringSymlinks $+    case mbase of+      Nothing -> fp+      Just x -> x </> fp++-- | Like `System.FilePath.normalise` but also normalises '..'+normalizeIgnoringSymlinks :: FilePath -> FilePath+normalizeIgnoringSymlinks = dropDotDot . normalise++-- Remove '..' from path component.+--+-- `System.FilePath.normalize` ought to do this already, but it doesn't due to+-- symlinks (which we don't use anyway.)+--+-- See https://github.com/haskell/filepath/issues/87+dropDotDot :: FilePath -> FilePath+dropDotDot =+  let go :: Int -> NonEmpty Text -> [Text]+      go n = \case+        (".." :| xs) -> maybe [] (go $ n + 1) $ nonEmpty xs+        (x :| xs) | n == 0 -> x : maybe [] (go 0) (nonEmpty xs)+        x -> maybe [] (go 0) $ nonEmpty $ NEL.drop n x+   in toString . T.intercalate "/" . maybe [] (reverse . go 0 . NEL.reverse) . nonEmpty . T.splitOn "/" . toText++-- | An `UnresolvedRelTarget` that has been resolved.+--+-- See @Model.Link.Resolve@ for actual resolution logic.+data ResolvedRelTarget a+  = RRTMissing+  | RRTAmbiguous (NonEmpty a)+  | RRTFound a+  deriving stock (Eq, Show, Ord, Functor, Generic)+  deriving anyclass (ToJSON)++resolvedRelTargetFromCandidates :: [a] -> ResolvedRelTarget a+resolvedRelTargetFromCandidates xs =+  case nonEmpty xs of+    Nothing ->+      RRTMissing+    Just (x :| []) ->+      RRTFound x+    Just xs' ->+      RRTAmbiguous xs'
+ src/Emanote/Model/Link/RelSpec.hs view
@@ -0,0 +1,32 @@+module Emanote.Model.Link.RelSpec where++import Emanote.Model.Link.Rel+import Hedgehog+import Relude+import Test.Hspec+import Test.Hspec.Hedgehog++spec :: Spec+spec = do+  describe "dropDotDot" $ do+    it "simple" . hedgehog $ do+      dropDotDot "foo/bar/qux" === "foo/bar/qux"+      dropDotDot "foo/../qux" === "qux"+      dropDotDot "bar/foo/../qux" === "bar/qux"+      dropDotDot "bar/foo/.." === "bar"+    it "dotInfix" . hedgehog $ do+      dropDotDot "bar/foo/../../qux" === "qux"+      dropDotDot "bar/foo/../../qux/../foo" === "foo"+    it "dotPrefix" . hedgehog $ do+      dropDotDot "../../foo" === "foo"+      dropDotDot "../foo" === "foo"+      dropDotDot "./../foo" === "foo"+      dropDotDot "./foo" === "./foo"+    it "dotSuffix" . hedgehog $ do+      dropDotDot "foo/.." === ""+      dropDotDot "foo/bar/.." === "foo"+      dropDotDot "foo/bar/../.." === ""++-- https://github.com/hedgehogqa/haskell-hedgehog/issues/121+once :: PropertyT IO () -> Property+once = withTests 1 . property
+ src/Emanote/Model/Link/Resolve.hs view
@@ -0,0 +1,45 @@+module Emanote.Model.Link.Resolve where++import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Note qualified as MN+import Emanote.Model.StaticFile qualified as SF+import Emanote.Model.Type (Model)+import Emanote.Model.Type qualified as M+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute qualified as SR+import Relude++resolveUnresolvedRelTarget ::+  Model ->+  Rel.UnresolvedRelTarget ->+  Rel.ResolvedRelTarget SR.SiteRoute+resolveUnresolvedRelTarget model = \case+  Rel.URTWikiLink (_wlType, wl) -> do+    resolveWikiLinkMustExist model wl+      <&> resourceSiteRoute+  Rel.URTResource r ->+    resolveModelRoute model r+      <&> resourceSiteRoute+  Rel.URTVirtual virtualRoute -> do+    Rel.RRTFound $+      SR.SiteRoute_VirtualRoute+        virtualRoute++resolveWikiLinkMustExist ::+  Model -> WL.WikiLink -> Rel.ResolvedRelTarget (Either MN.Note SF.StaticFile)+resolveWikiLinkMustExist model wl =+  Rel.resolvedRelTargetFromCandidates $ M.modelWikiLinkTargets wl model++resolveModelRoute ::+  Model -> R.ModelRoute -> Rel.ResolvedRelTarget (Either MN.Note SF.StaticFile)+resolveModelRoute model lr =+  bitraverse+    (`M.modelLookupNoteByRoute` model)+    (`M.modelLookupStaticFileByRoute` model)+    (R.modelRouteCase lr)+    & maybe Rel.RRTMissing Rel.RRTFound++resourceSiteRoute :: Either MN.Note SF.StaticFile -> SR.SiteRoute+resourceSiteRoute =+  either SR.noteFileSiteRoute SR.staticFileSiteRoute
+ src/Emanote/Model/Meta.hs view
@@ -0,0 +1,43 @@+module Emanote.Model.Meta+  ( lookupRouteMeta,+    getEffectiveRouteMeta,+    getEffectiveRouteMetaWith,+  )+where++import Data.Aeson (FromJSON)+import Data.Aeson qualified as Aeson+import Data.IxSet.Typed qualified as Ix+import Emanote.Model (ModelT, modelLookupNoteByRoute, modelSData)+import Emanote.Model.Note (_noteMeta)+import Emanote.Model.SData (sdataValue)+import Emanote.Model.SData qualified as SData+import Emanote.Route qualified as R+import Optics.Operators as Lens ((^.))+import Relude++-- | Look up a specific key in the meta for a given route.+lookupRouteMeta :: FromJSON a => a -> NonEmpty Text -> R.LMLRoute -> ModelT f -> a+lookupRouteMeta x k r =+  SData.lookupAeson x k . getEffectiveRouteMeta r++-- | Get the (final) metadata of a note at the given route, by merging it with+-- the defaults specified in parent routes all the way upto index.yaml.+getEffectiveRouteMeta :: R.LMLRoute -> ModelT f -> Aeson.Value+getEffectiveRouteMeta mr model =+  let mNote = modelLookupNoteByRoute mr model+   in getEffectiveRouteMetaWith (maybe Aeson.Null _noteMeta mNote) mr model++getEffectiveRouteMetaWith :: Aeson.Value -> R.LMLRoute -> ModelT f -> Aeson.Value+getEffectiveRouteMetaWith frontmatter mr model =+  let defaultFiles = R.routeInits @'R.Yaml (R.withLmlRoute coerce mr)+      defaults = flip mapMaybe (toList defaultFiles) $ \r -> do+        v <- getYamlMeta r model+        guard $ v /= Aeson.Null+        pure v+      metas = defaults <> maybe mempty one (guard (frontmatter /= Aeson.Null) >> pure frontmatter)+   in maybe Aeson.Null SData.mergeAesons $ nonEmpty metas++getYamlMeta :: R.R 'R.Yaml -> ModelT f -> Maybe Aeson.Value+getYamlMeta r model =+  fmap (^. sdataValue) . Ix.getOne . Ix.getEQ r $ model ^. modelSData
+ src/Emanote/Model/Note.hs view
@@ -0,0 +1,345 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.Note where++import Control.Monad.Logger (MonadLogger)+import Control.Monad.Writer (MonadWriter (tell), WriterT, runWriterT)+import Data.Aeson qualified as Aeson+import Data.Aeson.Optics qualified as AO+import Data.Default (Default (def))+import Data.IxSet.Typed (Indexable (..), IxSet, ixFun, ixList)+import Data.IxSet.Typed qualified as Ix+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Emanote.Model.Note.Filter (applyPandocFilters)+import Emanote.Model.SData qualified as SData+import Emanote.Model.Title qualified as Tit+import Emanote.Pandoc.Markdown.Parser qualified as Markdown+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Pandoc.Markdown.Syntax.WikiLink (plainify)+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Route (FileType (Folder), R)+import Emanote.Route qualified as R+import Network.URI.Slug (Slug)+import Optics.Core ((%), (.~))+import Optics.TH (makeLenses)+import Relude+import System.FilePath ((</>))+import Text.Pandoc (runPure)+import Text.Pandoc.Builder qualified as B+import Text.Pandoc.Definition (Pandoc (..))+import Text.Pandoc.Readers.Org (readOrg)+import Text.Pandoc.Walk qualified as W++data Note = Note+  { _noteRoute :: R.LMLRoute,+    _noteDoc :: Pandoc,+    _noteMeta :: Aeson.Value,+    _noteTitle :: Tit.Title,+    _noteErrors :: [Text]+  }+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Aeson.ToJSON)++newtype RAncestor = RAncestor {unRAncestor :: R 'R.Folder}+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Aeson.ToJSON)++type NoteIxs =+  '[ -- Route to this note+     R.LMLRoute,+     -- Allowed ways to wiki-link to this note.+     WL.WikiLink,+     -- HTML route for this note+     R 'R.Html,+     -- Ancestor folder routes+     RAncestor,+     -- Parent folder+     R 'R.Folder,+     -- Tag+     HT.Tag,+     -- Alias route for this note. Can be "foo" or "foo/bar".+     NonEmpty Slug+   ]++type IxNote = IxSet NoteIxs Note++instance Indexable NoteIxs Note where+  indices =+    ixList+      (ixFun $ one . _noteRoute)+      (ixFun $ toList . noteSelfRefs)+      (ixFun $ one . noteHtmlRoute)+      (ixFun noteAncestors)+      (ixFun $ maybeToList . noteParent)+      (ixFun noteTags)+      (ixFun $ maybeToList . noteSlug)++-- | All possible wiki-links that refer to this note.+noteSelfRefs :: Note -> NonEmpty WL.WikiLink+noteSelfRefs =+  routeSelfRefs+    . _noteRoute+  where+    routeSelfRefs :: R.LMLRoute -> NonEmpty WL.WikiLink+    routeSelfRefs =+      fmap snd+        . R.withLmlRoute WL.allowedWikiLinks++noteAncestors :: Note -> [RAncestor]+noteAncestors =+  maybe [] (toList . fmap RAncestor . R.routeInits) . noteParent++noteParent :: Note -> Maybe (R 'R.Folder)+noteParent = R.withLmlRoute R.routeParent . _noteRoute++hasChildNotes :: R 'Folder -> IxNote -> Bool+hasChildNotes r =+  not . Ix.null . Ix.getEQ r++noteTags :: Note -> [HT.Tag]+noteTags =+  fmap HT.Tag . maybeToMonoid . lookupMeta (one "tags")++noteSlug :: Note -> Maybe (NonEmpty Slug)+noteSlug note = do+  slugPath :: Text <- lookupMeta (one "slug") note+  fmap R.unRoute $ R.mkRouteFromFilePath @_ @'R.AnyExt $ toString slugPath++lookupMeta :: Aeson.FromJSON a => NonEmpty Text -> Note -> Maybe a+lookupMeta k =+  SData.lookupAeson Nothing k . _noteMeta++queryNoteTitle :: R.LMLRoute -> Pandoc -> Aeson.Value -> (Pandoc, Tit.Title)+queryNoteTitle r doc meta =+  let yamlNoteTitle = fromString <$> SData.lookupAeson Nothing (one "title") meta+      fileNameTitle = Tit.fromRoute r+      notePandocTitle = do+        case r of+          R.LMLRoute_Md _ ->+            getPandocTitle doc+          R.LMLRoute_Org _ ->+            getPandocMetaTitle doc+   in fromMaybe (doc, fileNameTitle) $+        fmap (doc,) yamlNoteTitle <|> fmap (withoutH1 doc,) notePandocTitle+  where+    getPandocTitle :: Pandoc -> Maybe Tit.Title+    getPandocTitle =+      fmap Tit.fromInlines . getPandocH1+      where+        getPandocH1 :: Pandoc -> Maybe [B.Inline]+        getPandocH1 (Pandoc _ (B.Header 1 _ inlines : _rest)) =+          Just inlines+        getPandocH1 _ =+          Nothing+    getPandocMetaTitle :: Pandoc -> Maybe Tit.Title+    getPandocMetaTitle (Pandoc docMeta _) = do+      B.MetaInlines inlines <- B.lookupMeta "title" docMeta+      pure $ Tit.fromInlines inlines+    withoutH1 :: B.Pandoc -> B.Pandoc+    withoutH1 (B.Pandoc m (B.Header 1 _ _ : rest)) =+      B.Pandoc m rest+    withoutH1 x =+      x++-- | The HTML route intended by user for this note.+noteHtmlRoute :: Note -> R 'R.Html+noteHtmlRoute note@Note {..} =+  -- Favour slug if one exists, otherwise use the full path.+  case noteSlug note of+    Nothing ->+      R.withLmlRoute coerce _noteRoute+    Just slugs ->+      R.mkRouteFromSlugs slugs++lookupNotesByHtmlRoute :: R 'R.Html -> IxNote -> [Note]+lookupNotesByHtmlRoute htmlRoute =+  Ix.toList . Ix.getEQ htmlRoute++lookupNotesByRoute :: HasCallStack => R.LMLRoute -> IxNote -> Maybe Note+lookupNotesByRoute r ix = do+  res <- nonEmpty $ Ix.toList $ Ix.getEQ r ix+  case res of+    note :| [] -> pure note+    _ -> error $ "ambiguous notes for route " <> show r++ancestorPlaceholderNote :: R.R 'Folder -> Note+ancestorPlaceholderNote r =+  let placeHolder =+        [ folderListingQuery,+          -- TODO: Ideally, we should use semantic tags, like <aside> (rather+          -- than <div>), to render these non-relevant content.+          B.Div (cls "emanote:placeholder-message") . one . B.Para $+            [ B.Str+                "Note: To override the auto-generated content here, create a file named one of: ",+              -- TODO: or, .org+              B.Span (cls "font-mono text-sm") $+                one $+                  B.Str $+                    oneOfLmlFilenames r+            ]+        ]+   in mkEmptyNoteWith (R.defaultLmlRoute r) placeHolder+  where+    folderListingQuery =+      B.CodeBlock (cls "query") "path:./*"++cls :: Text -> B.Attr+cls x =+  ("", one x, mempty) :: B.Attr++missingNote :: R.R ext -> Text -> Note+missingNote route404 urlPath =+  mkEmptyNoteWith (R.defaultLmlRoute route404) $+    one $+      B.Para+        [ B.Str "No note has the URL ",+          B.Code B.nullAttr $ "/" <> urlPath,+          -- TODO: org+          B.Span (cls "font-mono text-sm") $+            one $+              B.Str $+                ". You may create a file with that name, ie. one of: " <> oneOfLmlFilenames route404+        ]++oneOfLmlFilenames :: R ext -> Text+oneOfLmlFilenames r =+  T.intercalate+    ", "+    (toText . R.withLmlRoute R.encodeRoute <$> R.possibleLmlRoutes r)++ambiguousNoteURL :: FilePath -> NonEmpty R.LMLRoute -> Note+ambiguousNoteURL urlPath rs =+  mkEmptyNoteWith (head rs) $+    [ B.Para+        [ B.Str "The URL ",+          B.Code B.nullAttr $ toText urlPath,+          B.Str " is ambiguous, as more than one note (see list below) use it. To fix this, specify a different slug for these notes:"+        ]+    ]+      <> one candidates+  where+    candidates :: B.Block+    candidates =+      B.BulletList $+        toList rs <&> \(R.lmlRouteCase -> r) ->+          [ B.Plain $ one $ B.Str "  ",+            B.Plain $ one $ B.Code B.nullAttr $ show r+          ]++mkEmptyNoteWith :: R.LMLRoute -> [B.Block] -> Note+mkEmptyNoteWith someR (Pandoc mempty -> doc) =+  mkNoteWith someR doc meta mempty+  where+    meta = Aeson.Null++mkNoteWith :: R.LMLRoute -> Pandoc -> Aeson.Value -> [Text] -> Note+mkNoteWith r doc' meta errs =+  let (doc'', tit) = queryNoteTitle r doc' meta+      doc = if null errs then doc'' else pandocPrepend (errorDiv errs) doc''+   in Note r doc meta tit errs+  where+    -- Prepend to block to the beginning of a Pandoc document (never before H1)+    pandocPrepend :: B.Block -> Pandoc -> Pandoc+    pandocPrepend prefix (Pandoc docMeta blocks) =+      let blocks' = case blocks of+            (h1@(B.Header 1 _ _) : rest) ->+              h1 : prefix : rest+            _ -> prefix : blocks+       in Pandoc docMeta blocks'+    errorDiv :: [Text] -> B.Block+    errorDiv s =+      B.Div (cls "emanote:error") $ B.Para [B.Strong $ one $ B.Str "Emanote Errors 😔"] : (B.Para . one . B.Str <$> s)++parseNote ::+  forall m.+  (MonadIO m, MonadLogger m) =>+  FilePath ->+  R.LMLRoute ->+  FilePath ->+  Text ->+  m Note+parseNote pluginBaseDir r fp s = do+  ((doc, meta), errs) <- runWriterT $ do+    case r of+      R.LMLRoute_Md _ ->+        parseNoteMarkdown pluginBaseDir fp s+      R.LMLRoute_Org _ -> do+        parseNoteOrg s+  pure $ mkNoteWith r doc meta errs++parseNoteOrg :: (MonadWriter [Text] m) => Text -> m (Pandoc, Aeson.Value)+parseNoteOrg s =+  case runPure $ readOrg def s of+    Left err -> do+      tell [show err]+      pure (mempty, defaultFrontMatter)+    Right doc ->+      -- TODO: Merge Pandoc's Meta in here?+      pure (doc, defaultFrontMatter)++parseNoteMarkdown :: (MonadIO m, MonadLogger m) => FilePath -> FilePath -> Text -> WriterT [Text] m (Pandoc, Aeson.Value)+parseNoteMarkdown pluginBaseDir fp md = do+  case Markdown.parseMarkdown fp md of+    Left err -> do+      tell [err]+      pure (mempty, defaultFrontMatter)+    Right (withAesonDefault defaultFrontMatter -> frontmatter, doc') -> do+      -- Apply the various transformation filters.+      --+      -- Some are user-defined; some builtin. They operate on Pandoc, or the+      -- frontmatter meta.+      let filterPaths = (pluginBaseDir </>) <$> SData.lookupAeson @[FilePath] mempty ("pandoc" :| ["filters"]) frontmatter+      doc <- applyPandocFilters filterPaths doc'+      let meta = applyNoteMetaFilters doc frontmatter+      pure (doc, meta)+  where+    withAesonDefault default_ mv =+      fromMaybe default_ mv+        `SData.mergeAeson` default_++defaultFrontMatter :: Aeson.Value+defaultFrontMatter =+  Aeson.toJSON $ Map.fromList @Text @[Text] $ one ("tags", [])++applyNoteMetaFilters :: Pandoc -> Aeson.Value -> Aeson.Value+applyNoteMetaFilters doc =+  addTagsFromBody+    >>> addDescriptionFromBody+    >>> addImageFromBody+  where+    -- Merge frontmatter tags with inline tags in Pandoc document.+    -- DESIGN: In retrospect, this is like a Pandoc lua filter?+    addTagsFromBody frontmatter =+      frontmatter+        & AO.key "tags" % AO._Array+          .~ ( fromList . fmap Aeson.toJSON $+                 ordNub $+                   SData.lookupAeson @[HT.Tag] mempty (one "tags") frontmatter+                     <> HT.inlineTagsInPandoc doc+             )+    addDescriptionFromBody =+      overrideAesonText ("page" :| ["description"]) $ \case+        B.Para is -> [plainify is]+        _ -> mempty+    -- FIXME this doesn't take splice rendering into account. Specifically,+    -- `![[foo.jpeg]]` is not handled at all.+    addImageFromBody =+      overrideAesonText ("page" :| ["image"]) $ \case+        B.Image _ _ (url, _) -> [url]+        _ -> mempty+    overrideAesonText :: forall a. (W.Walkable a Pandoc) => NonEmpty Text -> (a -> [Text]) -> Aeson.Value -> Aeson.Value+    overrideAesonText key f frontmatter =+      SData.mergeAesons $+        frontmatter+          :| maybeToList+            ( do+                guard $ "" == SData.lookupAeson @Text "" key frontmatter+                val <- viaNonEmpty head $ W.query f doc+                pure $ SData.oneAesonText (toList key) val+            )++makeLenses ''Note
+ src/Emanote/Model/Note/Filter.hs view
@@ -0,0 +1,50 @@+module Emanote.Model.Note.Filter (applyPandocFilters) where++import Control.Monad.Logger (MonadLogger)+import Control.Monad.Writer.Strict (MonadWriter (tell))+import Data.Default (def)+import Emanote.Prelude (logE, logW)+import Relude+import System.Directory (doesFileExist)+import System.FilePath (takeExtension)+import Text.Pandoc (runIO)+import Text.Pandoc.Definition (Pandoc (..))+import Text.Pandoc.Filter qualified as PF+import UnliftIO.Exception (handle)++applyPandocFilters :: (MonadIO m, MonadLogger m, MonadWriter [Text] m) => [FilePath] -> Pandoc -> m Pandoc+applyPandocFilters paths doc = do+  res <- traverse mkLuaFilter paths+  forM_ (lefts res) $ \err ->+    tell [err]+  case rights res of+    [] ->+      pure doc+    filters ->+      applyPandocLuaFilters filters doc >>= \case+        Left err -> tell [err] >> pure doc+        Right x -> pure x++mkLuaFilter :: MonadIO m => FilePath -> m (Either Text PF.Filter)+mkLuaFilter relPath = do+  if takeExtension relPath == ".lua"+    then do+      liftIO (doesFileExist relPath) >>= \case+        True -> pure $ Right $ PF.LuaFilter relPath+        False -> pure $ Left $ toText $ "Lua filter missing: " <> relPath+    else pure $ Left $ "Unsupported filter: " <> toText relPath++applyPandocLuaFilters :: (MonadIO m, MonadLogger m) => [PF.Filter] -> Pandoc -> m (Either Text Pandoc)+applyPandocLuaFilters filters x = do+  logW $ "[Experimental feature] Applying pandoc filters: " <> show filters+  -- TODO: Can we constrain this to run Lua code purely (embedded) without using IO?+  liftIO (runIOCatchingErrors $ PF.applyFilters def filters ["markdown"] x) >>= \case+    Left err -> do+      logE $ "Error applying pandoc filters: " <> show err+      pure $ Left (show err)+    Right x' -> pure $ Right x'+  where+    -- `runIO` can throw `PandocError`. Fix this nonsense behaviour, by catching+    -- it and returning a `Left`.+    runIOCatchingErrors =+      handle (pure . Left) . runIO
+ src/Emanote/Model/Query.hs view
@@ -0,0 +1,92 @@+module Emanote.Model.Query where++import Data.IxSet.Typed ((@+), (@=))+import Data.IxSet.Typed qualified as Ix+import Data.Text qualified as T+import Emanote.Model.Calendar qualified as Calendar+import Emanote.Model.Note (Note)+import Emanote.Model.Note qualified as N+import Emanote.Model.Type (Model, modelNotes, modelTags)+import Emanote.Pandoc.Markdown.Syntax.HashTag (TagPattern)+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Route qualified as R+import Optics.Operators ((^.))+import Relude+import System.FilePattern (FilePattern, (?==))+import Text.Megaparsec qualified as M+import Text.Megaparsec.Char qualified as M+import Text.Show qualified as Show++data Query+  = QueryByTag HT.Tag+  | QueryByTagPattern TagPattern+  | QueryByPath FilePath+  | QueryByPathPattern FilePattern+  deriving stock (Eq)++instance Show.Show Query where+  show = \case+    QueryByTag tag ->+      toString $ "Pages tagged #" <> HT.unTag tag+    QueryByTagPattern pat ->+      toString $ "Pages tagged by '" <> HT.unTagPattern pat <> "'"+    QueryByPath p ->+      "Pages under path '/" <> p <> "'"+    QueryByPathPattern pat ->+      "Pages matching path '" <> pat <> "'"++parseQuery :: Text -> Maybe Query+parseQuery = do+  rightToMaybe . parse queryParser "<pandoc:code:query>"+  where+    parse :: M.Parsec Void Text a -> String -> Text -> Either Text a+    parse p fn =+      first (toText . M.errorBundlePretty)+        . M.parse (p <* M.eof) fn++queryParser :: M.Parsec Void Text Query+queryParser = do+  (M.string "tag:#" *> fmap (QueryByTag . HT.Tag . T.strip) M.takeRest)+    <|> (M.string "tag:" *> fmap (QueryByTagPattern . HT.mkTagPattern . T.strip) M.takeRest)+    <|> (M.string "path:" *> fmap (fromUserPath . T.strip) M.takeRest)+  where+    fromUserPath s =+      if+          | "*" `T.isInfixOf` s ->+              QueryByPathPattern (toString s)+          | "/" `T.isPrefixOf` s ->+              QueryByPath (toString $ T.drop 1 s)+          | otherwise ->+              QueryByPathPattern (toString $ "**/" <> s <> "/**")++runQuery :: R.LMLRoute -> Model -> Query -> [Note]+runQuery currentRoute model =+  sortOn Calendar.noteSortKey . \case+    QueryByTag tag ->+      Ix.toList $ (model ^. modelNotes) @= tag+    QueryByTagPattern pat ->+      let allTags = fst <$> modelTags model+          matchingTags = filter (HT.tagMatch pat) allTags+       in Ix.toList $ (model ^. modelNotes) @+ matchingTags+    QueryByPath path ->+      maybeToMonoid $ do+        r <- R.mkRouteFromFilePath path+        pure $ Ix.toList $ (model ^. modelNotes) @= N.RAncestor r+    QueryByPathPattern (resolveDotInFilePattern -> pat) ->+      let notes = Ix.toList $ model ^. modelNotes+       in flip mapMaybe notes $ \note -> do+            guard $ pat ?== R.withLmlRoute R.encodeRoute (note ^. N.noteRoute)+            pure note+  where+    -- Resolve the ./ prefix which will for substituting "$PWD" in current+    -- note's route context.+    resolveDotInFilePattern (toText -> pat) =+      if "./" `T.isPrefixOf` pat+        then+          let folderR :: R.R 'R.Folder = R.withLmlRoute coerce currentRoute+           in if folderR == R.indexRoute+                then -- If in "index.md", discard the ./+                  toString (T.drop 2 pat)+                else -- If in "$folder.md", discard the ./ and prepend with folder path prefix+                  R.encodeRoute folderR <> "/" <> toString (T.drop 2 pat)+        else toString pat
+ src/Emanote/Model/QuerySpec.hs view
@@ -0,0 +1,19 @@+module Emanote.Model.QuerySpec where++import Data.TagTree (Tag (Tag))+import Emanote.Model.Query+import Hedgehog+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import Relude+import Test.Hspec+import Test.Hspec.Hedgehog (hedgehog)++spec :: Spec+spec = do+  describe "tags" $ do+    it "tags can begin with hash" . hedgehog $ do+      s <- forAll $ Gen.text (Range.linear 2 20) Gen.alphaNum+      let q = "tag:#" <> s+          tag = Tag s+      parseQuery q === Just (QueryByTag tag)
+ src/Emanote/Model/SData.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.SData where++import Data.Aeson qualified as Aeson+import Data.Aeson.Extra.Merge qualified as AesonMerge+import Data.Aeson.KeyMap qualified as KM+import Data.Data (Data)+import Data.IxSet.Typed (Indexable (..), IxSet, ixGen, ixList)+import Data.List.NonEmpty qualified as NE+import Data.Yaml qualified as Yaml+import Emanote.Route qualified as R+import Optics.TH (makeLenses)+import Relude++-- | `S` for "structured". Refers to a per-route data file represented by Aeson+-- value.  Example: /foo/bar.yaml file+data SData = SData+  { _sdataValue :: Aeson.Value,+    -- | Location of this data file+    _sdataRoute :: R.R 'R.Yaml+  }+  deriving stock (Eq, Ord, Data, Show, Generic)+  deriving anyclass (Aeson.ToJSON)++type SDataIxs = '[R.R 'R.Yaml]++type IxSData = IxSet SDataIxs SData++instance Indexable SDataIxs SData where+  indices =+    ixList+      (ixGen $ Proxy @(R.R 'R.Yaml))++makeLenses ''SData++parseSDataCascading :: R.R 'R.Yaml -> NonEmpty (FilePath, ByteString) -> Either Text SData+parseSDataCascading r bs = do+  vals <- forM bs $ \(fp, b) ->+    (first (\err -> toText $ "Failed to parse " <> fp <> " :" <> Yaml.prettyPrintParseException err) . Yaml.decodeEither') b+  let val = mergeAesons vals+  pure $ SData val r++-- | Later values override former.+mergeAesons :: NonEmpty Aeson.Value -> Aeson.Value+mergeAesons =+  last . NE.scanl1 mergeAeson++mergeAeson :: Aeson.Value -> Aeson.Value -> Aeson.Value+mergeAeson = AesonMerge.lodashMerge++-- TODO: Use https://hackage.haskell.org/package/lens-aeson+lookupAeson :: forall a. Aeson.FromJSON a => a -> NonEmpty Text -> Aeson.Value -> a+lookupAeson x (k :| ks) meta =+  fromMaybe x $ do+    Aeson.Object obj <- pure meta+    val <- KM.lookup (fromString . toString $ k) obj+    case nonEmpty ks of+      Nothing -> resultToMaybe $ Aeson.fromJSON val+      Just ks' -> pure $ lookupAeson x ks' val+  where+    resultToMaybe :: Aeson.Result b -> Maybe b+    resultToMaybe = \case+      Aeson.Error _ -> Nothing+      Aeson.Success b -> pure b++oneAesonText :: [Text] -> Text -> Aeson.Value+oneAesonText k v =+  case nonEmpty k of+    Nothing ->+      Aeson.String v+    Just (x :| xs) ->+      Aeson.object [(fromString . toString) x Aeson..= oneAesonText (toList xs) v]
+ src/Emanote/Model/StaticFile.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.StaticFile where++import Data.Aeson qualified as Aeson+import Data.IxSet.Typed (Indexable (..), IxSet, ixFun, ixList)+import Data.Time (UTCTime)+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Route qualified as R+import Optics.TH (makeLenses)+import Relude++data StaticFile = StaticFile+  { _staticFileRoute :: R.R 'R.AnyExt,+    _staticFilePath :: FilePath,+    -- | Indicates that this file was updated no latter than the given time.+    _staticFileTime :: UTCTime+  }+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Aeson.ToJSON)++type StaticFileIxs = '[R.R 'R.AnyExt, WL.WikiLink]++type IxStaticFile = IxSet StaticFileIxs StaticFile++instance Indexable StaticFileIxs StaticFile where+  indices =+    ixList+      (ixFun $ one . _staticFileRoute)+      (ixFun $ toList . staticFileSelfRefs)++staticFileSelfRefs :: StaticFile -> NonEmpty WL.WikiLink+staticFileSelfRefs =+  fmap snd+    . WL.allowedWikiLinks+    . _staticFileRoute++makeLenses ''StaticFile
+ src/Emanote/Model/Stork.hs view
@@ -0,0 +1,31 @@+module Emanote.Model.Stork+  ( renderStorkIndex,+  )+where++import Control.Monad.Logger (MonadLoggerIO)+import Data.IxSet.Typed qualified as Ix+import Emanote.Model.Note qualified as N+import Emanote.Model.Stork.Index (File (File), Input (Input), readOrBuildStorkIndex)+import Emanote.Model.Title qualified as Tit+import Emanote.Model.Type (Model)+import Emanote.Model.Type qualified as M+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute qualified as SR+import Emanote.Source.Loc qualified as Loc+import Optics.Core ((^.))+import Relude+import System.FilePath ((</>))++renderStorkIndex :: (MonadIO m, MonadLoggerIO m) => Model -> m LByteString+renderStorkIndex model = do+  readOrBuildStorkIndex (model ^. M.modelStorkIndex) (Input $ storkFiles model)++storkFiles :: Model -> [File]+storkFiles model =+  let baseDir = Loc.locPath . Loc.primaryLayer $ model ^. M.modelLayers+   in Ix.toList (model ^. M.modelNotes) <&> \note ->+        File+          ((baseDir </>) $ R.withLmlRoute R.encodeRoute $ note ^. N.noteRoute)+          (SR.siteRouteUrl model $ SR.lmlSiteRoute $ note ^. N.noteRoute)+          (Tit.toPlain $ note ^. N.noteTitle)
+ src/Emanote/Model/Stork/Index.hs view
@@ -0,0 +1,104 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.Stork.Index+  ( IndexVar,+    newIndex,+    clearStorkIndex,+    readOrBuildStorkIndex,+    File (File),+    Input (Input),+  )+where++import Control.Monad.Logger (MonadLoggerIO)+import Data.Text qualified as T+import Data.Time (NominalDiffTime, diffUTCTime, getCurrentTime)+import Emanote.Prelude (log, logD, logW)+import Numeric (showGFloat)+import Relude+import System.Process.ByteString (readProcessWithExitCode)+import System.Which (staticWhich)+import Toml (TomlCodec, encode, list, string, text, (.=))++-- | In-memory Stork index tracked in a @TVar@+newtype IndexVar = IndexVar (TVar (Maybe LByteString))++newIndex :: MonadIO m => m IndexVar+newIndex =+  IndexVar <$> newTVarIO mempty++clearStorkIndex :: (MonadIO m) => IndexVar -> m ()+clearStorkIndex (IndexVar var) = atomically $ writeTVar var mempty++readOrBuildStorkIndex :: (MonadIO m, MonadLoggerIO m) => IndexVar -> Input -> m LByteString+readOrBuildStorkIndex (IndexVar indexVar) input = do+  readTVarIO indexVar >>= \case+    Just index -> do+      logD "STORK: Returning cached search index"+      pure index+    Nothing -> do+      -- TODO: What if there are concurrent reads? We probably need a lock.+      -- And we want to encapsulate this whole thing.+      logW "STORK: Generating search index (this may be expensive)"+      (diff, !index) <- timeIt $ runStork input+      log $ toText $ "STORK: Done generating search index in " <> showGFloat (Just 2) diff "" <> " seconds"+      atomically $ modifyTVar' indexVar $ \_ -> Just index+      pure index+  where+    timeIt :: MonadIO m => m b -> m (Double, b)+    timeIt m = do+      t0 <- liftIO getCurrentTime+      !x <- m+      t1 <- liftIO getCurrentTime+      let diff :: NominalDiffTime = diffUTCTime t1 t0+      pure (realToFrac diff, x)++storkBin :: FilePath+storkBin = $(staticWhich "stork")++runStork :: MonadIO m => Input -> m LByteString+runStork input = do+  let storkToml = handleTomlandBug $ Toml.encode inputCodec input+  (_, !index, _) <-+    liftIO $+      readProcessWithExitCode+        storkBin+        -- NOTE: Cannot use "--output -" due to bug in Rust or Stork:+        -- https://github.com/jameslittle230/stork/issues/262+        ["build", "-t", "--input", "-", "--output", "/dev/stdout"]+        (encodeUtf8 storkToml)+  pure $ toLazy index+  where+    handleTomlandBug =+      -- HACK: Deal with tomland's bug.+      -- https://github.com/EmaApps/emanote/issues/336+      -- https://github.com/kowainik/tomland/issues/408+      --+      -- This could be problematic if the user literally uses \\U in their note+      -- title (but why would they?)+      T.replace "\\\\U" "\\U"++newtype Input = Input+  { inputFiles :: [File]+  }+  deriving stock (Eq, Show)++data File = File+  { filePath :: FilePath,+    fileUrl :: Text,+    fileTitle :: Text+  }+  deriving stock (Eq, Show)++fileCodec :: TomlCodec File+fileCodec =+  File+    <$> Toml.string "path" .= filePath+    <*> Toml.text "url" .= fileUrl+    <*> Toml.text "title" .= fileTitle++inputCodec :: TomlCodec Input+inputCodec =+  Input+    <$> Toml.list fileCodec "input.files" .= inputFiles
+ src/Emanote/Model/Task.hs view
@@ -0,0 +1,51 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.Task where++import Data.Aeson qualified as Aeson+import Data.IxSet.Typed (Indexable (..), IxSet, ixFun, ixList)+import Data.IxSet.Typed qualified as Ix+import Emanote.Model.Note (Note)+import Emanote.Model.Note qualified as N+import Emanote.Route qualified as R+import Heist.Extra.Splices.Pandoc.TaskList qualified as TaskList+import Optics.Operators ((^.))+import Optics.TH (makeLenses)+import Relude+import Text.Pandoc.Builder qualified as B++data Task = Task+  { _taskRoute :: R.LMLRoute,+    -- Index of this task within the containing note. Used to sort tasks by+    -- their original order of appearance in the Markdown file.+    _taskNum :: Word,+    _taskDescription :: [B.Inline],+    _taskChecked :: Bool+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (Aeson.ToJSON)++instance Ord Task where+  (<=) = (<=) `on` (_taskRoute &&& _taskNum)++type TaskIxs =+  '[ -- Route to the note containing this task+     R.LMLRoute+   ]++type IxTask = IxSet TaskIxs Task++instance Indexable TaskIxs Task where+  indices =+    ixList+      (ixFun $ one . _taskRoute)++noteTasks :: Note -> IxTask+noteTasks note =+  let taskListItems = TaskList.queryTasks $ note ^. N.noteDoc+   in Ix.fromList $+        zip [1 ..] taskListItems <&> \(idx, (checked, doc)) ->+          Task (note ^. N.noteRoute) idx doc checked++makeLenses ''Task
+ src/Emanote/Model/Title.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE DeriveAnyClass #-}++module Emanote.Model.Title+  ( Title,++    -- * Title conversion+    fromRoute,+    fromInlines,+    toInlines,++    -- * Rendering a Title+    titleSplice,+    titleSpliceNoHtml,+    toPlain,+  )+where++import Data.Aeson (ToJSON)+import Emanote.Pandoc.Markdown.Syntax.WikiLink (plainify)+import Emanote.Route qualified as R+import Heist.Extra.Splices.Pandoc qualified as HP+import Heist.Interpreted qualified as HI+import Relude+import Text.Pandoc.Definition qualified as B+import Text.Pandoc.Walk qualified as W++data Title+  = TitlePlain Text+  | TitlePandoc [B.Inline]+  deriving stock (Show, Generic)+  deriving anyclass (ToJSON)++instance Eq Title where+  (==) =+    -- Use toPlain here, rather than toInlines, because the same text can have+    -- different inlines structure. For example, "Foo Bar" can be represented as+    --   [Str "Foo", Space, Str "Bar"],+    -- or as,+    --   [Str "Foo Bar"]+    on (==) toPlain++instance Ord Title where+  compare =+    on compare toPlain++instance Semigroup Title where+  TitlePlain a <> TitlePlain b =+    TitlePlain (a <> b)+  x <> y =+    TitlePandoc $ on (<>) toInlines x y++instance IsString Title where+  fromString = TitlePlain . toText++fromRoute :: R.LMLRoute -> Title+fromRoute =+  TitlePlain . R.withLmlRoute R.routeBaseName++fromInlines :: [B.Inline] -> Title+fromInlines = TitlePandoc++toInlines :: Title -> [B.Inline]+toInlines = \case+  TitlePlain s -> one (B.Str s)+  TitlePandoc is -> is++toPlain :: Title -> Text+toPlain = \case+  TitlePlain s -> s+  TitlePandoc is -> plainify is++titleSplice ::+  forall b.+  (W.Walkable B.Inline b, b ~ [B.Inline]) =>+  HP.RenderCtx ->+  (b -> b) ->+  Title ->+  HI.Splice Identity+titleSplice ctx f = \case+  TitlePlain x ->+    HI.textSplice x+  TitlePandoc is -> do+    let titleDoc = B.Pandoc mempty $ one $ B.Plain $ f is+    HP.pandocSplice ctx titleDoc++titleSpliceNoHtml :: Title -> HI.Splice Identity+titleSpliceNoHtml =+  HI.textSplice . toPlain
+ src/Emanote/Model/Type.hs view
@@ -0,0 +1,271 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Model.Type where++import Data.Aeson qualified as Aeson+import Data.Default (Default (def))+import Data.IxSet.Typed ((@=))+import Data.IxSet.Typed qualified as Ix+import Data.Map.Strict qualified as Map+import Data.Some (Some)+import Data.Time (UTCTime)+import Data.Tree (Tree)+import Data.Tree.Path qualified as PathTree+import Data.UUID (UUID)+import Ema.CLI qualified+import Emanote.Model.Link.Rel (IxRel)+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Note+  ( IxNote,+    Note,+  )+import Emanote.Model.Note qualified as N+import Emanote.Model.SData (IxSData, SData, sdataRoute)+import Emanote.Model.StaticFile+  ( IxStaticFile,+    StaticFile (StaticFile),+  )+import Emanote.Model.Stork.Index qualified as Stork+import Emanote.Model.Task (IxTask)+import Emanote.Model.Task qualified as Task+import Emanote.Model.Title qualified as Tit+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Pandoc.Renderer (EmanotePandocRenderers)+import Emanote.Route (FileType (AnyExt), LMLRoute, R)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute.Type (SiteRoute)+import Emanote.Source.Loc (Loc)+import Heist.Extra.TemplateState (TemplateState)+import Network.URI.Slug (Slug)+import Optics.Core (Prism')+import Optics.Operators ((%~), (.~), (^.))+import Optics.TH (makeLenses)+import Relude++data Status = Status_Loading | Status_Ready+  deriving stock (Eq, Show)++data ModelT encF = Model+  { _modelStatus :: Status,+    _modelLayers :: Set Loc,+    _modelEmaCLIAction :: Some Ema.CLI.Action,+    _modelRoutePrism :: encF (Prism' FilePath SiteRoute),+    -- | Dictates how exactly to render `Pandoc` to Heist nodes.+    _modelPandocRenderers :: EmanotePandocRenderers Model LMLRoute,+    _modelCompileTailwind :: Bool,+    -- | An unique ID for this process's model. ID changes across processes.+    _modelInstanceID :: UUID,+    _modelNotes :: IxNote,+    _modelRels :: IxRel,+    _modelSData :: IxSData,+    _modelStaticFiles :: IxStaticFile,+    _modelTasks :: IxTask,+    _modelNav :: [Tree Slug],+    _modelHeistTemplate :: TemplateState,+    _modelStorkIndex :: Stork.IndexVar+  }+  deriving stock (Generic)++type Model = ModelT Identity++-- | A bare version of `Model` that is managed by the Ema app.+--+-- The only difference is that this one has no `RouteEncoder`.+type ModelEma = ModelT (Const ())++deriving stock instance Generic ModelEma++deriving stock instance Generic Model++makeLenses ''ModelT++withoutRoutePrism :: Model -> (Prism' FilePath SiteRoute, ModelEma)+withoutRoutePrism model@Model {..} =+  let _modelRoutePrism = Const ()+   in (runIdentity $ model ^. modelRoutePrism, Model {..})++withRoutePrism :: Prism' FilePath SiteRoute -> ModelEma -> Model+withRoutePrism enc Model {..} =+  let _modelRoutePrism = Identity enc+   in Model {..}++emptyModel :: Set Loc -> Some Ema.CLI.Action -> EmanotePandocRenderers Model LMLRoute -> Bool -> UUID -> Stork.IndexVar -> ModelEma+emptyModel layers act ren ctw instanceId =+  Model Status_Loading layers act (Const ()) ren ctw instanceId Ix.empty Ix.empty Ix.empty Ix.empty mempty mempty def++modelReadyForView :: ModelT f -> ModelT f+modelReadyForView =+  modelStatus .~ Status_Ready++-- | Are we running in live server, or statically generated website?+inLiveServer :: Model -> Bool+inLiveServer = Ema.CLI.isLiveServer . _modelEmaCLIAction++modelInsertNote :: Note -> ModelT f -> ModelT f+modelInsertNote note =+  modelNotes+    %~ ( Ix.updateIx r note+           -- Insert folder placeholder automatically for ancestor paths+           >>> flip (foldr injectAncestor) (N.noteAncestors note)+       )+    >>> modelRels+      %~ updateIxMulti r (Rel.noteRels note)+    >>> modelTasks+      %~ updateIxMulti r (Task.noteTasks note)+    >>> modelNav+      %~ PathTree.treeInsertPath (R.withLmlRoute R.unRoute r)+  where+    r = note ^. N.noteRoute++injectAncestor :: N.RAncestor -> IxNote -> IxNote+injectAncestor (N.unRAncestor -> folderR) ns =+  case resolveLmlRouteIfExists ns folderR of+    Just _ -> ns+    Nothing ->+      let r = R.defaultLmlRoute folderR+       in Ix.updateIx r (N.ancestorPlaceholderNote folderR) ns++modelDeleteNote :: LMLRoute -> ModelT f -> ModelT f+modelDeleteNote k model =+  model+    & modelNotes+      %~ ( Ix.deleteIx k+             -- Restore folder placeholder, if $folder.md gets deleted (with $folder/*.md still present)+             -- TODO: If $k.md is the only file in its parent, delete unnecessary ancestors+             >>> maybe id restoreFolderPlaceholder mFolderR+         )+    & modelRels+      %~ deleteIxMulti k+    & modelTasks+      %~ deleteIxMulti k+    & modelNav+      %~ maybe (PathTree.treeDeletePath (R.withLmlRoute R.unRoute k)) (const id) mFolderR+  where+    -- If the note being deleted is $folder.md *and* folder/ has .md files, this+    -- will be `Just folderRoute`.+    mFolderR = do+      let folderR = R.withLmlRoute coerce k+      guard $ N.hasChildNotes folderR $ model ^. modelNotes+      pure folderR+    restoreFolderPlaceholder =+      injectAncestor . N.RAncestor++-- | Like `Ix.updateIx`, but works for multiple items.+updateIxMulti ::+  (Ix.IsIndexOf ix ixs, Ix.Indexable ixs a) =>+  ix ->+  Ix.IxSet ixs a ->+  Ix.IxSet ixs a ->+  Ix.IxSet ixs a+updateIxMulti r new rels =+  let old = rels @= r+      deleteMany = foldr Ix.delete+   in new `Ix.union` (rels `deleteMany` old)++-- | Like `Ix.deleteIx`, but works for multiple items+deleteIxMulti ::+  (Ix.Indexable ixs a, Ix.IsIndexOf ix ixs) =>+  ix ->+  Ix.IxSet ixs a ->+  Ix.IxSet ixs a+deleteIxMulti r rels =+  let candidates = Ix.toList $ Ix.getEQ r rels+   in flipfoldl' Ix.delete rels candidates++modelLookupStaticFile :: FilePath -> ModelT f -> Maybe StaticFile+modelLookupStaticFile fp m = do+  r :: R.R 'AnyExt <- R.mkRouteFromFilePath fp+  Ix.getOne $ Ix.getEQ r $ m ^. modelStaticFiles++modelInsertStaticFile :: UTCTime -> R.R 'AnyExt -> FilePath -> ModelT f -> ModelT f+modelInsertStaticFile t r fp =+  modelStaticFiles %~ Ix.updateIx r staticFile+  where+    staticFile = StaticFile r fp t++modelDeleteStaticFile :: R.R 'AnyExt -> ModelT f -> ModelT f+modelDeleteStaticFile r =+  modelStaticFiles %~ Ix.deleteIx r++modelInsertData :: SData -> ModelT f -> ModelT f+modelInsertData v =+  modelSData %~ Ix.updateIx (v ^. sdataRoute) v++modelDeleteData :: R.R 'R.Yaml -> ModelT f -> ModelT f+modelDeleteData k =+  modelSData %~ Ix.deleteIx k++modelLookupNoteByRoute :: LMLRoute -> ModelT f -> Maybe Note+modelLookupNoteByRoute r (_modelNotes -> notes) =+  N.lookupNotesByRoute r notes++modelLookupNoteByHtmlRoute :: R 'R.Html -> ModelT f -> Rel.ResolvedRelTarget Note+modelLookupNoteByHtmlRoute r =+  Rel.resolvedRelTargetFromCandidates+    . N.lookupNotesByHtmlRoute r+    . _modelNotes++modelLookupTitle :: LMLRoute -> ModelT f -> Tit.Title+modelLookupTitle r =+  maybe (Tit.fromRoute r) N._noteTitle . modelLookupNoteByRoute r++-- Lookup the wiki-link and return its candidates in the model.+modelWikiLinkTargets :: WL.WikiLink -> Model -> [Either Note StaticFile]+modelWikiLinkTargets wl model =+  let notes =+        Ix.toList $+          (model ^. modelNotes) @= wl+      staticFiles =+        Ix.toList $+          (model ^. modelStaticFiles) @= wl+   in fmap Right staticFiles <> fmap Left notes++modelLookupStaticFileByRoute :: R 'AnyExt -> ModelT f -> Maybe StaticFile+modelLookupStaticFileByRoute r =+  Ix.getOne . Ix.getEQ r . _modelStaticFiles++modelTags :: ModelT f -> [(HT.Tag, [Note])]+modelTags =+  Ix.groupAscBy @HT.Tag . _modelNotes++modelNoteRels :: Model -> [Rel.Rel]+modelNoteRels =+  Ix.toList . _modelRels++modelNoteMetas :: Model -> Map LMLRoute (Tit.Title, LMLRoute, Aeson.Value)+modelNoteMetas model =+  Map.fromList $+    Ix.toList (_modelNotes model) <&> \note ->+      (note ^. N.noteRoute, (note ^. N.noteTitle, note ^. N.noteRoute, note ^. N.noteMeta))++modelNoteErrors :: Model -> Map LMLRoute [Text]+modelNoteErrors model =+  Map.fromList $+    flip mapMaybe (Ix.toList (_modelNotes model)) $ \note -> do+      let errs = note ^. N.noteErrors+      guard $ not $ null errs+      pure (note ^. N.noteRoute, errs)++-- | Return the most suitable index LML route+--+--  If index.org exist, use that. Otherwise, fallback to index.md.+modelIndexRoute :: ModelT f -> LMLRoute+modelIndexRoute model = do+  resolveLmlRoute model R.indexRoute++resolveLmlRoute :: forall lmlType f. ModelT f -> R ('R.LMLType lmlType) -> LMLRoute+resolveLmlRoute model r =+  fromMaybe (R.defaultLmlRoute r) $ resolveLmlRouteIfExists (model ^. modelNotes) r++resolveLmlRouteIfExists :: forall ext. IxNote -> R ext -> Maybe LMLRoute+resolveLmlRouteIfExists notes r = do+  -- TODO: Refactor using `[minBound..maxBound] :: [LML]`+  note <-+    asum+      [ N.lookupNotesByRoute (R.LMLRoute_Org $ coerce r) notes,+        N.lookupNotesByRoute (R.LMLRoute_Md $ coerce r) notes+      ]+  pure $ note ^. N.noteRoute
+ src/Emanote/Pandoc/BuiltinFilters.hs view
@@ -0,0 +1,53 @@+module Emanote.Pandoc.BuiltinFilters+  ( prepareNoteDoc,+    preparePandoc,+  )+where++import Emanote.Model.Note qualified as N+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Route (encodeRoute)+import Emanote.Route.SiteRoute.Type (encodeTagIndexR)+import Optics.Core ((^.))+import Relude+import Text.Pandoc.Definition qualified as B+import Text.Pandoc.Walk qualified as W++-- TODO: Run this in `parseNote`?+prepareNoteDoc :: N.Note -> B.Pandoc+prepareNoteDoc note =+  preparePandoc $ note ^. N.noteDoc++preparePandoc :: W.Walkable B.Inline b => b -> b+preparePandoc =+  linkifyInlineTags+    >>> fixEmojiFontFamily++-- HashTag.hs generates a Span for inline tags.+-- Here, we must link them to the special tag index page.+linkifyInlineTags :: W.Walkable B.Inline b => b -> b+linkifyInlineTags =+  W.walk $ \case+    inline@(B.Span attr is) ->+      if+          | Just inlineTag <- HT.getTagFromInline inline ->+              B.Span attr [B.Link mempty is (tagUrl inlineTag, "Tag")]+          | otherwise ->+              inline+    x ->+      x+  where+    tagUrl =+      toText . encodeRoute . encodeTagIndexR . toList . HT.deconstructTag++-- Undo font-familly on emoji spans, so the browser uses an emoji font.+-- Ref: https://github.com/jgm/commonmark-hs/blob/3d545d7afa6c91820b4eebf3efeeb80bf1b27128/commonmark-extensions/src/Commonmark/Extensions/Emoji.hs#L30-L33+fixEmojiFontFamily :: W.Walkable B.Inline b => b -> b+fixEmojiFontFamily =+  W.walk $ \case+    B.Span (id', classes, attrs) is+      | classes == ["emoji"] ->+          let emojiFontAttr = ("style", "font-family: emoji")+              newAttrs = attrs <> one emojiFontAttr+           in B.Span (id', classes, newAttrs) is+    x -> x
+ src/Emanote/Pandoc/Link.hs view
@@ -0,0 +1,37 @@+module Emanote.Pandoc.Link where++import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Relude+import Text.Pandoc.Definition qualified as B++-- | A Pandoc inline node that refers to something else.+--+-- There are, currently, only two possible nodes: link & image.+data InlineRef+  = InlineLink+  | InlineImage+  deriving stock (Eq, Show)++parseInlineRef :: B.Inline -> Maybe (InlineRef, B.Attr, [B.Inline], (Text, Text))+parseInlineRef = \case+  B.Link attr is (url, tit) ->+    pure (InlineLink, attr, is, (url, tit))+  B.Image attr is (url, tit) ->+    pure (InlineImage, attr, is, (url, tit))+  _ ->+    Nothing++-- | Given an inline that is known to be an InlineRef, reconstruct and return+-- its orginal Markdown source.+unParseLink :: HasCallStack => B.Inline -> Text+unParseLink inl =+  case WL.wikiLinkInlineRendered inl of+    Just url ->+      url+    Nothing ->+      let (inlRef, _, is, (url, _tit)) = parseInlineRefMust inl+          prefix = if inlRef == InlineImage then "![" else "["+       in prefix <> WL.plainify is <> "](" <> url <> ")"+  where+    parseInlineRefMust =+      fromMaybe (error "Non-InlineRef Inline") . parseInlineRef
+ src/Emanote/Pandoc/Markdown/Parser.hs view
@@ -0,0 +1,51 @@+module Emanote.Pandoc.Markdown.Parser+  ( parseMarkdown,+  )+where++import Commonmark qualified as CM+import Commonmark.Extensions qualified as CE+import Commonmark.Simple (parseMarkdownWithFrontMatter)+import Data.Aeson qualified as Aeson+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as IT+import Emanote.Pandoc.Markdown.Syntax.Highlight qualified as IH+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Relude+import Text.Pandoc.Definition (Pandoc)++parseMarkdown :: FilePath -> Text -> Either Text (Maybe Aeson.Value, Pandoc)+parseMarkdown =+  parseMarkdownWithFrontMatter @Aeson.Value $+    -- As the commonmark documentation states, pipeTableSpec should be placed after+    -- fancyListSpec and defaultSyntaxSpec to avoid bad results when parsing+    -- non-table lines.+    -- see https://github.com/jgm/commonmark-hs/issues/52+    baseExtsSansPipeTable+      <> gfmExtensionsSansPipeTable+      <> CE.pipeTableSpec+      <> WL.wikilinkSpec+      -- ASK: Can we conditionally disable this?+      -- cf. https://github.com/srid/emanote/issues/167+      <> IT.hashTagSpec+      <> IH.highlightSpec+  where+    baseExtsSansPipeTable =+      mconcat+        [ CE.fancyListSpec,+          CE.footnoteSpec,+          CE.mathSpec,+          CE.smartPunctuationSpec,+          CE.definitionListSpec,+          CE.attributesSpec,+          CE.rawAttributeSpec,+          CE.fencedDivSpec,+          CE.bracketedSpanSpec,+          CE.autolinkSpec,+          CM.defaultSyntaxSpec+        ]+    gfmExtensionsSansPipeTable =+      CE.emojiSpec+        <> CE.strikethroughSpec+        <> CE.autolinkSpec+        <> CE.autoIdentifiersSpec+        <> CE.taskListSpec
+ src/Emanote/Pandoc/Markdown/Syntax/HashTag.hs view
@@ -0,0 +1,82 @@+module Emanote.Pandoc.Markdown.Syntax.HashTag+  ( hashTagSpec,+    inlineTagsInPandoc,+    getTagFromInline,+    TT.Tag (..),+    TT.TagPattern (..),+    TT.TagNode (..),+    TT.mkTagPattern,+    TT.tagMatch,+    TT.constructTag,+    TT.deconstructTag,+    TT.tagTree,+  )+where++import Commonmark (TokType (..))+import Commonmark qualified as CM+import Commonmark.Inlines qualified as CM+import Commonmark.Pandoc qualified as CP+import Commonmark.TokParsers (noneOfToks, symbol)+import Data.Map.Strict qualified as Map+import Data.TagTree qualified as TT+import Data.Text qualified as T+import Relude+import Text.Pandoc.Builder qualified as B+import Text.Pandoc.Walk qualified as W+import Text.Parsec qualified as P++inlineTagsInPandoc :: B.Pandoc -> [TT.Tag]+inlineTagsInPandoc = W.query $ maybeToList . getTagFromInline++getTagFromInline :: B.Inline -> Maybe TT.Tag+getTagFromInline = \case+  B.Span (_, _, Map.fromList -> attrs) _ -> do+    tag <- Map.lookup tagDataAttr attrs+    pure $ TT.Tag tag+  _ -> Nothing++class HasHashTag il where+  hashTag :: TT.Tag -> il++instance HasHashTag (CP.Cm b B.Inlines) where+  hashTag (TT.Tag tag) =+    let attrs =+          [ ("title", "Tag"),+            (tagDataAttr, tag)+          ]+        classes =+          [ "emanote:inline-tag",+            -- This must be placed *after* the class above, to allow the user to+            -- override generic styles (of the class above)+            "emanote:inline-tag:" <> tag+          ]+     in CP.Cm $ B.spanWith ("", classes, attrs) $ B.str $ "#" <> tag++tagDataAttr :: Text+tagDataAttr = "data-tag"++hashTagSpec ::+  (Monad m, CM.IsBlock il bl, CM.IsInline il, HasHashTag il) =>+  CM.SyntaxSpec m il bl+hashTagSpec =+  mempty+    { CM.syntaxInlineParsers = [pTag]+    }+  where+    pTag ::+      (Monad m, CM.IsInline il, HasHashTag il) =>+      CM.InlineParser m il+    pTag = P.try $ do+      _ <- symbol '#'+      tag <- CM.untokenize <$> tagP+      pure $ hashTag $ TT.Tag tag+    tagP :: Monad m => P.ParsecT [CM.Tok] s m [CM.Tok]+    tagP = do+      s <- some (noneOfToks disallowed)+      -- A tag cannot end with a slash (which is a separator in hierarchical tags)+      guard $ not $ "/" `T.isSuffixOf` CM.untokenize s+      pure s+      where+        disallowed = [Spaces, UnicodeSpace, LineEnd] <> fmap Symbol punctuation+        punctuation = "[];:,.?!"
+ src/Emanote/Pandoc/Markdown/Syntax/Highlight.hs view
@@ -0,0 +1,28 @@+module Emanote.Pandoc.Markdown.Syntax.Highlight+  ( highlightSpec,+  )+where++import Commonmark qualified as CM+import Commonmark.Inlines qualified as CM+import Commonmark.Pandoc qualified as CP+import Relude+import Text.Pandoc.Builder qualified as B++highlightSpec ::+  (Monad m, CM.IsInline il, HasHighlight il) =>+  CM.SyntaxSpec m il bl+highlightSpec =+  mempty+    { CM.syntaxFormattingSpecs =+        [ CM.FormattingSpec '=' True True Nothing (Just highlight) '='+        ]+    }++class HasHighlight a where+  highlight :: a -> a++instance HasHighlight (CP.Cm a B.Inlines) where+  highlight il = B.spanWith attr <$> il+    where+      attr = ("", one "highlight-inline", [])
+ src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs view
@@ -0,0 +1,275 @@+{-# LANGUAGE DeriveAnyClass #-}++module Emanote.Pandoc.Markdown.Syntax.WikiLink+  ( WikiLink,+    WikiLinkType (..),+    wikilinkSpec,+    mkWikiLinkFromRoute,+    delineateLink,+    wikilinkInline,+    wikiLinkInlineRendered,+    mkWikiLinkFromInline,+    allowedWikiLinks,++    -- * Anchors in URLs+    Anchor,+    anchorSuffix,++    -- * Pandoc helper, which use wikilink somehow+    plainify,+  )+where++import Commonmark qualified as CM+import Commonmark.Pandoc qualified as CP+import Commonmark.TokParsers qualified as CT+import Control.Monad (liftM2)+import Data.Aeson (ToJSON (toJSON))+import Data.Data (Data)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Emanote.Route.Ext qualified as Ext+import Emanote.Route.R (R (..))+import Network.URI.Encode qualified as UE+import Network.URI.Slug (Slug)+import Network.URI.Slug qualified as Slug+import Relude+import Text.Megaparsec qualified as M+import Text.Pandoc.Builder qualified as B+import Text.Pandoc.Walk qualified as W+import Text.Parsec qualified as P+import Text.Read (Read (readsPrec))+import Text.Show qualified (Show (show))++-- | Represents the "Foo" in [[Foo]]+--+-- As wiki links may contain multiple path components, it can also represent+-- [[Foo/Bar]], hence we use nonempty slug list.+newtype WikiLink = WikiLink {unWikiLink :: NonEmpty Slug}+  deriving stock (Eq, Ord, Typeable, Data)++instance ToJSON WikiLink where+  toJSON = toJSON . wikilinkUrl++instance Show WikiLink where+  show wl =+    toString $ "[[" <> wikilinkUrl wl <> "]]"++-- -----------------+-- Making wiki links+-- -----------------++mkWikiLinkFromRoute :: R ext -> WikiLink+mkWikiLinkFromRoute (R slugs) = WikiLink slugs++mkWikiLinkFromUrl :: (Monad m, Alternative m) => Text -> m WikiLink+mkWikiLinkFromUrl s = do+  slugs <- maybe empty pure $ nonEmpty $ Slug.decodeSlug <$> T.splitOn "/" s+  pure $ WikiLink slugs++mkWikiLinkFromInline :: B.Inline -> Maybe (WikiLink, [B.Inline])+mkWikiLinkFromInline inl = do+  B.Link (_id, _class, otherAttrs) is (url, tit) <- pure inl+  (Left (_, wl), _manchor) <- delineateLink (otherAttrs <> one ("title", tit)) url+  pure (wl, is)++-- | An URL anchor without the '#'+newtype Anchor = Anchor Text+  deriving newtype (Eq, Show, Ord)++mkAnchor :: String -> Maybe Anchor+mkAnchor ('#' : name) = pure $ Anchor $ toText name+mkAnchor _ = Nothing++anchorSuffix :: Maybe Anchor -> Text+anchorSuffix = maybe "" (\(Anchor a) -> "#" <> a)++dropUrlAnchor :: Text -> (Text, Maybe Anchor)+dropUrlAnchor = second (mkAnchor . toString) . T.breakOn "#"++-- | Given a Pandoc Link node, apparaise what kind of link it is.+--+-- * Nothing, if the link is an absolute URL+-- * Just (Left wl), if a wiki-link+-- * Just (Right fp), if a relative path (not a wiki-link)+delineateLink :: [(Text, Text)] -> Text -> Maybe (Either (WikiLinkType, WikiLink) FilePath, Maybe Anchor)+delineateLink (Map.fromList -> attrs) url = do+  -- Must be relative+  guard $ not $ "://" `T.isInfixOf` url+  wikiLink <|> internalLink+  where+    wikiLink = do+      wlType :: WikiLinkType <- readMaybe . toString <=< Map.lookup htmlAttr $ attrs+      -- Ignore anchors until https://github.com/srid/emanote/discussions/105+      let (s, manc) = dropUrlAnchor url+      wl <- mkWikiLinkFromUrl s+      pure (Left (wlType, wl), manc)+    internalLink = do+      -- Avoid links like "mailto:", "magnet:", etc.+      -- An easy way to parse them is to look for colon character.+      --+      -- This does mean that "Foo: Bar.md" cannot be linked to this way, however+      -- the user can do it using wiki-links.+      guard $ not $ ":" `T.isInfixOf` url+      let (s, manc) = dropUrlAnchor url+      guard $ not $ T.null s -- Same page anchors+      pure (Right $ UE.decode (toString s), manc)++-- ---------------------+-- Converting wiki links+-- ---------------------++-- | [[Foo/Bar]] -> "Foo/Bar"+wikilinkUrl :: WikiLink -> Text+wikilinkUrl =+  T.intercalate "/" . fmap Slug.unSlug . toList . unWikiLink++wikilinkInline :: WikiLinkType -> WikiLink -> B.Inlines -> B.Inlines+wikilinkInline typ wl = B.linkWith attrs (wikilinkUrl wl) ""+  where+    attrs = ("", [], [(htmlAttr, show typ)])++wikiLinkInlineRendered :: B.Inline -> Maybe Text+wikiLinkInlineRendered x = do+  (wl, inl) <- mkWikiLinkFromInline x+  pure $ case nonEmpty inl of+    Nothing -> show wl+    Just _ ->+      let inlStr = plainify inl+       in if inlStr == wikilinkUrl wl+            then show wl+            else "[[" <> wikilinkUrl wl <> "|" <> plainify inl <> "]]"++-- | Return the various ways to link to a route (ignoring ext)+--+-- Foo/Bar/Qux.md -> [[Qux]], [[Bar/Qux]], [[Foo/Bar/Qux]]+--+-- All possible combinations of Wikilink type use is automatically included.+allowedWikiLinks :: HasCallStack => R @Ext.SourceExt ext -> NonEmpty (WikiLinkType, WikiLink)+allowedWikiLinks r =+  let wls = fmap WikiLink $ tailsNE $ unRoute r+      typs :: NonEmpty WikiLinkType = NE.fromList universe+   in liftM2 (,) typs wls+  where+    tailsNE =+      NE.fromList . mapMaybe nonEmpty . tails . toList++-------------------------+-- Parser+--------------------------++-- | A # prefix or suffix allows semantically distinct wikilinks+--+-- Typically called branching link or a tag link, when used with #.+data WikiLinkType+  = -- | [[Foo]]+    WikiLinkNormal+  | -- | [[Foo]]#+    WikiLinkBranch+  | -- | #[[Foo]]+    WikiLinkTag+  | -- | ![[Foo]]+    WikiLinkEmbed+  deriving stock (Eq, Show, Ord, Typeable, Data, Enum, Bounded, Generic)+  deriving anyclass (ToJSON)++instance Read WikiLinkType where+  readsPrec _ s+    | s == show WikiLinkNormal = [(WikiLinkNormal, "")]+    | s == show WikiLinkBranch = [(WikiLinkBranch, "")]+    | s == show WikiLinkTag = [(WikiLinkTag, "")]+    | s == show WikiLinkEmbed = [(WikiLinkEmbed, "")]+    | otherwise = []++-- | The HTML 'data attribute' storing the wiki-link type.+htmlAttr :: Text+htmlAttr = "data-wikilink-type"++class HasWikiLink il where+  wikilink :: WikiLinkType -> WikiLink -> Maybe il -> il++instance HasWikiLink (CP.Cm b B.Inlines) where+  wikilink typ wl il =+    CP.Cm $ wikilinkInline typ wl $ maybe mempty CP.unCm il++-- | Like `Commonmark.Extensions.Wikilinks.wikilinkSpec` but Zettelkasten-friendly.+--+-- Compared with the official extension, this has two differences:+--+-- - Supports flipped inner text, eg: `[[Foo | some inner text]]`+-- - Supports neuron folgezettel, i.e.: #[[Foo]] or [[Foo]]#+wikilinkSpec ::+  (Monad m, CM.IsInline il, HasWikiLink il) =>+  CM.SyntaxSpec m il bl+wikilinkSpec =+  mempty+    { CM.syntaxInlineParsers =+        [ P.try $+            P.choice+              [ P.try (CT.symbol '#' *> pWikilink WikiLinkTag),+                P.try (CT.symbol '!' *> pWikilink WikiLinkEmbed),+                P.try (pWikilink WikiLinkBranch <* CT.symbol '#'),+                P.try (pWikilink WikiLinkNormal)+              ]+        ]+    }+  where+    pWikilink typ = do+      replicateM_ 2 $ CT.symbol '['+      P.notFollowedBy (CT.symbol '[')+      url <-+        CM.untokenize <$> many (satisfyNoneOf [isPipe, isAnchor, isClose])+      wl <- mkWikiLinkFromUrl url+      -- We ignore the anchor until https://github.com/srid/emanote/discussions/105+      _anchor <-+        M.optional $+          CM.untokenize+            <$> ( CT.symbol '#'+                    *> many (satisfyNoneOf [isPipe, isClose])+                )+      title <-+        M.optional $+          -- TODO: Should parse as inline so link text can be formatted?+          CM.untokenize+            <$> ( CT.symbol '|'+                    *> many (satisfyNoneOf [isClose])+                )+      replicateM_ 2 $ CT.symbol ']'+      return $ wikilink typ wl (fmap CM.str title)+    satisfyNoneOf toks =+      CT.satisfyTok $ \t -> not $ or $ toks <&> \tok -> tok t+    isAnchor =+      isSymbol '#'+    isPipe =+      isSymbol '|'+    isClose =+      isSymbol ']'+    isSymbol c =+      CT.hasType (CM.Symbol c)++-- | Convert Pandoc AST inlines to raw text.+--+-- TODO: extend on top of plainify from heist-extra+plainify :: [B.Inline] -> Text+plainify = W.query $ \case+  B.Str x -> x+  B.Code _attr x -> x+  B.Space -> " "+  B.SoftBreak -> " "+  B.LineBreak -> " "+  -- TODO: if fmt is html, we should strip the html tags+  B.RawInline _fmt s -> s+  -- Ignore "wrapper" inlines like span.+  B.Span _ _ -> ""+  -- TODO: How to wrap math stuff here?+  B.Math _mathTyp s -> s+  -- Wiki-links must be displayed using its show instance (which returns its+  -- human-readable representation)+  (mkWikiLinkFromInline -> Just (wl, customText)) ->+    if null customText+      then show wl -- We will display raw wikilink; ideally, though, we want to display the title.+      else plainify customText+  -- Ignore the rest of AST nodes, as they are recursively defined in terms of+  -- `Inline` which `W.query` will traverse again.+  _ -> ""
+ src/Emanote/Pandoc/Renderer.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE RecordWildCards #-}++-- | Types for custom render extensions to Pandoc AST nodes.+--+-- Note that unlike Pandoc *filters* (which operate on entire document), these+-- are modeled based on Text.Pandoc.Walk, ie. fine-grained on individual inline+-- and block processing. We do this only so as to render a specific node during+-- recursion (cf. `rpBlock` and `rpInline` in Render.hs).+--+-- So we expect the extensions to be in Haskell, however external script may be+-- supported using a traditional whole-AST extension API.+module Emanote.Pandoc.Renderer+  ( PandocRenderers (PandocRenderers),+    PandocInlineRenderer,+    PandocBlockRenderer,+    mkRenderCtxWithPandocRenderers,+    EmanotePandocRenderers (..),+  )+where++import Heist (HeistT)+import Heist.Extra.Splices.Pandoc qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx qualified as Splices+import Heist.Interpreted qualified as HI+import Relude+import Text.Pandoc.Definition qualified as B++-- | Custom Heist renderer function for specific Pandoc AST nodes+type PandocRenderF model route astNode =+  model ->+  PandocRenderers model route ->+  Splices.RenderCtx ->+  route ->+  astNode ->+  Maybe (HI.Splice Identity)++type PandocInlineRenderer model route = PandocRenderF model route B.Inline++type PandocBlockRenderer model route = PandocRenderF model route B.Block++data PandocRenderers model route = PandocRenderers+  { pandocInlineRenderers :: [PandocInlineRenderer model route],+    pandocBlockRenderers :: [PandocBlockRenderer model route]+  }++mkRenderCtxWithPandocRenderers ::+  forall model route m.+  (Monad m) =>+  PandocRenderers model route ->+  Map Text Text ->+  model ->+  route ->+  HeistT Identity m Splices.RenderCtx+mkRenderCtxWithPandocRenderers nr@PandocRenderers {..} classRules model x =+  Splices.mkRenderCtx+    classRules+    ( \ctx blk ->+        asum $+          pandocBlockRenderers <&> \f ->+            f model nr ctx x blk+    )+    ( \ctx blk ->+        asum $+          pandocInlineRenderers <&> \f ->+            f model nr ctx x blk+    )++data EmanotePandocRenderers a r = EmanotePandocRenderers+  { blockRenderers :: PandocRenderers a r,+    -- | Like `blockRenderers` but for use in inline contexts.+    --+    -- Backlinks and titles constitute an example of inline context, where we don't+    -- care about block elements.+    inlineRenderers :: PandocRenderers a r,+    -- | Like `inlineRenderers` but suitable for use inside links (<a> tags).+    linkInlineRenderers :: PandocRenderers a r+  }
+ src/Emanote/Pandoc/Renderer/Embed.hs view
@@ -0,0 +1,110 @@+module Emanote.Pandoc.Renderer.Embed where++import Data.Map.Syntax ((##))+import Data.Text qualified as T+import Emanote.Model (Model)+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Link.Resolve qualified as Resolve+import Emanote.Model.Note qualified as MN+import Emanote.Model.StaticFile qualified as SF+import Emanote.Model.Title qualified as Tit+import Emanote.Pandoc.BuiltinFilters (prepareNoteDoc, preparePandoc)+import Emanote.Pandoc.Link qualified as Link+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Pandoc.Renderer (PandocBlockRenderer, PandocInlineRenderer)+import Emanote.Pandoc.Renderer.Url qualified as RenderedUrl+import Emanote.Route.ModelRoute qualified as R+import Emanote.Route.R qualified as R+import Emanote.Route.SiteRoute qualified as SF+import Emanote.Route.SiteRoute qualified as SR+import Heist qualified as H+import Heist.Extra qualified as HE+import Heist.Extra.Splices.Pandoc (pandocSplice)+import Heist.Extra.Splices.Pandoc qualified as HP+import Heist.Interpreted qualified as HI+import Optics.Operators ((^.))+import Relude+import Text.Pandoc.Definition qualified as B++embedBlockWikiLinkResolvingSplice :: PandocBlockRenderer Model R.LMLRoute+embedBlockWikiLinkResolvingSplice model _nf ctx noteRoute node = do+  B.Para [inl] <- pure node+  (inlRef, (_, _, otherAttrs), is, (url, tit)) <- Link.parseInlineRef inl+  guard $ inlRef == Link.InlineLink+  let parentR = R.withLmlRoute R.routeParent noteRoute+  -- TODO: Use anchor to embed a section?+  (Rel.URTWikiLink (WL.WikiLinkEmbed, wl), _mAnchor) <-+    Rel.parseUnresolvedRelTarget parentR (otherAttrs <> one ("title", tit)) url+  let rRel = Resolve.resolveWikiLinkMustExist model wl+  RenderedUrl.renderSomeInlineRefWith Resolve.resourceSiteRoute (is, (url, tit)) rRel model ctx inl $+    either (embedResourceRoute model ctx) (const Nothing)++embedBlockRegularLinkResolvingSplice :: PandocBlockRenderer Model R.LMLRoute+embedBlockRegularLinkResolvingSplice model _nf ctx noteRoute node = do+  B.Para [inl] <- pure node+  (inlRef, (_, _, otherAttrs), is, (url, tit)) <- Link.parseInlineRef inl+  guard $ inlRef == Link.InlineImage+  let parentR = R.withLmlRoute R.routeParent noteRoute+  (Rel.URTResource mr, _mAnchor) <-+    Rel.parseUnresolvedRelTarget parentR (otherAttrs <> one ("title", tit)) url+  let rRel = Resolve.resolveModelRoute model mr+  RenderedUrl.renderSomeInlineRefWith Resolve.resourceSiteRoute (is, (url, tit)) rRel model ctx inl $+    either (const Nothing) (embedStaticFileRoute model $ WL.plainify is)++embedInlineWikiLinkResolvingSplice :: PandocInlineRenderer Model R.LMLRoute+embedInlineWikiLinkResolvingSplice model _nf ctx noteRoute inl = do+  (inlRef, (_, _, otherAttrs), is, (url, tit)) <- Link.parseInlineRef inl+  guard $ inlRef == Link.InlineLink+  let parentR = R.withLmlRoute R.routeParent noteRoute+  (Rel.URTWikiLink (WL.WikiLinkEmbed, wl), _mAnchor) <- Rel.parseUnresolvedRelTarget parentR (otherAttrs <> one ("title", tit)) url+  let rRel = Resolve.resolveWikiLinkMustExist model wl+  RenderedUrl.renderSomeInlineRefWith Resolve.resourceSiteRoute (is, (url, tit)) rRel model ctx inl $+    either (const Nothing) (embedStaticFileRoute model $ show wl)++runEmbedTemplate :: ByteString -> H.Splices (HI.Splice Identity) -> HI.Splice Identity+runEmbedTemplate name splices = do+  tpl <- HE.lookupHtmlTemplateMust $ "/templates/filters/embed-" <> name+  HE.runCustomTemplate tpl splices++embedResourceRoute :: Model -> HP.RenderCtx -> MN.Note -> Maybe (HI.Splice Identity)+embedResourceRoute model ctx note = do+  pure . runEmbedTemplate "note" $ do+    "ema:note:title" ## Tit.titleSplice ctx preparePandoc (MN._noteTitle note)+    "ema:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute $ note ^. MN.noteRoute)+    "ema:note:pandoc" ##+      pandocSplice ctx (prepareNoteDoc note)++embedStaticFileRoute :: Model -> Text -> SF.StaticFile -> Maybe (HI.Splice Identity)+embedStaticFileRoute model altText staticFile = do+  let fp = staticFile ^. SF.staticFilePath+      url = SF.siteRouteUrl model $ SF.staticFileSiteRoute staticFile+  if+      | any (`T.isSuffixOf` toText fp) imageExts ->+          pure . runEmbedTemplate "image" $ do+            "ema:url" ## HI.textSplice url+            "ema:alt" ## HI.textSplice altText+      | any (`T.isSuffixOf` toText fp) videoExts -> do+          pure . runEmbedTemplate "video" $ do+            "ema:url" ## HI.textSplice url+      | ".pdf" `T.isSuffixOf` toText fp -> do+          pure . runEmbedTemplate "pdf" $ do+            "ema:url" ## HI.textSplice url+      | otherwise -> Nothing++imageExts :: [Text]+imageExts =+  [ ".jpg",+    ".jpeg",+    ".png",+    ".svg",+    ".gif",+    ".bmp",+    ".webp"+  ]++videoExts :: [Text]+videoExts =+  [ ".mp4",+    ".webm",+    ".ogv"+  ]
+ src/Emanote/Pandoc/Renderer/Query.hs view
@@ -0,0 +1,53 @@+module Emanote.Pandoc.Renderer.Query+  ( queryResolvingSplice,+    noteSpliceMap,+  )+where++import Data.List qualified as List+import Data.Map.Syntax ((##))+import Data.Text qualified as T+import Emanote.Model (Model)+import Emanote.Model.Note qualified as MN+import Emanote.Model.Query qualified as Q+import Emanote.Model.Title qualified as Tit+import Emanote.Pandoc.BuiltinFilters (preparePandoc)+import Emanote.Pandoc.Renderer (PandocBlockRenderer)+import Emanote.Route (LMLRoute)+import Emanote.Route.SiteRoute qualified as SR+import Heist qualified as H+import Heist.Extra qualified as HE+import Heist.Extra.Splices.Pandoc (RenderCtx)+import Heist.Interpreted qualified as HI+import Heist.Splices.Json qualified as HJ+import Optics.Operators ((^.))+import Relude+import Text.Pandoc.Definition qualified as B++queryResolvingSplice :: PandocBlockRenderer Model LMLRoute+queryResolvingSplice model _nr ctx noteRoute blk = do+  B.CodeBlock+    (_id', classes, _attrs)+    (Q.parseQuery -> Just q) <-+    pure blk+  guard $ List.elem "query" classes+  let mOtherCls = nonEmpty (List.delete "query" classes) <&> T.intercalate " " . toList+      queryTpl = encodeUtf8 $ "/templates/filters/query-" <> fromMaybe "default" mOtherCls+  pure $ do+    tpl <- HE.lookupHtmlTemplateMust queryTpl+    HE.runCustomTemplate tpl $ do+      "query" ##+        HI.textSplice (show q)+      "result" ##+        (HI.runChildrenWith . noteSpliceMap ($ ctx) model) `foldMapM` Q.runQuery noteRoute model q++-- TODO: Reuse this elsewhere+noteSpliceMap ::+  ((RenderCtx -> HI.Splice Identity) -> HI.Splice Identity) ->+  Model ->+  MN.Note ->+  H.Splices (HI.Splice Identity)+noteSpliceMap withCtx model note = do+  "ema:note:title" ## withCtx $ \ctx -> Tit.titleSplice ctx preparePandoc (MN._noteTitle note)+  "ema:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute $ note ^. MN.noteRoute)+  "ema:note:metadata" ## HJ.bindJson (note ^. MN.noteMeta)
+ src/Emanote/Pandoc/Renderer/Url.hs view
@@ -0,0 +1,166 @@+module Emanote.Pandoc.Renderer.Url+  ( urlResolvingSplice,+    plainifyWikiLinkSplice,+    renderSomeInlineRefWith,+  )+where++import Data.Text qualified as T+import Emanote.Model (Model)+import Emanote.Model qualified as M+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Link.Resolve qualified as Resolve+import Emanote.Model.Note qualified as MN+import Emanote.Model.Title qualified as Tit+import Emanote.Pandoc.Link qualified as Link+import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL+import Emanote.Pandoc.Renderer (PandocInlineRenderer)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute qualified as SR+import Heist.Extra.Splices.Pandoc qualified as HP+import Heist.Extra.Splices.Pandoc qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx (ctxSansCustomSplicing)+import Heist.Interpreted qualified as HI+import Optics.Core (review)+import Relude+import Text.Pandoc.Definition qualified as B+import Text.Pandoc.Walk qualified as W++-- | Resolve all URLs in inlines (<a> and <img>)+urlResolvingSplice :: PandocInlineRenderer Model R.LMLRoute+urlResolvingSplice model _nf (ctxSansCustomSplicing -> ctx) noteRoute inl = do+  (inlRef, attr@(id', cls, otherAttrs), is, (url, tit)) <- Link.parseInlineRef inl+  let parentR = R.withLmlRoute R.routeParent noteRoute+  (uRel, mAnchor) <- Rel.parseUnresolvedRelTarget parentR (otherAttrs <> one ("title", tit)) url+  let rRel = Resolve.resolveUnresolvedRelTarget model uRel+  renderSomeInlineRefWith id (is, (url, tit)) rRel model ctx inl $ \sr ->+    case inlRef of+      Link.InlineLink -> do+        -- TODO: If uRel is `Rel.URTWikiLink (WL.WikiLinkEmbed, _)`, *and* it appears+        -- in B.Para (so do this in block-level custom splice), then embed it.+        -- We don't do this here, as this inline splice can't embed block elements.+        let (newIs, (newUrl', isNotEmaLink)) = replaceLinkNodeWithRoute model sr (is, url)+            newOtherAttrs = otherAttrs <> [openInNewTabAttr | M.inLiveServer model && isNotEmaLink]+            newAttr = (id', cls, newOtherAttrs)+            newUrl = newUrl' <> WL.anchorSuffix mAnchor+        pure $ HP.rpInline ctx $ B.Link newAttr newIs (newUrl, tit)+      Link.InlineImage -> do+        let (newIs, (newUrl, _)) =+              replaceLinkNodeWithRoute model sr (toList $ nonEmptyInlines url is, url)+        pure $ HP.rpInline ctx $ B.Image attr newIs (newUrl, tit)++openInNewTabAttr :: (Text, Text)+openInNewTabAttr =+  ("target", "_blank")++renderSomeInlineRefWith ::+  (a -> SR.SiteRoute) ->+  -- | AST Node attributes of @InlineRef@+  ([B.Inline], (Text, Text)) ->+  Rel.ResolvedRelTarget a ->+  Model ->+  Splices.RenderCtx ->+  B.Inline ->+  (a -> Maybe (HI.Splice Identity)) ->+  Maybe (HI.Splice Identity)+renderSomeInlineRefWith getSr (is, (url, tit)) rRel model (ctxSansCustomSplicing -> ctx) origInl f = do+  case rRel of+    Rel.RRTMissing -> do+      pure $ do+        raw <-+          HP.rpInline+            ctx+            ( tooltip+                "Link is broken"+                [ B.Strikeout $ one $ B.Str $ Link.unParseLink origInl,+                  B.Str "❌"+                ]+            )+        details <-+          HP.rpInline ctx $+            -- FIXME: This aside is meaningless for non-wikilink links (regular+            -- Markdown links)+            B.Span ("", ["emanote:error:aside"], []) $+              one $+                tooltip "Find notes containing this broken link" $+                  one $+                    B.Link B.nullAttr (one $ B.Emph $ one $ B.Str "backlinks") (url, "")+        if M.inLiveServer model+          then pure $ raw <> details+          else pure raw+    Rel.RRTAmbiguous srs -> do+      pure $ do+        raw <- HP.rpInline ctx (tooltip "Link is ambiguous" [B.Strikeout $ one $ B.Str $ Link.unParseLink origInl, B.Str "❗"])+        candidates <-+          fmap mconcat . sequence $+            toList srs+              <&> \(getSr -> sr) -> do+                let (rp, _) = M.withoutRoutePrism model+                    srRoute = toText $ review rp sr+                    (_newIs, (newUrl, isNotEmaLink)) = replaceLinkNodeWithRoute model sr (is, srRoute)+                    linkAttr = [openInNewTabAttr | M.inLiveServer model && isNotEmaLink]+                    newIs = one $ B.Str $ show sr+                HP.rpInline ctx $+                  B.Span ("", ["emanote:error:aside"], []) $+                    one $+                      tooltip (show sr <> " -> " <> srRoute) $+                        one $+                          B.Link ("", mempty, linkAttr) newIs (newUrl, tit)+        if M.inLiveServer model+          then pure $ raw <> candidates+          else pure raw+    Rel.RRTFound sr -> do+      f sr+  where+    tooltip :: Text -> [B.Inline] -> B.Inline+    tooltip s = B.Span ("", [], one ("title", s))++plainifyWikiLinkSplice :: PandocInlineRenderer Model R.LMLRoute+plainifyWikiLinkSplice _model _nf (ctxSansCustomSplicing -> ctx) _ inl = do+  s <- WL.wikiLinkInlineRendered inl+  pure $ HP.rpInline ctx $ B.Str s++inlinesWithWikiLinksPlainified :: [B.Inline] -> [B.Inline]+inlinesWithWikiLinksPlainified = W.walk $ \case+  (WL.wikiLinkInlineRendered -> Just s) ->+    B.Str s+  x -> x++replaceLinkNodeWithRoute ::+  HasCallStack =>+  Model ->+  SR.SiteRoute ->+  ([B.Inline], Text) ->+  ([B.Inline], (Text, Bool))+replaceLinkNodeWithRoute model r (inner, url) =+  ( inlinesWithWikiLinksPlainified $ nonEmptyLinkInlines model url (Just r) inner,+    let linkUrl = SR.siteRouteUrl model r+     in (linkUrl, "?" `T.isInfixOf` linkUrl)+  )+  where+    nonEmptyLinkInlines :: Model -> Text -> Maybe SR.SiteRoute -> [B.Inline] -> [B.Inline]+    nonEmptyLinkInlines model' url' mr = \case+      [] ->+        toList $+          nonEmptyInlines url $+            fromMaybe [] $+              siteRouteDefaultInnerText model' url' =<< mr+      x -> x++-- | Ensure that inlines list is non-empty, using the provided singleton value if necessary.+nonEmptyInlines :: Text -> [B.Inline] -> NonEmpty B.Inline+nonEmptyInlines x =+  fromMaybe (one $ B.Str x) . nonEmpty++siteRouteDefaultInnerText :: Model -> Text -> SR.SiteRoute -> Maybe [B.Inline]+siteRouteDefaultInnerText model url = \case+  SR.SiteRoute_MissingR _ -> Nothing+  SR.SiteRoute_AmbiguousR _ _ -> Nothing+  SR.SiteRoute_VirtualRoute _ -> Nothing+  SR.SiteRoute_ResourceRoute resR ->+    case resR of+      SR.ResourceRoute_LML lmlR ->+        Tit.toInlines . MN._noteTitle <$> M.modelLookupNoteByRoute lmlR model+      SR.ResourceRoute_StaticFile _ _ ->+        -- Just append a file: prefix, to existing wiki-link.+        pure $ B.Str "File:" : [B.Str url]
+ src/Emanote/Prelude.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE DeriveAnyClass #-}++-- | We use `relude` as our prelude; any extra Prelude-like functionality is put+-- here.+module Emanote.Prelude where++import Control.Monad.Logger (MonadLogger, logDebugNS, logErrorNS, logInfoNS, logWarnNS)+import Relude++-- | Monadic version of `chain`+chainM :: Monad m => (b -> m (a -> a)) -> [b] -> m (a -> a)+chainM f =+  fmap chain . mapM f+  where+    -- Apply the list of actions in the given order to an initial argument.+    --+    -- chain [f1, f2, ...] a = ... (f2 (f1 x))+    chain :: [a -> a] -> a -> a+    chain = flip $ flipfoldl' ($)++-- | User-provided input is malformed.+newtype BadInput = BadInput Text+  deriving stock (Show)+  deriving anyclass (Exception)++-------------+-- Logging+--------------++log :: MonadLogger m => Text -> m ()+log = logInfoNS "emanote"++logD :: MonadLogger m => Text -> m ()+logD = logDebugNS "emanote"++logE :: MonadLogger m => Text -> m ()+logE = logErrorNS "emanote"++logW :: MonadLogger m => Text -> m ()+logW = logWarnNS "emanote"
+ src/Emanote/Route.hs view
@@ -0,0 +1,5 @@+module Emanote.Route (module X) where++import Emanote.Route.Ext as X+import Emanote.Route.ModelRoute as X+import Emanote.Route.R as X
+ src/Emanote/Route/Ext.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DeriveAnyClass #-}++module Emanote.Route.Ext where++import Data.Aeson (ToJSON)+import Data.Data (Data)+import Relude hiding (show)+import System.FilePath qualified as FP++data SourceExt = SourceExt+  deriving stock (Eq, Ord, Show, Read, Data, Generic)+  deriving anyclass (ToJSON)++data FileType a where+  LMLType :: LML -> FileType SourceExt+  Yaml :: FileType SourceExt+  HeistTpl :: FileType SourceExt+  -- | `AnyExt` has no *known* (at compile time) extension. It is used as a+  -- "catch all" type to capture files using an arbitrary.+  AnyExt :: FileType SourceExt+  Html :: FileType ()+  Folder :: FileType ()+  deriving stock (Typeable)++deriving stock instance Eq a => Eq (FileType a)++deriving stock instance Ord a => Ord (FileType a)++-- | A lightweight markup language+--+-- https://en.wikipedia.org/wiki/Lightweight_markup_language+--+-- This type exists simply because we may support more formats (eg: org-mode) in+-- the future.+data LML = Md | Org+  deriving stock (Generic, Eq, Ord, Typeable, Data, Enum, Bounded)+  deriving anyclass (ToJSON)++-- | The `HasExt` class's responsibility is to allow dealing with basepath sans+-- extension (and vice-versa).+class HasExt (ext :: FileType a) where+  fileType :: FileType a++  -- | Return the filepath with the known extension.+  withExt :: FilePath -> FilePath++  -- | Return the filepath without the known extension.+  withoutKnownExt :: FilePath -> Maybe FilePath++instance HasExt ('LMLType 'Md) where+  fileType = LMLType Md+  withExt = flip FP.addExtension ".md"+  withoutKnownExt = fpWithoutExt ".md"++instance HasExt ('LMLType 'Org) where+  fileType = LMLType Org+  withExt = flip FP.addExtension ".org"+  withoutKnownExt = fpWithoutExt ".org"++instance HasExt 'Yaml where+  fileType = Yaml+  withExt = flip FP.addExtension ".yaml"+  withoutKnownExt = fpWithoutExt ".yaml"++instance HasExt 'Html where+  fileType = Html+  withExt = flip FP.addExtension ".html"+  withoutKnownExt = fpWithoutExt ".html"++instance HasExt 'HeistTpl where+  fileType = HeistTpl+  withExt = flip FP.addExtension ".tpl"+  withoutKnownExt = fpWithoutExt ".tpl"++instance HasExt 'Folder where+  fileType = Folder+  withExt = id+  withoutKnownExt = pure++-- | The AnyExt instance ignores explicitly dealing with extensions, expecting+-- the user to explicitly encode the extension in their value tpye.+instance HasExt 'AnyExt where+  fileType = AnyExt+  withExt = id+  withoutKnownExt = pure++fpWithoutExt :: (Monad m, Alternative m) => String -> FilePath -> m FilePath+fpWithoutExt e fp = do+  let (base, ext) = FP.splitExtension fp+  guard $ ext == e+  pure base
+ src/Emanote/Route/ModelRoute.hs view
@@ -0,0 +1,95 @@+{-# LANGUAGE DeriveAnyClass #-}++-- | Route types representing the resources in our `Model`.+--+-- See also: `Emanote.Route.SiteRoute`.+module Emanote.Route.ModelRoute+  ( -- Some route in a generated site+    ModelRoute (..),+    modelRouteCase,+    mkModelRouteFromFilePath,+    -- Only LML routes+    LMLRoute (..),+    defaultLmlRoute,+    possibleLmlRoutes,+    lmlRouteCase,+    withLmlRoute,+    mkLMLRouteFromFilePath,+    mkLMLRouteFromKnownFilePath,+    isMdRoute,+    -- Static file routes+    StaticFileRoute,+  )+where++import Data.Aeson.Types (ToJSON)+import Emanote.Route.Ext (FileType (AnyExt, LMLType), HasExt, LML (Md, Org))+import Emanote.Route.R (R)+import Emanote.Route.R qualified as R+import Relude++type StaticFileRoute = R 'AnyExt++-- | A R to anywhere in `Model`+data ModelRoute+  = ModelRoute_StaticFile StaticFileRoute+  | ModelRoute_LML LMLRoute+  deriving stock (Eq, Show, Ord, Generic)+  deriving anyclass (ToJSON)++-- | R to a note file in LML (lightweight markup language) format+data LMLRoute+  = LMLRoute_Md (R ('LMLType 'Md))+  | LMLRoute_Org (R ('LMLType 'Org))+  deriving stock (Eq, Show, Ord, Generic)+  deriving anyclass (ToJSON)++defaultLmlRoute :: R (ext :: FileType a) -> LMLRoute+defaultLmlRoute =+  LMLRoute_Md . coerce++possibleLmlRoutes :: R (ext :: FileType a) -> [LMLRoute]+possibleLmlRoutes r =+  [ LMLRoute_Md (coerce r),+    LMLRoute_Org (coerce r)+  ]++lmlRouteCase ::+  LMLRoute ->+  Either (R ('LMLType 'Md)) (R ('LMLType 'Org))+lmlRouteCase = \case+  LMLRoute_Md r -> Left r+  LMLRoute_Org r -> Right r++isMdRoute :: LMLRoute -> Bool+isMdRoute = \case+  LMLRoute_Md _ -> True+  _ -> False++withLmlRoute :: (forall lmlType. HasExt ('LMLType lmlType) => R ('LMLType lmlType) -> r) -> LMLRoute -> r+withLmlRoute f = either f f . lmlRouteCase++modelRouteCase ::+  ModelRoute ->+  Either LMLRoute StaticFileRoute+modelRouteCase = \case+  ModelRoute_LML r -> Left r+  ModelRoute_StaticFile r -> Right r++mkModelRouteFromFilePath :: FilePath -> Maybe ModelRoute+mkModelRouteFromFilePath fp =+  fmap ModelRoute_LML (mkLMLRouteFromFilePath fp)+    <|> fmap ModelRoute_StaticFile (R.mkRouteFromFilePath fp)++mkLMLRouteFromFilePath :: FilePath -> Maybe LMLRoute+mkLMLRouteFromFilePath fp =+  mkLMLRouteFromKnownFilePath Md fp+    <|> mkLMLRouteFromKnownFilePath Org fp++-- | Like `mkLMLRouteFromFilePath`, but when the file extension is known ahead+-- to be of `lmlType`.+mkLMLRouteFromKnownFilePath :: LML -> FilePath -> Maybe LMLRoute+mkLMLRouteFromKnownFilePath lmlType fp =+  case lmlType of+    Md -> fmap LMLRoute_Md (R.mkRouteFromFilePath fp)+    Org -> fmap LMLRoute_Org (R.mkRouteFromFilePath fp)
+ src/Emanote/Route/R.hs view
@@ -0,0 +1,109 @@+module Emanote.Route.R where++import Data.Aeson (ToJSON (toJSON))+import Data.Data (Data)+import Data.List.NonEmpty qualified as NE+import Data.Text qualified as T+import Emanote.Route.Ext (FileType (..), HasExt (..))+import Network.URI.Slug (Slug)+import Network.URI.Slug qualified as Slug+import Relude+import System.FilePath (splitPath)+import Text.Show qualified (Show (show))++-- | Represents the relative path to some file (or its isomporphic URL+-- represetation).+newtype R (ext :: FileType a) = R {unRoute :: NonEmpty Slug}+  deriving stock (Eq, Ord, Typeable, Data)++instance HasExt ext => ToJSON (R ext) where+  toJSON = toJSON . encodeRoute++instance HasExt ext => Show (R ext) where+  show r =+    toString $+      "R[/" <> encodeRoute r <> "]"++-- | Convert foo/bar.<ext> to a @R@+mkRouteFromFilePath :: forall a (ext :: FileType a). HasExt ext => FilePath -> Maybe (R ext)+mkRouteFromFilePath fp = do+  base <- withoutKnownExt @_ @ext fp+  let slugs = fromString . toString . T.dropWhileEnd (== '/') . toText <$> splitPath base+  viaNonEmpty R slugs++mkRouteFromSlugs :: NonEmpty Slug -> R ext+mkRouteFromSlugs =+  R++-- | If the route is a single-slug URL, return the only slug.+routeSlug :: R ext -> Maybe Slug+routeSlug r = do+  x :| [] <- pure $ unRoute r+  pure x++-- | Like `routeSlug` but skips the given prefixes, returning the (only) pending slug.+routeSlugWithPrefix :: NonEmpty Slug -> R ext -> Maybe Slug+routeSlugWithPrefix prefix r = do+  lastSlug :| (nonEmpty -> Just prevSlugs) <- pure $ NE.reverse $ unRoute r+  guard $ NE.reverse prevSlugs == prefix+  pure lastSlug++-- | The base name of the route without its parent path.+routeBaseName :: R ext -> Text+routeBaseName =+  Slug.unSlug . head . NE.reverse . unRoute++routeParent :: R ext -> Maybe (R 'Folder)+routeParent =+  viaNonEmpty R . init . unRoute++-- | For use in breadcrumbs+routeInits :: R ext -> NonEmpty (R ext)+routeInits = \case+  (R ("index" :| [])) ->+    one indexRoute+  (R (slug :| rest')) ->+    indexRoute :| case nonEmpty rest' of+      Nothing ->+        one $ R (one slug)+      Just rest ->+        R (one slug) : go (one slug) rest+  where+    go :: NonEmpty Slug -> NonEmpty Slug -> [R ext]+    go x (y :| ys') =+      let this = R (x <> one y)+       in case nonEmpty ys' of+            Nothing ->+              one this+            Just ys ->+              this : go (unRoute this) ys++indexRoute :: R ext+indexRoute = R $ "index" :| []++-- | Convert a route to filepath+encodeRoute :: forall a (ft :: FileType a). HasExt ft => R ft -> FilePath+encodeRoute (R slugs) =+  let parts = Slug.unSlug <$> slugs+   in withExt @a @ft $ toString $ T.intercalate "/" (toList parts)++-- | Parse our route from html file path+decodeHtmlRoute :: FilePath -> R 'Html+decodeHtmlRoute fp = do+  let base = fromMaybe (toText fp) $ T.stripSuffix ".html" (toText fp)+  R $ case splitOnNE "/" base of+    Nothing ->+      one "index"+    Just parts ->+      fmap Slug.decodeSlug parts+  where+    -- Like `T.splitOn` but returns a NonEmpty list with sensible semantics+    splitOnNE k s =+      case T.splitOn k s of+        [] -> Nothing+        [""] -> Nothing+        x : xs -> Just $ x :| xs++decodeAnyRoute :: FilePath -> Maybe (R 'AnyExt)+decodeAnyRoute =+  mkRouteFromFilePath @_ @'AnyExt
+ src/Emanote/Route/SiteRoute.hs view
@@ -0,0 +1,15 @@+module Emanote.Route.SiteRoute+  ( SiteRoute (..),+    VirtualRoute (..),+    ResourceRoute (..),+    decodeVirtualRoute,+    noteFileSiteRoute,+    staticFileSiteRoute,+    lmlSiteRoute,+    siteRouteUrl,+    siteRouteUrlStatic,+  )+where++import Emanote.Route.SiteRoute.Class+import Emanote.Route.SiteRoute.Type
+ src/Emanote/Route/SiteRoute/Class.hs view
@@ -0,0 +1,202 @@+module Emanote.Route.SiteRoute.Class+  ( decodeVirtualRoute,+    noteFileSiteRoute,+    staticFileSiteRoute,+    lmlSiteRoute,+    indexRoute,+    tagIndexRoute,+    taskIndexRoute,+    siteRouteUrl,+    siteRouteUrlStatic,+    urlStrategySuffix,++    -- * Ema stuff+    emanoteRouteEncoder,+    emanoteGeneratableRoutes,+  )+where++import Data.IxSet.Typed qualified as Ix+import Data.List.NonEmpty qualified as NE+import Data.Set qualified as Set+import Data.Time.Format (defaultTimeLocale, formatTime)+import Ema (UrlStrategy (..), routeUrlWith)+import Emanote.Model qualified as M+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Meta qualified as Model+import Emanote.Model.Note qualified as N+import Emanote.Model.StaticFile qualified as SF+import Emanote.Model.Type (Model, ModelEma, ModelT)+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Route qualified as R+import Emanote.Route.ModelRoute (LMLRoute, StaticFileRoute)+import Emanote.Route.SiteRoute.Type+import Emanote.View.LiveServerFiles qualified as LiveServerFile+import Optics.Core (Prism', prism')+import Optics.Operators ((^.))+import Relude++emanoteGeneratableRoutes :: ModelEma -> [SiteRoute]+emanoteGeneratableRoutes model =+  let htmlRoutes =+        model ^. M.modelNotes+          & Ix.toList+          <&> noteFileSiteRoute+      staticRoutes =+        let includeFile f =+              not (LiveServerFile.isLiveServerFile f)+                || (f == LiveServerFile.tailwindFullCssPath && not (model ^. M.modelCompileTailwind))+         in model ^. M.modelStaticFiles+              & Ix.toList+              & filter (includeFile . R.encodeRoute . SF._staticFileRoute)+              <&> staticFileSiteRoute+      virtualRoutes :: [VirtualRoute] =+        let tags = fst <$> M.modelTags model+            tagPaths =+              Set.fromList $+                ([] :) $ -- [] Triggers generation of main tag index.+                  concat $+                    tags <&> \(HT.deconstructTag -> tagPath) ->+                      NE.filter (not . null) $ NE.inits tagPath+         in VirtualRoute_Index+              : VirtualRoute_Export+              : VirtualRoute_StorkIndex+              : VirtualRoute_TaskIndex+              : (VirtualRoute_TagIndex <$> toList tagPaths)+   in htmlRoutes+        <> staticRoutes+        <> fmap SiteRoute_VirtualRoute virtualRoutes++emanoteRouteEncoder :: HasCallStack => ModelEma -> Prism' FilePath SiteRoute+emanoteRouteEncoder model =+  prism' enc dec+  where+    enc = \case+      SiteRoute_MissingR s ->+        -- error $ toText $ "emanote: attempt to encode a 404 route: " <> s+        -- Unfortunately, since ema:multisite does isomorphism check of+        -- encoder, we can't just error out here.+        s+      SiteRoute_AmbiguousR fp _ ->+        -- FIXME: See note above.+        error $ "emanote: attempt to encode an ambiguous route: " <> toText fp+      SiteRoute_ResourceRoute r ->+        encodeResourceRoute model r+      SiteRoute_VirtualRoute r ->+        encodeVirtualRoute r++    dec fp =+      fmap SiteRoute_VirtualRoute (decodeVirtualRoute fp)+        <|> decodeGeneratedRoute model fp+        <|> pure (SiteRoute_MissingR fp)++encodeResourceRoute :: HasCallStack => ModelEma -> ResourceRoute -> FilePath+encodeResourceRoute model = \case+  ResourceRoute_LML r ->+    R.encodeRoute+      $+      -- HACK: This should never fail ... but *if* it does, consult+      -- https://github.com/srid/emanote/issues/148+      maybe+        -- FIXME: See note above.+        (error $ "emanote: attempt to encode missing note: " <> show r)+        N.noteHtmlRoute+      $ M.modelLookupNoteByRoute r model+  ResourceRoute_StaticFile r _fpAbs ->+    R.encodeRoute r++-- | Decode a route that is known to refer to a resource in the model+decodeGeneratedRoute :: ModelEma -> FilePath -> Maybe SiteRoute+decodeGeneratedRoute model fp =+  fmap+    staticFileSiteRoute+    (flip M.modelLookupStaticFileByRoute model =<< R.decodeAnyRoute fp)+    <|> noteHtmlSiteRoute+      (flip M.modelLookupNoteByHtmlRoute model $ R.decodeHtmlRoute fp)+  where+    noteHtmlSiteRoute :: Rel.ResolvedRelTarget N.Note -> Maybe SiteRoute+    noteHtmlSiteRoute = \case+      Rel.RRTMissing ->+        Nothing+      Rel.RRTFound note ->+        Just $ noteFileSiteRoute note+      Rel.RRTAmbiguous notes ->+        Just $ ambiguousNoteURLsRoute notes+    ambiguousNoteURLsRoute :: NonEmpty N.Note -> SiteRoute+    ambiguousNoteURLsRoute ns =+      SiteRoute_AmbiguousR ("/" <> fp) (N._noteRoute <$> ns)++noteFileSiteRoute :: N.Note -> SiteRoute+noteFileSiteRoute =+  lmlSiteRoute . N._noteRoute++lmlSiteRoute :: LMLRoute -> SiteRoute+lmlSiteRoute =+  SiteRoute_ResourceRoute . lmlResourceRoute++lmlResourceRoute :: LMLRoute -> ResourceRoute+lmlResourceRoute = ResourceRoute_LML++staticFileSiteRoute :: SF.StaticFile -> SiteRoute+staticFileSiteRoute =+  (SiteRoute_ResourceRoute . staticResourceRoute) . (SF._staticFileRoute &&& SF._staticFilePath)+  where+    staticResourceRoute :: (StaticFileRoute, FilePath) -> ResourceRoute+    staticResourceRoute = uncurry ResourceRoute_StaticFile++-- | Like `siteRouteUrl` but avoids any dynamism in the URL+siteRouteUrlStatic :: HasCallStack => Model -> SiteRoute -> Text+siteRouteUrlStatic model =+  Ema.routeUrlWith (urlStrategy model) rp+  where+    (rp, _) = M.withoutRoutePrism model++siteRouteUrl :: HasCallStack => Model -> SiteRoute -> Text+siteRouteUrl model sr =+  siteRouteUrlStatic model sr+    <> siteRouteQuery+  where+    siteRouteQuery =+      maybe "" (("?t=" <>) . toText . formatTime defaultTimeLocale "%s") staticFileModifiedTime+    staticFileModifiedTime = do+      -- In live server model, we append a ?t=.. to trigger the browser into+      -- reloading (or invalidating its cache of) this embed static file.+      guard $ M.inLiveServer model+      sfRoute <- staticFileRouteCase sr+      sf <- M.modelLookupStaticFileByRoute sfRoute model+      pure $ sf ^. SF.staticFileTime+    staticFileRouteCase :: SiteRoute -> Maybe StaticFileRoute+    staticFileRouteCase = \case+      SiteRoute_MissingR _fp ->+        Nothing+      SiteRoute_AmbiguousR _ _ ->+        Nothing+      SiteRoute_ResourceRoute rr ->+        case rr of+          ResourceRoute_StaticFile sfR _fp ->+            Just sfR+          ResourceRoute_LML _ ->+            Nothing+      SiteRoute_VirtualRoute _ -> Nothing++urlStrategySuffix :: Model -> Text+urlStrategySuffix model =+  case urlStrategy model of+    Ema.UrlDirect -> ".html"+    Ema.UrlPretty -> ""++urlStrategy :: ModelT f -> UrlStrategy+urlStrategy model =+  Model.lookupRouteMeta Ema.UrlDirect ("template" :| one "urlStrategy") (M.modelIndexRoute model) model++indexRoute :: SiteRoute+indexRoute =+  SiteRoute_VirtualRoute VirtualRoute_Index++tagIndexRoute :: [HT.TagNode] -> SiteRoute+tagIndexRoute =+  SiteRoute_VirtualRoute . VirtualRoute_TagIndex++taskIndexRoute :: SiteRoute+taskIndexRoute =+  SiteRoute_VirtualRoute VirtualRoute_TaskIndex
+ src/Emanote/Route/SiteRoute/Type.hs view
@@ -0,0 +1,114 @@+{-# LANGUAGE DeriveAnyClass #-}++module Emanote.Route.SiteRoute.Type+  ( SiteRoute (..),+    VirtualRoute (..),+    ResourceRoute (..),+    decodeVirtualRoute,+    encodeVirtualRoute,+    encodeTagIndexR,+  )+where++import Data.Aeson (ToJSON)+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Route.Ext qualified as Ext+import Emanote.Route.ModelRoute (LMLRoute, StaticFileRoute, lmlRouteCase)+import Emanote.Route.R qualified as R+import Network.URI.Slug qualified as Slug+import Relude hiding (show)+import Text.Show (show)++-- | A route to a virtual resource (not in `Model`)+data VirtualRoute+  = VirtualRoute_Index+  | VirtualRoute_TagIndex [HT.TagNode]+  | VirtualRoute_Export+  | VirtualRoute_StorkIndex+  | VirtualRoute_TaskIndex+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (ToJSON)++-- | A route to a resource in `Model`+--+-- This is *mostly isomorphic* to `ModelRoute`, except for containing the+-- absolute path to the static file.+data ResourceRoute+  = ResourceRoute_StaticFile StaticFileRoute FilePath+  | ResourceRoute_LML LMLRoute+  deriving stock (Eq, Show, Ord, Generic)+  deriving anyclass (ToJSON)++data SiteRoute+  = SiteRoute_VirtualRoute VirtualRoute+  | SiteRoute_ResourceRoute ResourceRoute+  | SiteRoute_MissingR FilePath+  | SiteRoute_AmbiguousR FilePath (NonEmpty LMLRoute)+  deriving stock (Eq, Ord, Generic)++instance Show SiteRoute where+  show = \case+    SiteRoute_MissingR urlPath ->+      "404: " <> urlPath+    SiteRoute_AmbiguousR urlPath _notes ->+      "Amb: " <> urlPath+    SiteRoute_ResourceRoute rr ->+      case rr of+        ResourceRoute_StaticFile r _fp ->+          show r+        ResourceRoute_LML r ->+          show $ lmlRouteCase r+    SiteRoute_VirtualRoute x ->+      show x++decodeVirtualRoute :: FilePath -> Maybe VirtualRoute+decodeVirtualRoute fp =+  (VirtualRoute_Index <$ decodeIndexR fp)+    <|> (VirtualRoute_TagIndex <$> decodeTagIndexR fp)+    <|> (VirtualRoute_Export <$ decodeExportR fp)+    <|> (VirtualRoute_StorkIndex <$ decodeStorkIndexR fp)+    <|> (VirtualRoute_TaskIndex <$ decodeTaskIndexR fp)++decodeIndexR :: FilePath -> Maybe ()+decodeIndexR fp = do+  "-" :| ["all"] <- pure $ R.unRoute $ R.decodeHtmlRoute fp+  pass++decodeExportR :: FilePath -> Maybe ()+decodeExportR fp = do+  "-" :| ["export.json"] <- R.unRoute <$> R.decodeAnyRoute fp+  pass++decodeStorkIndexR :: FilePath -> Maybe ()+decodeStorkIndexR fp = do+  "-" :| ["stork.st"] <- R.unRoute <$> R.decodeAnyRoute fp+  pass++decodeTagIndexR :: FilePath -> Maybe [HT.TagNode]+decodeTagIndexR fp = do+  "-" :| "tags" : tagPath <- pure $ R.unRoute $ R.decodeHtmlRoute fp+  pure $ fmap (HT.TagNode . Slug.unSlug) tagPath++decodeTaskIndexR :: FilePath -> Maybe ()+decodeTaskIndexR fp = do+  "-" :| ["tasks"] <- pure $ R.unRoute $ R.decodeHtmlRoute fp+  pass++-- NOTE: The sentinel route slugs in this function should match with those of+-- the decoders above.+encodeVirtualRoute :: VirtualRoute -> FilePath+encodeVirtualRoute = \case+  VirtualRoute_TagIndex tagNodes ->+    R.encodeRoute $ encodeTagIndexR tagNodes+  VirtualRoute_Index ->+    R.encodeRoute $ R.R @() @'Ext.Html $ "-" :| ["all"]+  VirtualRoute_Export ->+    R.encodeRoute $ R.R @Ext.SourceExt @'Ext.AnyExt $ "-" :| ["export.json"]+  VirtualRoute_StorkIndex ->+    R.encodeRoute $ R.R @Ext.SourceExt @'Ext.AnyExt $ "-" :| ["stork.st"]+  VirtualRoute_TaskIndex ->+    R.encodeRoute $ R.R @() @'Ext.Html $ "-" :| ["tasks"]++encodeTagIndexR :: [HT.TagNode] -> R.R 'Ext.Html+encodeTagIndexR tagNodes =+  R.R $ "-" :| "tags" : fmap (fromString . toString . HT.unTagNode) tagNodes
+ src/Emanote/Source/Dynamic.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}++module Emanote.Source.Dynamic+  ( emanoteSiteInput,+    EmanoteConfig (..),+    emanoteCompileTailwind,+    emanoteConfigCli,+    emanoteConfigNoteFn,+    emanoteConfigPandocRenderers,+  )+where++import Control.Monad.Logger (MonadLogger, MonadLoggerIO)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Some (Some)+import Data.UUID.V4 qualified as UUID+import Ema (Dynamic (..))+import Ema.CLI qualified+import Emanote.CLI qualified as CLI+import Emanote.Model.Note (Note)+import Emanote.Model.Stork.Index qualified as Stork+import Emanote.Model.Type qualified as Model+import Emanote.Pandoc.Renderer (EmanotePandocRenderers)+import Emanote.Prelude (chainM)+import Emanote.Route (LMLRoute)+import Emanote.Source.Loc (Loc)+import Emanote.Source.Loc qualified as Loc+import Emanote.Source.Patch qualified as Patch+import Emanote.Source.Pattern qualified as Pattern+import Optics.TH (makeLenses)+import Paths_emanote qualified+import Relude+import System.UnionMount qualified as UM+import UnliftIO (MonadUnliftIO)++-- | Everything that's required to run an Emanote site.+data EmanoteConfig = EmanoteConfig+  { -- | CLI arguments (includes layers)+    _emanoteConfigCli :: CLI.Cli,+    -- | A function to filter the `Note` before it gets added to the model.+    _emanoteConfigNoteFn :: Note -> Note,+    -- | How to render Pandoc to Heist HTML.+    _emanoteConfigPandocRenderers :: EmanotePandocRenderers Model.Model LMLRoute,+    -- | Whether to replace Tailwind2 CDN with a minimized Tailwind3 CSS file.+    _emanoteCompileTailwind :: Bool+  }++-- | Make an Ema `Dynamic` for the Emanote model.+--+-- The bulk of logic for building the Dynamic is in `Patch.hs`.+emanoteSiteInput :: (MonadUnliftIO m, MonadLoggerIO m) => Some Ema.CLI.Action -> EmanoteConfig -> m (Dynamic m Model.ModelEma)+emanoteSiteInput cliAct EmanoteConfig {..} = do+  defaultLayer <- Loc.defaultLayer <$> liftIO Paths_emanote.getDataDir+  instanceId <- liftIO UUID.nextRandom+  storkIndex <- Stork.newIndex+  let layers = Loc.userLayers (CLI.layers _emanoteConfigCli) <> one defaultLayer+      initialModel = Model.emptyModel layers cliAct _emanoteConfigPandocRenderers _emanoteCompileTailwind instanceId storkIndex+  Dynamic+    <$> UM.unionMount+      (layers & Set.map (id &&& Loc.locPath))+      Pattern.filePatterns+      Pattern.ignorePatterns+      initialModel+      (mapFsChanges $ Patch.patchModel layers _emanoteConfigNoteFn storkIndex)++type ChangeHandler tag model m =+  tag ->+  FilePath ->+  UM.FileAction (NonEmpty (Loc, FilePath)) ->+  m (model -> model)++mapFsChanges :: (MonadIO m, MonadLogger m) => ChangeHandler tag model m -> UM.Change Loc tag -> m (model -> model)+mapFsChanges h ch = do+  uncurry (mapFsChangesOnExt h) `chainM` Map.toList ch+  where+    -- Temporarily use block buffering before calling an IO action that is+    -- known ahead to log rapidly, so as to not hamper serial processing speed.+    -- FIXME: This buffers warnings and errors (when parsing .md file) without+    -- dumping them to console. So disabling for now. But we need a proper fix.+    _withBlockBuffering f =+      (hSetBuffering stdout (BlockBuffering Nothing) >> hSetBuffering stderr LineBuffering)+        *> f+        <* (hFlush stdout >> hFlush stderr >> hSetBuffering stdout LineBuffering)++mapFsChangesOnExt ::+  (MonadIO m, MonadLogger m) =>+  ChangeHandler tag model m ->+  tag ->+  Map FilePath (UM.FileAction (NonEmpty (Loc, FilePath))) ->+  m (model -> model)+mapFsChangesOnExt h fpType fps = do+  uncurry (h fpType) `chainM` Map.toList fps++makeLenses ''EmanoteConfig
+ src/Emanote/Source/Loc.hs view
@@ -0,0 +1,62 @@+-- | Notebook location+module Emanote.Source.Loc+  ( -- * Type+    Loc (..),++    -- * Making a `Loc`+    defaultLayer,+    userLayers,++    -- * Using a `Loc`+    locResolve,+    locPath,++    -- * Dealing with layers of locs+    LocLayers,+    primaryLayer,+  )+where++import Data.Set qualified as Set+import Relude+import System.FilePath ((</>))++-- | Location of the notebook+--+-- The order here matters. Top = higher precedence.+data Loc+  = -- | The Int argument specifies the precedence (lower value = higher precedence)+    LocUser Int FilePath+  | -- | The default location (ie., emanote default layer)+    LocDefault FilePath+  deriving stock (Eq, Ord, Show)++type LocLayers = Set Loc++-- | Return the "primary" `LocUser` layer (that which are not overrides).+--+-- Assumes that the user has put it always by last; i.e, `-L foo;primary/layer`.+primaryLayer :: HasCallStack => LocLayers -> Loc+primaryLayer =+  Set.findMax . Set.filter isUserLayer+  where+    isUserLayer = \case+      LocUser _ _ -> True+      _ -> False++defaultLayer :: FilePath -> Loc+defaultLayer = LocDefault++userLayers :: NonEmpty FilePath -> Set Loc+userLayers paths =+  fromList $+    zip [1 ..] (toList paths) <&> uncurry LocUser++-- | Return the effective path of a file.+locResolve :: (Loc, FilePath) -> FilePath+locResolve (loc, fp) = locPath loc </> fp++locPath :: Loc -> FilePath+locPath = \case+  LocUser _ fp -> fp+  LocDefault fp -> fp
+ src/Emanote/Source/Patch.hs view
@@ -0,0 +1,184 @@+-- | Patch model state depending on file change event.+module Emanote.Source.Patch+  ( patchModel,+    filePatterns,+    ignorePatterns,+  )+where++import Control.Exception (throwIO)+import Control.Monad.Logger (LoggingT (runLoggingT), MonadLogger, MonadLoggerIO (askLoggerIO))+import Data.ByteString qualified as BS+import Data.List.NonEmpty qualified as NEL+import Data.Time (defaultTimeLocale, formatTime, getCurrentTime)+import Emanote.Model qualified as M+import Emanote.Model.Note qualified as N+import Emanote.Model.SData qualified as SD+import Emanote.Model.Stork.Index qualified as Stork+import Emanote.Model.Type (ModelEma)+import Emanote.Prelude+  ( BadInput (BadInput),+    log,+    logD,+  )+import Emanote.Route qualified as R+import Emanote.Source.Loc (Loc, LocLayers, locPath, locResolve, primaryLayer)+import Emanote.Source.Pattern (filePatterns, ignorePatterns)+import Heist.Extra.TemplateState qualified as T+import Optics.Operators ((%~))+import Relude+import Relude.Extra (traverseToSnd)+import System.UnionMount qualified as UM+import UnliftIO.Concurrent (threadDelay)+import UnliftIO.Directory (doesDirectoryExist)++-- | Map a filesystem change to the corresponding model change.+patchModel ::+  (MonadIO m, MonadLogger m, MonadLoggerIO m) =>+  LocLayers ->+  (N.Note -> N.Note) ->+  Stork.IndexVar ->+  -- | Type of the file being changed+  R.FileType R.SourceExt ->+  -- | Path to the file being changed+  FilePath ->+  -- | Specific change to the file, along with its paths from other "layers"+  UM.FileAction (NonEmpty (Loc, FilePath)) ->+  m (ModelEma -> ModelEma)+patchModel layers noteF storkIndexTVar fpType fp action = do+  logger <- askLoggerIO+  now <- liftIO getCurrentTime+  -- Prefix all patch logging with timestamp.+  let newLogger loc src lvl s =+        logger loc src lvl $ fromString (formatTime defaultTimeLocale "[%H:%M:%S] " now) <> s+  runLoggingT (patchModel' layers noteF storkIndexTVar fpType fp action) newLogger++-- | Map a filesystem change to the corresponding model change.+patchModel' ::+  (MonadIO m, MonadLogger m) =>+  LocLayers ->+  (N.Note -> N.Note) ->+  Stork.IndexVar ->+  -- | Type of the file being changed+  R.FileType R.SourceExt ->+  -- | Path to the file being changed+  FilePath ->+  -- | Specific change to the file, along with its paths from other "layers"+  UM.FileAction (NonEmpty (Loc, FilePath)) ->+  m (ModelEma -> ModelEma)+patchModel' layers noteF storkIndexTVar fpType fp action = do+  case fpType of+    R.LMLType lmlType -> do+      case R.mkLMLRouteFromKnownFilePath lmlType fp of+        Nothing ->+          pure id -- Impossible+        Just r -> do+          -- Stork doesn't support incremental building of index, so we must+          -- clear it to pave way for a rebuild later when requested.+          --+          -- From https://github.com/jameslittle230/stork/discussions/112#discussioncomment-252861+          --+          -- > Stork also doesn't support incremental index updates today --+          -- you'd have to re-index everything when users added a new document,+          -- which might be prohibitively long.+          Stork.clearStorkIndex storkIndexTVar++          case action of+            UM.Refresh refreshAction overlays -> do+              let fpAbs = locResolve $ head overlays+                  -- TODO: This should automatically be computed, instead of being passed.+                  -- We need access to the model though! With dependency management to boot.+                  -- Until this, `layers` is threaded through as a hack.+                  currentLayerPath = locPath $ primaryLayer layers+              s <- readRefreshedFile refreshAction fpAbs+              note <- N.parseNote currentLayerPath r fpAbs (decodeUtf8 s)+              pure $ M.modelInsertNote $ noteF note+            UM.Delete -> do+              log $ "Removing note: " <> toText fp+              pure $ M.modelDeleteNote r+    R.Yaml ->+      case R.mkRouteFromFilePath fp of+        Nothing ->+          pure id+        Just r -> case action of+          UM.Refresh refreshAction overlays -> do+            yamlContents <- forM (NEL.reverse overlays) $ \overlay -> do+              let fpAbs = locResolve overlay+              traverseToSnd (readRefreshedFile refreshAction) fpAbs+            sData <-+              liftIO $+                either (throwIO . BadInput) pure $+                  SD.parseSDataCascading r yamlContents+            pure $ M.modelInsertData sData+          UM.Delete -> do+            log $ "Removing data: " <> toText fp+            pure $ M.modelDeleteData r+    R.HeistTpl ->+      case action of+        UM.Refresh refreshAction overlays -> do+          let fpAbs = locResolve $ head overlays+              -- Once we start loading HTML templates, mark the model as "ready"+              -- so Ema will begin rendering content in place of "Loading..."+              -- indicator+              readyOnTemplates = bool id M.modelReadyForView (refreshAction == UM.Existing)+          act <- do+            s <- readRefreshedFile refreshAction fpAbs+            logD $ "Read " <> show (BS.length s) <> " bytes of template"+            pure $ M.modelHeistTemplate %~ T.addTemplateFile fpAbs fp s+          pure $ readyOnTemplates >>> act+        UM.Delete -> do+          log $ "Removing template: " <> toText fp+          pure $ M.modelHeistTemplate %~ T.removeTemplateFile fp+    R.AnyExt -> do+      case R.mkRouteFromFilePath fp of+        Nothing ->+          pure id+        Just r -> case action of+          UM.Refresh refreshAction overlays -> do+            let fpAbs = locResolve $ head overlays+            doesDirectoryExist fpAbs >>= \case+              True ->+                -- A directory got added; this is not a static 'file'+                pure id+              False -> do+                let logF = case refreshAction of+                      UM.Existing -> logD . ("Registering" <>)+                      _ -> log . ("Re-registering" <>)+                logF $ " file: " <> toText fpAbs <> " " <> show r+                t <- liftIO getCurrentTime+                pure $ M.modelInsertStaticFile t r fpAbs+          UM.Delete -> do+            pure $ M.modelDeleteStaticFile r++readRefreshedFile :: (MonadLogger m, MonadIO m) => UM.RefreshAction -> FilePath -> m ByteString+readRefreshedFile refreshAction fp =+  case refreshAction of+    UM.Existing -> do+      logD $ "Loading file: " <> toText fp+      readFileBS fp+    _ ->+      readFileFollowingFsnotify fp++-- | Like `readFileBS` but accounts for file truncation due to us responding+-- *immediately* to a fsnotify modify event (which is triggered even before the+-- writer *finishes* writing the new contents). We solve this "glitch" by+-- delaying the read retry, expecting (hoping really) that *this time* the new+-- non-empty contents will come through. 'tis a bit of a HACK though.+readFileFollowingFsnotify :: (MonadIO m, MonadLogger m) => FilePath -> m ByteString+readFileFollowingFsnotify fp = do+  log $ "Reading file: " <> toText fp+  readFileBS fp >>= \case+    "" ->+      reReadFileBS 100 fp >>= \case+        "" ->+          -- Sometimes 100ms is not enough (eg: on WSL), so wait a bit more and+          -- give it another try.+          reReadFileBS 300 fp+        s -> pure s+    s -> pure s+  where+    -- Wait before reading, logging the given delay.+    reReadFileBS ms filePath = do+      threadDelay $ 1000 * ms+      log $ "Re-reading (" <> show ms <> "ms" <> ") file: " <> toText filePath+      readFileBS filePath
+ src/Emanote/Source/Pattern.hs view
@@ -0,0 +1,42 @@+module Emanote.Source.Pattern where++import Emanote.Route qualified as R+import Relude+import System.FilePattern (FilePattern)++filePattern :: HasCallStack => R.FileType R.SourceExt -> FilePath+filePattern = \case+  R.LMLType R.Md ->+    R.withExt @_ @('R.LMLType 'R.Md) $+      "**/*"+  R.LMLType R.Org ->+    R.withExt @_ @('R.LMLType 'R.Org) $+      "**/*"+  R.Yaml ->+    R.withExt @_ @'R.Yaml $+      "**/*"+  R.HeistTpl ->+    R.withExt @_ @'R.HeistTpl $+      "**/*"+  R.AnyExt ->+    "**"++filePatterns :: [(R.FileType R.SourceExt, FilePattern)]+filePatterns =+  (id &&& filePattern)+    <$> [ R.LMLType R.Md,+          R.LMLType R.Org,+          R.Yaml,+          R.HeistTpl,+          R.AnyExt+        ]++ignorePatterns :: [FilePattern]+ignorePatterns =+  [ -- Ignore all dotfile directories (eg: .git, .vscode)+    "**/.*/**",+    -- Ignore vi/vim/neovim writebackup files (see ":help writebackup")+    "**/*~",+    -- /Top-level ./-/ directory is reserved by Emanote+    "-/**"+  ]
+ src/Emanote/View.hs view
@@ -0,0 +1,3 @@+module Emanote.View (render) where++import Emanote.View.Template (render)
+ src/Emanote/View/Common.hs view
@@ -0,0 +1,226 @@+{-# LANGUAGE RecordWildCards #-}++module Emanote.View.Common+  ( commonSplices,+    renderModelTemplate,+    routeBreadcrumbs,+    generatedCssFile,++    -- * Render context+    TemplateRenderCtx (..),+    mkTemplateRenderCtx,+    defaultRouteMeta,+  )+where++import Data.Aeson.Types qualified as Aeson+import Data.Map.Syntax ((##))+import Data.Text qualified as T+import Data.Version (showVersion)+import Ema qualified+import Emanote.Model.Meta qualified as Meta+import Emanote.Model.SData qualified as SData+import Emanote.Model.Title qualified as Tit+import Emanote.Model.Type (Model)+import Emanote.Model.Type qualified as M+import Emanote.Pandoc.BuiltinFilters (preparePandoc)+import Emanote.Pandoc.Renderer (EmanotePandocRenderers (..), PandocRenderers (..))+import Emanote.Pandoc.Renderer qualified as Renderer+import Emanote.Route (LMLRoute)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute.Class qualified as SR+import Emanote.View.LiveServerFiles qualified as LiveServerFiles+import Heist qualified as H+import Heist.Extra.Splices.List qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx (RenderCtx)+import Heist.Extra.TemplateState qualified as Tmpl+import Heist.Interpreted qualified as HI+import Heist.Splices.Apply qualified as HA+import Heist.Splices.Bind qualified as HB+import Heist.Splices.Json qualified as HJ+import Optics.Operators ((^.))+import Paths_emanote qualified+import Relude+import System.FilePath ((</>))+import Text.Blaze.Html ((!))+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A+import Text.Blaze.Renderer.XmlHtml qualified as RX++data TemplateRenderCtx n = TemplateRenderCtx+  { withInlineCtx :: (RenderCtx -> HI.Splice Identity) -> HI.Splice Identity,+    withBlockCtx :: (RenderCtx -> HI.Splice Identity) -> HI.Splice Identity,+    withLinkInlineCtx :: (RenderCtx -> HI.Splice Identity) -> HI.Splice Identity,+    titleSplice :: Tit.Title -> HI.Splice Identity+  }++-- | Create the context in which Heist templates (notably `pandoc.tpl`) will be+-- rendered.+mkTemplateRenderCtx ::+  -- | Current model.+  Model ->+  -- | Current route.+  R.LMLRoute ->+  -- | Associated metadata.+  Aeson.Value ->+  TemplateRenderCtx Identity+mkTemplateRenderCtx model r meta =+  let EmanotePandocRenderers {..} = M._modelPandocRenderers model+      withInlineCtx =+        withRenderCtx inlineRenderers+      withLinkInlineCtx =+        withRenderCtx linkInlineRenderers+      withBlockCtx =+        withRenderCtx blockRenderers+      -- TODO: We should be using withInlineCtx, so as to make the wikilink+      -- render in note title.+      titleSplice titleDoc = withLinkInlineCtx $ \ctx ->+        Tit.titleSplice ctx preparePandoc titleDoc+   in TemplateRenderCtx {..}+  where+    withRenderCtx ::+      (Monad m) =>+      PandocRenderers Model LMLRoute ->+      (RenderCtx -> H.HeistT Identity m x) ->+      H.HeistT Identity m x+    withRenderCtx pandocRenderers f =+      f+        =<< Renderer.mkRenderCtxWithPandocRenderers+          pandocRenderers+          classRules+          model+          r+    classRules :: Map Text Text+    classRules =+      SData.lookupAeson mempty ("pandoc" :| ["rewriteClass"]) meta++defaultRouteMeta :: Model -> (LMLRoute, Aeson.Value)+defaultRouteMeta model =+  let r = M.modelIndexRoute model+      meta = Meta.getEffectiveRouteMeta r model+   in (r, meta)++generatedCssFile :: FilePath+generatedCssFile = "tailwind.css"++commonSplices ::+  HasCallStack =>+  ((RenderCtx -> HI.Splice Identity) -> HI.Splice Identity) ->+  Model ->+  Aeson.Value ->+  Tit.Title ->+  H.Splices (HI.Splice Identity)+commonSplices withCtx model meta routeTitle = do+  let siteTitle = fromString . toString $ SData.lookupAeson @Text "Emanote Site" ("page" :| ["siteTitle"]) meta+      routeTitleFull =+        if routeTitle == siteTitle+          then siteTitle+          else routeTitle <> " – " <> siteTitle+  -- Heist helpers+  "bind" ## HB.bindImpl+  "apply" ## HA.applyImpl+  -- Add tailwind css shim+  "tailwindCssShim" ##+    do+      pure . RX.renderHtmlNodes $+        if M.inLiveServer model || not (model ^. M.modelCompileTailwind)+          then do+            -- Twind shim doesn't reliably work in dev server mode. Let's just use the+            -- tailwind CDN.+            cachedTailwindCdn+          else do+            H.link+              -- TODO: Use ?md5 to prevent stale browser caching of CSS.+              -- TODO: This should go through Ema route encoder!+              ! A.href (H.toValue $ cannotBeCached generatedCssFile)+              ! A.rel "stylesheet"+              ! A.type_ "text/css"+  "ema:version" ##+    HI.textSplice (toText $ showVersion Paths_emanote.version)+  "ema:metadata" ##+    HJ.bindJson meta+  "ema:title" ## withCtx $ \ctx ->+    Tit.titleSplice ctx preparePandoc routeTitle+  -- <head>'s <title> cannot contain HTML+  "ema:titleFull" ##+    Tit.titleSpliceNoHtml routeTitleFull+  -- `ema:homeUrl` is normally `""`; but if Emanote is being served from an URL+  -- prefix, it would be "/foo/" (with a slash at the end). This allows you to+  -- just concatanate homeUrl with a relative URL path (no slash in between), to+  -- get the full URL. The reason there is no slash in between is to account for+  -- the usual case of homeUrl being an empty string.+  "ema:homeUrl" ##+    ( let homeR = SR.lmlSiteRoute (M.modelIndexRoute model)+          homeUrl' = SR.siteRouteUrl model homeR+          homeUrl = if homeUrl' /= "" then homeUrl' <> "/" else homeUrl'+       in HI.textSplice homeUrl+    )+  "ema:indexUrl" ##+    HI.textSplice (SR.siteRouteUrl model SR.indexRoute)+  "ema:tagIndexUrl" ##+    HI.textSplice (SR.siteRouteUrl model $ SR.tagIndexRoute [])+  "ema:taskIndexUrl" ##+    HI.textSplice (SR.siteRouteUrl model SR.taskIndexRoute)+  "ema:emanoteStaticLayerUrl" ##+    HI.textSplice+      ( -- HACK+        -- Also: more-head.tpl is the one place where this is hardcoded.+        let staticFolder = "_emanote-static"+            itUrl =+              SR.siteRouteUrl model $+                SR.staticFileSiteRoute $+                  fromMaybe (error "no _emanote-static?") $+                    M.modelLookupStaticFile (staticFolder </> "inverted-tree.css") model+            staticFolderUrl = fst $ T.breakOn "/inverted-tree.css" itUrl+            -- Deal with a silly Firefox bug https://github.com/EmaApps/emanote/issues/340+            --+            -- Firefox deduces an incorrect <base> after doing morphdom+            -- patching, unless the <base> is absolute (i.e., starts with a '/').+            patchForFirefoxBug folder url =+              if M.inLiveServer model && url == toText folder+                then "/" <> url+                else url+         in patchForFirefoxBug staticFolder staticFolderUrl+      )+  -- For those cases the user really wants to hardcode the URL+  "ema:urlStrategySuffix" ##+    HI.textSplice (SR.urlStrategySuffix model)+  where+    -- A hack to force the browser not to cache the CSS, because we are not md5+    -- hashing the CSS yet (because the CSS is generated *after* the HTML files+    -- are generated.)+    -- For a proper way to do this, see: https://github.com/srid/ema/issues/20+    cannotBeCached url = url <> "?instanceId=" <> show (model ^. M.modelInstanceID)+    cachedTailwindCdn = do+      let localCdnUrl =+            SR.siteRouteUrl model $+              SR.staticFileSiteRoute $+                LiveServerFiles.tailwindCssFile model+      H.link+        ! A.href (H.toValue localCdnUrl)+        ! A.rel "stylesheet"+        ! A.type_ "text/css"++renderModelTemplate :: Model -> Tmpl.TemplateName -> H.Splices (HI.Splice Identity) -> LByteString+renderModelTemplate model templateName =+  let handleErr =+        if M.inLiveServer model+          then Ema.emaErrorHtmlResponse+          else -- When staticaly generating, we must fail asap on template errors.+            error+   in -- Until Ema's error handling improves ...+      either handleErr id+        . flip (Tmpl.renderHeistTemplate templateName) (model ^. M.modelHeistTemplate)++routeBreadcrumbs :: TemplateRenderCtx n -> Model -> LMLRoute -> HI.Splice Identity+routeBreadcrumbs TemplateRenderCtx {..} model r = do+  let breadcrumbs =+        r+          & R.lmlRouteCase+          -- Hardcode to 'Md, and resolve using resolveLmlRoute latter.+          & either R.routeInits (R.routeInits . coerce)+          & init+          & fmap (M.resolveLmlRoute model)+  Splices.listSplice breadcrumbs "each-crumb" $ \crumbR -> do+    "crumb:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute crumbR)+    "crumb:title" ## titleSplice (M.modelLookupTitle crumbR model)
+ src/Emanote/View/Export.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE DeriveAnyClass #-}++module Emanote.View.Export+  ( renderGraphExport,+    Link (..),+    modelRels,+  )+where++import Data.Aeson (ToJSON)+import Data.Aeson qualified as Aeson+import Data.Map.Strict qualified as Map+import Emanote.Model (Model)+import Emanote.Model qualified as M+import Emanote.Model.Graph qualified as G+import Emanote.Model.Link.Rel qualified as Rel+import Emanote.Model.Link.Resolve qualified as Resolve+import Emanote.Model.Title qualified as Tit+import Emanote.Route (LMLRoute)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute qualified as SR+import Emanote.Route.SiteRoute.Class (lmlSiteRoute)+import Optics.Operators ((^.))+import Relude++data Export = Export+  { version :: Word,+    files :: Map Text SourceFile+  }+  deriving stock (Generic, Show)+  deriving anyclass (ToJSON)++currentVersion :: Word+currentVersion = 1++-- | A source file in `Model`+data SourceFile = SourceFile+  { title :: Text,+    filePath :: Text,+    parentNote :: Maybe Text,+    url :: Text,+    meta :: Aeson.Value,+    links :: [Link]+  }+  deriving stock (Generic, Show)+  deriving anyclass (ToJSON)++data Link = Link+  { unresolvedRelTarget :: Rel.UnresolvedRelTarget,+    resolvedRelTarget :: Rel.ResolvedRelTarget Text+  }+  deriving stock (Generic, Eq, Ord, Show)+  deriving anyclass (ToJSON)++renderGraphExport :: Model -> LByteString+renderGraphExport model =+  let notes_ =+        M.modelNoteMetas model+          & Map.mapKeys lmlRouteKey+          & Map.map+            ( \(tit, r, meta_) ->+                let k = lmlRouteKey r+                 in SourceFile+                      (Tit.toPlain tit)+                      k+                      (toText . lmlSourcePath <$> G.parentLmlRoute model r)+                      (SR.siteRouteUrl model $ lmlSiteRoute r)+                      meta_+                      (fromMaybe [] $ Map.lookup k rels)+            )+      rels = modelRels model & Map.mapKeys lmlRouteKey+      export = Export currentVersion notes_+   in Aeson.encode export++modelRels :: Model -> Map LMLRoute [Link]+modelRels model =+  Map.fromListWith (<>) $+    M.modelNoteRels model <&> \rel ->+      let from_ = rel ^. Rel.relFrom+          to_ = rel ^. Rel.relTo+          toTarget =+            Resolve.resolveUnresolvedRelTarget model to_+              <&> SR.siteRouteUrlStatic model+       in (from_, one $ Link to_ toTarget)++-- An unique key to represent this LMLRoute in the exported JSON+--+-- We use the source path consistently.+lmlRouteKey :: LMLRoute -> Text+lmlRouteKey =+  toText . R.withLmlRoute R.encodeRoute++-- Path of the LML note+lmlSourcePath :: LMLRoute -> FilePath+lmlSourcePath =+  R.withLmlRoute R.encodeRoute
+ src/Emanote/View/LiveServerFiles.hs view
@@ -0,0 +1,27 @@+module Emanote.View.LiveServerFiles+  ( isLiveServerFile,+    tailwindFullCssPath,+    tailwindCssFile,+  )+where++import Data.Text qualified as T+import Emanote.Model.StaticFile (StaticFile)+import Emanote.Model.Type qualified as M+import Relude++-- TODO: Check this compile-time using TH?++baseDir :: FilePath+baseDir = "_emanote-live-server"++tailwindFullCssPath :: FilePath+tailwindFullCssPath = baseDir <> "/tailwind/2.2.2/tailwind.min.css"++isLiveServerFile :: FilePath -> Bool+isLiveServerFile (toText -> fp) =+  toText baseDir `T.isPrefixOf` fp++tailwindCssFile :: M.Model -> StaticFile+tailwindCssFile model =+  fromMaybe (error "model not ready?") $ M.modelLookupStaticFile tailwindFullCssPath model
+ src/Emanote/View/TagIndex.hs view
@@ -0,0 +1,111 @@+module Emanote.View.TagIndex (renderTagIndex) where++import Data.List qualified as List+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Map.Syntax ((##))+import Data.Tree (Forest, Tree)+import Data.Tree qualified as Tree+import Emanote.Model (Model)+import Emanote.Model qualified as M+import Emanote.Model.Note qualified as MN+import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT+import Emanote.Pandoc.Renderer.Query qualified as PF+import Emanote.Route.SiteRoute.Class qualified as SR+import Emanote.View.Common+  ( TemplateRenderCtx (withInlineCtx),+    commonSplices,+    defaultRouteMeta,+    mkTemplateRenderCtx,+    renderModelTemplate,+  )+import Heist.Extra.Splices.List qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx (emptyRenderCtx)+import Heist.Interpreted qualified as HI+import Relude++-- An index view into the notebook indexed by the given tag path.+data TagIndex = TagIndex+  { -- | The tag path under which this index is creatd+    tagIndexPath :: [HT.TagNode],+    -- | User descriptive title of this index+    tagIndexTitle :: Text,+    -- | All notes tagged precisely with this tag path+    tagIndexNotes :: [MN.Note],+    -- | Tags immediately under this tag path.+    --+    -- If the tag path being index is "foo/bar", this will contain "foo/bar/qux".+    tagIndexChildren :: [(NonEmpty HT.TagNode, [MN.Note])]+  }+  deriving stock (Eq)++mkTagIndex :: Model -> [HT.TagNode] -> TagIndex+mkTagIndex model tagPath' =+  let mTagPath = nonEmpty tagPath'+      tagMap = Map.fromList $ M.modelTags model+      tagForest = HT.tagTree tagMap+      childNodes =+        maybe+          (fst . Tree.rootLabel <$> tagForest)+          (fmap (fst . Tree.rootLabel) . Tree.subForest . flip lookupForestMust tagForest)+          mTagPath+      childTags =+        childNodes <&> \childNode ->+          let t = NE.reverse $ childNode :| reverse tagPath'+           in (t, maybeToMonoid $ Map.lookup (HT.constructTag t) tagMap)+   in case mTagPath of+        Nothing ->+          -- The root index displays all top-level tags (no notes)+          TagIndex [] "Tag Index" [] childTags+        Just tagPath ->+          let notes =+                snd . Tree.rootLabel $ lookupForestMust tagPath tagForest+              viewTitle = "#" <> tagNodesText tagPath <> " - Tag Index"+           in TagIndex (toList tagPath) viewTitle notes childTags+  where+    lookupForestMust :: (Show k, Eq k) => NonEmpty k -> Forest (k, a) -> Tree (k, a)+    lookupForestMust path =+      fromMaybe (error $ "Tag not found in forest: " <> show path)+        . lookupForest path+    lookupForest :: Eq k => NonEmpty k -> Forest (k, a) -> Maybe (Tree (k, a))+    lookupForest (k :| ks') trees =+      case nonEmpty ks' of+        Nothing ->+          List.find (\(Tree.Node lbl _) -> fst lbl == k) trees+        Just ks -> do+          subForest <- Tree.subForest <$> List.find (\(Tree.Node lbl _) -> fst lbl == k) trees+          lookupForest ks subForest++renderTagIndex :: Model -> [HT.TagNode] -> LByteString+renderTagIndex model tagPath = do+  let (r, meta) = defaultRouteMeta model+      tCtx = mkTemplateRenderCtx model r meta+      tagIdx = mkTagIndex model tagPath+  renderModelTemplate model "templates/special/tagindex" $ do+    commonSplices ($ emptyRenderCtx) model meta $ fromString . toString $ tagIndexTitle tagIdx+    "ema:tag:title" ## HI.textSplice (maybe "/" (HT.unTagNode . last) $ nonEmpty tagPath)+    "ema:tag:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.tagIndexRoute tagPath)+    let parents = maybe [] (inits . init) $ nonEmpty (tagIndexPath tagIdx)+    "ema:tagcrumbs" ##+      Splices.listSplice parents "ema:each-crumb" $+        \crumb -> do+          let crumbTitle = maybe "/" (HT.unTagNode . last) . nonEmpty $ crumb+              crumbUrl = SR.siteRouteUrl model $ SR.tagIndexRoute crumb+          "ema:tagcrumb:title" ## HI.textSplice crumbTitle+          "ema:tagcrumb:url" ## HI.textSplice crumbUrl+    "ema:childTags" ##+      Splices.listSplice (tagIndexChildren tagIdx) "ema:each-childTag" $+        \childTag -> do+          let childIndex = mkTagIndex model (toList . fst $ childTag)+          "ema:childTag:title" ## HI.textSplice (tagNodesText $ fst childTag)+          "ema:childTag:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.tagIndexRoute (toList $ fst childTag))+          "ema:childTag:count-note" ## HI.textSplice (show (length $ snd childTag))+          "ema:childTag:count-tag" ## HI.textSplice (show (length $ tagIndexChildren childIndex))+    "ema:notes" ##+      Splices.listSplice (tagIndexNotes tagIdx) "ema:each-note" $+        \note ->+          PF.noteSpliceMap (withInlineCtx tCtx) model note++tagNodesText :: NonEmpty HT.TagNode -> Text+tagNodesText =+  HT.unTag . HT.constructTag
+ src/Emanote/View/TaskIndex.hs view
@@ -0,0 +1,55 @@+module Emanote.View.TaskIndex (renderTasks) where++import Data.IxSet.Typed qualified as Ix+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Map.Syntax ((##))+import Emanote.Model (Model)+import Emanote.Model.Task (Task)+import Emanote.Model.Task qualified as Task+import Emanote.Model.Type qualified as M+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute qualified as SR+import Emanote.View.Common qualified as Common+import Heist.Extra.Splices.List qualified as Splices+import Heist.Extra.Splices.Pandoc qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx (emptyRenderCtx)+import Heist.Interpreted qualified as HI+import Optics.Operators ((^.))+import Relude+import Text.Pandoc.Definition qualified as B++newtype TaskIndex = TaskIndex {unTaskIndex :: Map R.LMLRoute (NonEmpty Task)}++mkTaskIndex :: Model -> TaskIndex+mkTaskIndex model =+  TaskIndex . Map.map NE.sort $+    Map.fromListWith (<>) $+      filter (not . Task._taskChecked) (Ix.toList $ model ^. M.modelTasks) <&> \task ->+        (task ^. Task.taskRoute, one task)++renderTasks :: Model -> LByteString+renderTasks model = do+  let (defR, meta) = Common.defaultRouteMeta model+      tCtx = Common.mkTemplateRenderCtx model defR meta+      taskIndex = mkTaskIndex model+      taskGroupSplice r tasks = do+        "t:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute r)+        "t:note:title" ## Common.titleSplice tCtx (M.modelLookupTitle r model)+        "t:note:breadcrumbs" ##+          Common.routeBreadcrumbs tCtx model r+        "t:tasks" ## Splices.listSplice (toList tasks) "task" taskSplice+      taskSplice task = do+        let r = task ^. Task.taskRoute+        -- TODO: reuse note splice+        "task:description" ## Common.withInlineCtx tCtx $ \ctx ->+          Splices.pandocSplice ctx $ B.Pandoc mempty $ one $ B.Plain $ task ^. Task.taskDescription+        "note:title" ## Common.titleSplice tCtx (M.modelLookupTitle r model)+        "note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute r)++  Common.renderModelTemplate model "templates/special/tasks" $ do+    Common.commonSplices ($ emptyRenderCtx) model meta "Task Index"+    let groups =+          Map.toList (unTaskIndex taskIndex)+            & sortWith fst+    "ema:taskGroups" ## Splices.listSplice groups "taskGroup" (uncurry taskGroupSplice)
+ src/Emanote/View/Template.hs view
@@ -0,0 +1,233 @@+module Emanote.View.Template (emanoteSiteOutput, render) where++import Control.Monad.Logger (MonadLoggerIO)+import Data.Aeson.Types qualified as Aeson+import Data.List (partition)+import Data.List.NonEmpty qualified as NE+import Data.Map.Syntax ((##))+import Data.Text qualified as T+import Data.Tree.Path qualified as PathTree+import Ema qualified+import Emanote.Model (Model, ModelEma)+import Emanote.Model qualified as M+import Emanote.Model.Calendar qualified as Calendar+import Emanote.Model.Graph qualified as G+import Emanote.Model.Meta qualified as Meta+import Emanote.Model.Note qualified as MN+import Emanote.Model.SData qualified as SData+import Emanote.Model.Stork (renderStorkIndex)+import Emanote.Pandoc.BuiltinFilters (prepareNoteDoc, preparePandoc)+import Emanote.Route qualified as R+import Emanote.Route.SiteRoute (SiteRoute)+import Emanote.Route.SiteRoute qualified as SR+import Emanote.Route.SiteRoute.Class (indexRoute)+import Emanote.View.Common qualified as C+import Emanote.View.Export (renderGraphExport)+import Emanote.View.TagIndex qualified as TagIndex+import Emanote.View.TaskIndex qualified as TaskIndex+import Heist qualified as H+import Heist.Extra.Splices.List qualified as Splices+import Heist.Extra.Splices.Pandoc qualified as Splices+import Heist.Extra.Splices.Pandoc.Ctx (emptyRenderCtx)+import Heist.Extra.Splices.Tree qualified as Splices+import Heist.Interpreted qualified as HI+import Heist.Splices qualified as Heist+import Optics.Core (Prism', review)+import Optics.Operators ((.~), (^.))+import Relude+import Text.Pandoc.Builder qualified as B+import Text.Pandoc.Definition (Pandoc (..))++emanoteSiteOutput :: (MonadIO m, MonadLoggerIO m) => Prism' FilePath SiteRoute -> ModelEma -> SR.SiteRoute -> m (Ema.Asset LByteString)+emanoteSiteOutput rp model' r = do+  let model = M.withRoutePrism rp model'+  render model r <&> fmap fixStaticUrl+  where+    -- See the FIXME in more-head.tpl.+    fixStaticUrl :: LByteString -> LByteString+    fixStaticUrl s =+      case findPrefix of+        Nothing -> s+        Just prefix ->+          -- Patch the URL in CSS's "src" attribute.+          encodeUtf8+            . T.replace "src: url(_emanote-static/" ("src: url(" <> prefix <> "_emanote-static/")+            . decodeUtf8+            $ s+      where+        -- Find the "prefix" in PrefixedRoute if Emanote is used as a library.+        findPrefix :: Maybe Text+        findPrefix = do+          let indexR = toText $ review rp indexRoute+          prefix <- T.stripSuffix "-/all.html" indexR+          guard $ not $ T.null prefix+          pure prefix++render :: (MonadIO m, MonadLoggerIO m) => Model -> SR.SiteRoute -> m (Ema.Asset LByteString)+render m sr =+  let setErrorPageMeta =+        MN.noteMeta .~ SData.mergeAesons (withTemplateName "/templates/error" :| [withSiteTitle "Emanote Error"])+   in case sr of+        SR.SiteRoute_MissingR urlPath -> do+          let hereRoute = R.decodeHtmlRoute urlPath+              note404 =+                MN.missingNote hereRoute (toText urlPath)+                  & setErrorPageMeta+                  & MN.noteTitle .~ "! Missing link"+          pure $ Ema.AssetGenerated Ema.Html $ renderLmlHtml m note404+        SR.SiteRoute_AmbiguousR urlPath notes -> do+          let noteAmb =+                MN.ambiguousNoteURL urlPath notes+                  & setErrorPageMeta+                  & MN.noteTitle .~ "! Ambiguous link"+          pure $ Ema.AssetGenerated Ema.Html $ renderLmlHtml m noteAmb+        SR.SiteRoute_ResourceRoute r -> pure $ renderResourceRoute m r+        SR.SiteRoute_VirtualRoute r -> renderVirtualRoute m r++renderResourceRoute :: Model -> SR.ResourceRoute -> Ema.Asset LByteString+renderResourceRoute m = \case+  SR.ResourceRoute_LML r -> do+    case M.modelLookupNoteByRoute r m of+      Just note ->+        Ema.AssetGenerated Ema.Html $ renderLmlHtml m note+      Nothing ->+        -- This should never be reached because decodeRoute looks up the model.+        error $ "Bad route: " <> show r+  SR.ResourceRoute_StaticFile _ fpAbs ->+    Ema.AssetStatic fpAbs++renderVirtualRoute :: (MonadIO m, MonadLoggerIO m) => Model -> SR.VirtualRoute -> m (Ema.Asset LByteString)+renderVirtualRoute m = \case+  SR.VirtualRoute_TagIndex mtag ->+    pure $ Ema.AssetGenerated Ema.Html $ TagIndex.renderTagIndex m mtag+  SR.VirtualRoute_Index ->+    pure $ Ema.AssetGenerated Ema.Html $ renderSRIndex m+  SR.VirtualRoute_Export ->+    pure $ Ema.AssetGenerated Ema.Other $ renderGraphExport m+  SR.VirtualRoute_StorkIndex ->+    Ema.AssetGenerated Ema.Other <$> renderStorkIndex m+  SR.VirtualRoute_TaskIndex ->+    pure $ Ema.AssetGenerated Ema.Html $ TaskIndex.renderTasks m++renderSRIndex :: Model -> LByteString+renderSRIndex model = do+  let (r, meta) = C.defaultRouteMeta model+      tCtx = C.mkTemplateRenderCtx model r meta+  C.renderModelTemplate model "templates/special/index" $ do+    C.commonSplices ($ emptyRenderCtx) model meta "Index"+    routeTreeSplice tCtx Nothing model++loaderHead :: LByteString+loaderHead =+  "<em style='font-size: 400%; border-bottom: 1px solid; margin-bottom: 4em; '>Union mounting notebook layers; please wait ...</em>"++renderLmlHtml :: Model -> MN.Note -> LByteString+renderLmlHtml model note = do+  let r = note ^. MN.noteRoute+      meta = Meta.getEffectiveRouteMetaWith (note ^. MN.noteMeta) r model+      ctx = C.mkTemplateRenderCtx model r meta+      templateName = lookupTemplateName meta+      -- Force a doctype into the generated HTML as a workaround for Heist+      -- discarding it. See: https://github.com/srid/emanote/issues/216+      withDoctype = ("<!DOCTYPE html>\n" <>)+      withLoadingMessage =+        if M.inLiveServer model && model ^. M.modelStatus == M.Status_Loading+          then (loaderHead <>)+          else id+  withDoctype . withLoadingMessage . C.renderModelTemplate model templateName $ do+    C.commonSplices (C.withLinkInlineCtx ctx) model meta (note ^. MN.noteTitle)+    let backlinksSplice (bs :: [(R.LMLRoute, NonEmpty [B.Block])]) =+          Splices.listSplice bs "backlink" $+            \(source, contexts) -> do+              let bnote = fromMaybe (error "backlink note missing - impossible") $ M.modelLookupNoteByRoute source model+                  bmeta = Meta.getEffectiveRouteMetaWith (bnote ^. MN.noteMeta) source model+                  bctx = C.mkTemplateRenderCtx model source bmeta+              -- TODO: reuse note splice+              "backlink:note:title" ## C.titleSplice bctx (M.modelLookupTitle source model)+              "backlink:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute source)+              "backlink:note:contexts" ## Splices.listSplice (toList contexts) "context" $ \backlinkCtx -> do+                let ctxDoc :: Pandoc = preparePandoc $ Pandoc mempty $ one $ B.Div B.nullAttr backlinkCtx+                "context:body" ## C.withInlineCtx bctx $ \ctx' ->+                  Splices.pandocSplice ctx' ctxDoc+    -- Sidebar navigation+    routeTreeSplice ctx (Just r) model+    "ema:breadcrumbs" ##+      C.routeBreadcrumbs ctx model r+    -- Note stuff+    "ema:note:title" ##+      C.titleSplice ctx (note ^. MN.noteTitle)+    let modelRoute = R.ModelRoute_LML r+    "ema:note:source-path" ##+      HI.textSplice (toText . R.withLmlRoute R.encodeRoute $ r)+    "ema:note:url" ##+      HI.textSplice (SR.siteRouteUrl model . SR.lmlSiteRoute $ r)+    "ema:note:backlinks" ##+      backlinksSplice (G.modelLookupBacklinks modelRoute model)+    let (backlinksDaily, backlinksNoDaily) = partition (Calendar.isDailyNote . fst) $ G.modelLookupBacklinks modelRoute model+    "ema:note:backlinks:daily" ##+      backlinksSplice backlinksDaily+    "ema:note:backlinks:nodaily" ##+      backlinksSplice backlinksNoDaily+    let folgeAnc = G.modelFolgezettelAncestorTree modelRoute model+    "ema:note:uptree" ##+      Splices.treeSplice (const ()) folgeAnc $+        \(last -> nodeRoute) children -> do+          "node:text" ## C.titleSplice ctx $ M.modelLookupTitle nodeRoute model+          "node:url" ## HI.textSplice $ SR.siteRouteUrl model $ SR.lmlSiteRoute nodeRoute+          "tree:open" ## Heist.ifElseISplice (not . null $ children)+    "ema:note:uptree:nonempty" ## Heist.ifElseISplice (not . null $ folgeAnc)+    "ema:note:pandoc" ##+      C.withBlockCtx ctx $+        \ctx' ->+          Splices.pandocSplice ctx' (prepareNoteDoc note)++-- | If there is no 'current route', all sub-trees are marked as active/open.+routeTreeSplice ::+  Monad n =>+  C.TemplateRenderCtx n ->+  Maybe R.LMLRoute ->+  Model ->+  H.Splices (HI.Splice Identity)+routeTreeSplice tCtx mr model = do+  "ema:route-tree" ##+    ( let tree = PathTree.treeDeleteChild "index" $ model ^. M.modelNav+          getOrder tr =+            ( Meta.lookupRouteMeta @Int 0 (one "order") tr model,+              tr+            )+          getCollapsed tr =+            Meta.lookupRouteMeta @Bool True ("template" :| ["sidebar", "collapsed"]) tr model+          mkLmlRoute =+            M.resolveLmlRoute model . R.mkRouteFromSlugs+          lmlRouteSlugs = R.withLmlRoute R.unRoute+       in Splices.treeSplice (getOrder . mkLmlRoute) tree $ \(mkLmlRoute -> nodeRoute) children -> do+            "node:text" ## C.titleSplice tCtx $ M.modelLookupTitle nodeRoute model+            "node:url" ## HI.textSplice $ SR.siteRouteUrl model $ SR.lmlSiteRoute nodeRoute+            let isActiveNode = Just nodeRoute == mr+                isActiveTree =+                  -- Active tree checking is applicable only when there is an+                  -- active route (i.e., mr is a Just)+                  flip (maybe True) mr $ \r ->+                    toList (lmlRouteSlugs nodeRoute) `NE.isPrefixOf` lmlRouteSlugs r+                openTree =+                  isActiveTree -- Active tree is always open+                    || not (getCollapsed nodeRoute)+            "node:active" ## Heist.ifElseISplice isActiveNode+            "node:terminal" ## Heist.ifElseISplice (null children)+            "tree:childrenCount" ## HI.textSplice (show $ length children)+            "tree:open" ## Heist.ifElseISplice openTree+    )++lookupTemplateName :: ConvertUtf8 Text b => Aeson.Value -> b+lookupTemplateName meta =+  encodeUtf8 $ SData.lookupAeson @Text defaultTemplate ("template" :| ["name"]) meta+  where+    defaultTemplate = "templates/layouts/book"++withTemplateName :: Text -> Aeson.Value+withTemplateName =+  SData.oneAesonText (toList $ "template" :| ["name"])++withSiteTitle :: Text -> Aeson.Value+withSiteTitle =+  SData.oneAesonText (toList $ "page" :| ["siteTitle"])
+ src/Main.hs view
@@ -0,0 +1,28 @@+module Main where++import Emanote (defaultEmanoteConfig, run)+import Emanote.CLI qualified as CLI+import Emanote.Source.Dynamic (emanoteCompileTailwind)+import Main.Utf8 (withUtf8)+import Optics.Core ((.~))+import Relude+import Spec qualified+import System.Environment qualified as Env++main :: IO ()+main =+  withUtf8 $ do+    cli <- CLI.parseCli+    -- Why is test part of executable?+    -- Simply so that ghcid can reload both library and test at the same.+    -- Once Multiple Home Units for GHC is implemented, we can go back to+    -- using separate test stanza.+    if CLI.test cli+      then test+      else run (defaultEmanoteConfig cli & emanoteCompileTailwind .~ True)++test :: IO ()+test = do+  Env.withArgs+    mempty -- Discard emanote's CLI arguments+    Spec.main
+ src/Spec.hs view
@@ -0,0 +1,12 @@+module Spec (main) where++import Emanote.Model.Link.RelSpec qualified as RelSpec+import Emanote.Model.QuerySpec qualified as QuerySpec+import Relude+import Test.Hspec (hspec)++main :: IO ()+main = do+  hspec $ do+    QuerySpec.spec+    RelSpec.spec