diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -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/>.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/default/_emanote-bin/compile-css b/default/_emanote-bin/compile-css
new file mode 100644
--- /dev/null
+++ b/default/_emanote-bin/compile-css
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -xe
+
+echo "Deprecation Warning: this script should not be used anymore"
diff --git a/default/_emanote-live-server/tailwind/2.2.2/tailwind.min.css b/default/_emanote-live-server/tailwind/2.2.2/tailwind.min.css
new file mode 100644
# file too large to diff: default/_emanote-live-server/tailwind/2.2.2/tailwind.min.css
diff --git a/default/_emanote-static/emanote-logo.svg b/default/_emanote-static/emanote-logo.svg
new file mode 100644
--- /dev/null
+++ b/default/_emanote-static/emanote-logo.svg
@@ -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>
diff --git a/default/_emanote-static/fonts/Work_Sans/WorkSans-Italic-VariableFont_wght.ttf b/default/_emanote-static/fonts/Work_Sans/WorkSans-Italic-VariableFont_wght.ttf
new file mode 100644
Binary files /dev/null and b/default/_emanote-static/fonts/Work_Sans/WorkSans-Italic-VariableFont_wght.ttf differ
diff --git a/default/_emanote-static/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf b/default/_emanote-static/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf
new file mode 100644
Binary files /dev/null and b/default/_emanote-static/fonts/Work_Sans/WorkSans-VariableFont_wght.ttf differ
diff --git a/default/_emanote-static/inverted-tree.css b/default/_emanote-static/inverted-tree.css
new file mode 100644
--- /dev/null
+++ b/default/_emanote-static/inverted-tree.css
@@ -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);
+}
diff --git a/default/_emanote-static/stork/flat.css b/default/_emanote-static/stork/flat.css
new file mode 100644
--- /dev/null
+++ b/default/_emanote-static/stork/flat.css
@@ -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%);
+}
diff --git a/default/_emanote-static/stork/stork.js b/default/_emanote-static/stork/stork.js
new file mode 100644
--- /dev/null
+++ b/default/_emanote-static/stork/stork.js
@@ -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
diff --git a/default/_emanote-static/stork/stork.wasm b/default/_emanote-static/stork/stork.wasm
new file mode 100644
Binary files /dev/null and b/default/_emanote-static/stork/stork.wasm differ
diff --git a/default/favicon.svg b/default/favicon.svg
new file mode 100644
--- /dev/null
+++ b/default/favicon.svg
@@ -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>
diff --git a/default/index.md b/default/index.md
new file mode 100644
--- /dev/null
+++ b/default/index.md
@@ -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
diff --git a/default/index.yaml b/default/index.yaml
new file mode 100644
--- /dev/null
+++ b/default/index.yaml
@@ -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
diff --git a/default/templates/base.tpl b/default/templates/base.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/base.tpl
@@ -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>
diff --git a/default/templates/components/backlinks.tpl b/default/templates/components/backlinks.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/backlinks.tpl
@@ -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>
diff --git a/default/templates/components/breadcrumbs.tpl b/default/templates/components/breadcrumbs.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/breadcrumbs.tpl
@@ -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>
diff --git a/default/templates/components/checkbox-checked.tpl b/default/templates/components/checkbox-checked.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/checkbox-checked.tpl
@@ -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>
diff --git a/default/templates/components/checkbox-unchecked.tpl b/default/templates/components/checkbox-unchecked.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/checkbox-unchecked.tpl
@@ -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>
diff --git a/default/templates/components/context.tpl b/default/templates/components/context.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/context.tpl
@@ -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>
diff --git a/default/templates/components/footer.tpl b/default/templates/components/footer.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/footer.tpl
@@ -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>
diff --git a/default/templates/components/metadata.tpl b/default/templates/components/metadata.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/metadata.tpl
@@ -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>
diff --git a/default/templates/components/note-body.tpl b/default/templates/components/note-body.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/note-body.tpl
@@ -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>
diff --git a/default/templates/components/note-title.tpl b/default/templates/components/note-title.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/note-title.tpl
@@ -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>
diff --git a/default/templates/components/note-uptree-recurse.tpl b/default/templates/components/note-uptree-recurse.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/note-uptree-recurse.tpl
@@ -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>
diff --git a/default/templates/components/note-uptree.tpl b/default/templates/components/note-uptree.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/note-uptree.tpl
@@ -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>
diff --git a/default/templates/components/pandoc.tpl b/default/templates/components/pandoc.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/pandoc.tpl
@@ -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>
diff --git a/default/templates/components/sidebar-tree.tpl b/default/templates/components/sidebar-tree.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/sidebar-tree.tpl
@@ -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>
diff --git a/default/templates/components/stork/stork-icon.tpl b/default/templates/components/stork/stork-icon.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/stork/stork-icon.tpl
@@ -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>
diff --git a/default/templates/components/stork/stork-search-head.tpl b/default/templates/components/stork/stork-search-head.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/stork/stork-search-head.tpl
@@ -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>
diff --git a/default/templates/components/stork/stork-search.tpl b/default/templates/components/stork/stork-search.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/stork/stork-search.tpl
@@ -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>
diff --git a/default/templates/components/timeline.tpl b/default/templates/components/timeline.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/components/timeline.tpl
@@ -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>
diff --git a/default/templates/error.tpl b/default/templates/error.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/error.tpl
@@ -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>
diff --git a/default/templates/experimental.tpl b/default/templates/experimental.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/experimental.tpl
@@ -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>
diff --git a/default/templates/filters/embed-image.tpl b/default/templates/filters/embed-image.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/embed-image.tpl
@@ -0,0 +1,1 @@
+<img class="inline mb-3" src="${ema:url}" alt="${ema:alt}" style="width: inherit;" />
diff --git a/default/templates/filters/embed-note.tpl b/default/templates/filters/embed-note.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/embed-note.tpl
@@ -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>
diff --git a/default/templates/filters/embed-pdf.tpl b/default/templates/filters/embed-pdf.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/embed-pdf.tpl
@@ -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>
diff --git a/default/templates/filters/embed-video.tpl b/default/templates/filters/embed-video.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/embed-video.tpl
@@ -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>
diff --git a/default/templates/filters/query-default.tpl b/default/templates/filters/query-default.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/query-default.tpl
@@ -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>
diff --git a/default/templates/filters/query-timeline.tpl b/default/templates/filters/query-timeline.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/filters/query-timeline.tpl
@@ -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>
diff --git a/default/templates/hooks/after-note.tpl b/default/templates/hooks/after-note.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/hooks/after-note.tpl
@@ -0,0 +1,1 @@
+<!-- What goes in this file will appear below the note body-->
diff --git a/default/templates/hooks/before-note.tpl b/default/templates/hooks/before-note.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/hooks/before-note.tpl
@@ -0,0 +1,1 @@
+<!-- What goes in this file will appear on top of note body-->
diff --git a/default/templates/hooks/more-head.tpl b/default/templates/hooks/more-head.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/hooks/more-head.tpl
@@ -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>
diff --git a/default/templates/hooks/note-end.tpl b/default/templates/hooks/note-end.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/hooks/note-end.tpl
@@ -0,0 +1,1 @@
+<!-- What goes in this file will at the very end of the main div -->
diff --git a/default/templates/layouts/book.tpl b/default/templates/layouts/book.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/layouts/book.tpl
@@ -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>
diff --git a/default/templates/layouts/note.tpl b/default/templates/layouts/note.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/layouts/note.tpl
@@ -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>
diff --git a/default/templates/special/base.tpl b/default/templates/special/base.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/special/base.tpl
@@ -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>
diff --git a/default/templates/special/index.tpl b/default/templates/special/index.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/special/index.tpl
@@ -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>
diff --git a/default/templates/special/tagindex.tpl b/default/templates/special/tagindex.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/special/tagindex.tpl
@@ -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>
diff --git a/default/templates/special/tasks.tpl b/default/templates/special/tasks.tpl
new file mode 100644
--- /dev/null
+++ b/default/templates/special/tasks.tpl
@@ -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>
diff --git a/emanote.cabal b/emanote.cabal
new file mode 100644
--- /dev/null
+++ b/emanote.cabal
@@ -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
diff --git a/src/Emanote.hs b/src/Emanote.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote.hs
@@ -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 {..}
diff --git a/src/Emanote/CLI.hs b/src/Emanote/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/CLI.hs
@@ -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
diff --git a/src/Emanote/Model.hs b/src/Emanote/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model.hs
@@ -0,0 +1,3 @@
+module Emanote.Model (module X) where
+
+import Emanote.Model.Type as X
diff --git a/src/Emanote/Model/Calendar.hs b/src/Emanote/Model/Calendar.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Calendar.hs
@@ -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)
diff --git a/src/Emanote/Model/Graph.hs b/src/Emanote/Model/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Graph.hs
@@ -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
diff --git a/src/Emanote/Model/Link/Rel.hs b/src/Emanote/Model/Link/Rel.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Link/Rel.hs
@@ -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'
diff --git a/src/Emanote/Model/Link/RelSpec.hs b/src/Emanote/Model/Link/RelSpec.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Link/RelSpec.hs
@@ -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
diff --git a/src/Emanote/Model/Link/Resolve.hs b/src/Emanote/Model/Link/Resolve.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Link/Resolve.hs
@@ -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
diff --git a/src/Emanote/Model/Meta.hs b/src/Emanote/Model/Meta.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Meta.hs
@@ -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
diff --git a/src/Emanote/Model/Note.hs b/src/Emanote/Model/Note.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Note.hs
@@ -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
diff --git a/src/Emanote/Model/Note/Filter.hs b/src/Emanote/Model/Note/Filter.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Note/Filter.hs
@@ -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
diff --git a/src/Emanote/Model/Query.hs b/src/Emanote/Model/Query.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Query.hs
@@ -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
diff --git a/src/Emanote/Model/QuerySpec.hs b/src/Emanote/Model/QuerySpec.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/QuerySpec.hs
@@ -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)
diff --git a/src/Emanote/Model/SData.hs b/src/Emanote/Model/SData.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/SData.hs
@@ -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]
diff --git a/src/Emanote/Model/StaticFile.hs b/src/Emanote/Model/StaticFile.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/StaticFile.hs
@@ -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
diff --git a/src/Emanote/Model/Stork.hs b/src/Emanote/Model/Stork.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Stork.hs
@@ -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)
diff --git a/src/Emanote/Model/Stork/Index.hs b/src/Emanote/Model/Stork/Index.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Stork/Index.hs
@@ -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
diff --git a/src/Emanote/Model/Task.hs b/src/Emanote/Model/Task.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Task.hs
@@ -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
diff --git a/src/Emanote/Model/Title.hs b/src/Emanote/Model/Title.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Title.hs
@@ -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
diff --git a/src/Emanote/Model/Type.hs b/src/Emanote/Model/Type.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Model/Type.hs
@@ -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
diff --git a/src/Emanote/Pandoc/BuiltinFilters.hs b/src/Emanote/Pandoc/BuiltinFilters.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/BuiltinFilters.hs
@@ -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
diff --git a/src/Emanote/Pandoc/Link.hs b/src/Emanote/Pandoc/Link.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Link.hs
@@ -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
diff --git a/src/Emanote/Pandoc/Markdown/Parser.hs b/src/Emanote/Pandoc/Markdown/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Markdown/Parser.hs
@@ -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
diff --git a/src/Emanote/Pandoc/Markdown/Syntax/HashTag.hs b/src/Emanote/Pandoc/Markdown/Syntax/HashTag.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Markdown/Syntax/HashTag.hs
@@ -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 = "[];:,.?!"
diff --git a/src/Emanote/Pandoc/Markdown/Syntax/Highlight.hs b/src/Emanote/Pandoc/Markdown/Syntax/Highlight.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Markdown/Syntax/Highlight.hs
@@ -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", [])
diff --git a/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs b/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs
@@ -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.
+  _ -> ""
diff --git a/src/Emanote/Pandoc/Renderer.hs b/src/Emanote/Pandoc/Renderer.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Renderer.hs
@@ -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
+  }
diff --git a/src/Emanote/Pandoc/Renderer/Embed.hs b/src/Emanote/Pandoc/Renderer/Embed.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Renderer/Embed.hs
@@ -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"
+  ]
diff --git a/src/Emanote/Pandoc/Renderer/Query.hs b/src/Emanote/Pandoc/Renderer/Query.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Renderer/Query.hs
@@ -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)
diff --git a/src/Emanote/Pandoc/Renderer/Url.hs b/src/Emanote/Pandoc/Renderer/Url.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/Renderer/Url.hs
@@ -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]
diff --git a/src/Emanote/Prelude.hs b/src/Emanote/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Prelude.hs
@@ -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"
diff --git a/src/Emanote/Route.hs b/src/Emanote/Route.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route.hs
@@ -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
diff --git a/src/Emanote/Route/Ext.hs b/src/Emanote/Route/Ext.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/Ext.hs
@@ -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
diff --git a/src/Emanote/Route/ModelRoute.hs b/src/Emanote/Route/ModelRoute.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/ModelRoute.hs
@@ -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)
diff --git a/src/Emanote/Route/R.hs b/src/Emanote/Route/R.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/R.hs
@@ -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
diff --git a/src/Emanote/Route/SiteRoute.hs b/src/Emanote/Route/SiteRoute.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/SiteRoute.hs
@@ -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
diff --git a/src/Emanote/Route/SiteRoute/Class.hs b/src/Emanote/Route/SiteRoute/Class.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/SiteRoute/Class.hs
@@ -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
diff --git a/src/Emanote/Route/SiteRoute/Type.hs b/src/Emanote/Route/SiteRoute/Type.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Route/SiteRoute/Type.hs
@@ -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
diff --git a/src/Emanote/Source/Dynamic.hs b/src/Emanote/Source/Dynamic.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Source/Dynamic.hs
@@ -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
diff --git a/src/Emanote/Source/Loc.hs b/src/Emanote/Source/Loc.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Source/Loc.hs
@@ -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
diff --git a/src/Emanote/Source/Patch.hs b/src/Emanote/Source/Patch.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Source/Patch.hs
@@ -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
diff --git a/src/Emanote/Source/Pattern.hs b/src/Emanote/Source/Pattern.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Source/Pattern.hs
@@ -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
+    "-/**"
+  ]
diff --git a/src/Emanote/View.hs b/src/Emanote/View.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View.hs
@@ -0,0 +1,3 @@
+module Emanote.View (render) where
+
+import Emanote.View.Template (render)
diff --git a/src/Emanote/View/Common.hs b/src/Emanote/View/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/Common.hs
@@ -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)
diff --git a/src/Emanote/View/Export.hs b/src/Emanote/View/Export.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/Export.hs
@@ -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
diff --git a/src/Emanote/View/LiveServerFiles.hs b/src/Emanote/View/LiveServerFiles.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/LiveServerFiles.hs
@@ -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
diff --git a/src/Emanote/View/TagIndex.hs b/src/Emanote/View/TagIndex.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/TagIndex.hs
@@ -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
diff --git a/src/Emanote/View/TaskIndex.hs b/src/Emanote/View/TaskIndex.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/TaskIndex.hs
@@ -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)
diff --git a/src/Emanote/View/Template.hs b/src/Emanote/View/Template.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/View/Template.hs
@@ -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"])
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -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
diff --git a/src/Spec.hs b/src/Spec.hs
new file mode 100644
--- /dev/null
+++ b/src/Spec.hs
@@ -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
