diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Revision history for emanote
+
+## 1.0.0.0 (2022-12-04)
+
+- UI
+  - Index pages are no longer marked as 'experimental'
+  - Add external link icon to external links (this behaviour is customizable). [\#189](https://github.com/EmaApps/emanote/pull/189)
+  - `js.mermaid` snippet uses the "module" script tag approach, which is added to end of `<body>`. See [here](https://mermaid-js.github.io/mermaid/#/n00b-gettingStarted?id=_3-calling-the-javascript-api). 
+  - Align dates in timeline queries
+- Configuration
+  - Add `page.bodyHtml` option to inject custom HTML at the end of `<body>` tag.
+- Packaging
+  - Move tests to their own cabal component (thereby, also, unexpose them from library exposed modules)
+  - Wikilink parser is now a separate library: https://github.com/srid/commonmark-wikilink
+  - Use `heist-extra` 0.2.0.0 which switches over from `heist-emanote` to using official `heist` package.
+- Bug fixes
+  - #380 & #386: Better handling of `.org` and `.md` ambiguties. Default layer's `index.md` has been removed.
+
+## 0.8.0.0 (2022-11-03)
+
+Initial release.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -4,10 +4,14 @@
 [![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).
+Emanote emanates[^def] a structured view of your plain-text notes.
 
+[^def]: **emanate**: *(of something abstract but perceptible) issue or spread out from (a source)*
+
 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.
 
+Emanote is spiritual successor to [neuron](https://neuron.zettel.page) based on [Ema](https://ema.srid.ca).
+
 ## Installing and using
 
 https://emanote.srid.ca/start/install
@@ -18,21 +22,25 @@
 
 ## 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.
+Emanote is a Haskell software.[^licenses] Thanks to Nix, this repository is pre-configured to provide a delightful development experience with full IDE support in Visual Studio Code. 
 
-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.
+See https://srid.ca/haskell-template/start for complete instructions, but the tldr is: Install nix, enable Flakes and run `bin/run`.
 
+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
+
+## Related projects
+
+- https://github.com/squidfunk/mkdocs-material
+
+[^licenses]: Emanote uses software and resources that are licensed differently, viz.:
+    - [Logo](https://www.svgrepo.com/svg/267765/paper-plane)
+    - [Stork search](https://github.com/jameslittle230/stork/blob/master/license.txt)
+    - Various SVG icons are from [Heroicons](https://heroicons.com/)
diff --git a/default/_emanote-static/emanote-logo.svg b/default/_emanote-static/emanote-logo.svg
--- a/default/_emanote-static/emanote-logo.svg
+++ b/default/_emanote-static/emanote-logo.svg
@@ -1,64 +1,27 @@
 <?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"/>
+	 viewBox="0 0 512.003 512.003" style="enable-background:new 0 0 512.003 512.003;" xml:space="preserve">
+<polygon style="fill:#0068FF;" points="276.547,363.724 156.428,439.952 229.841,320.17 "/>
 <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"/>
+	<polygon style="fill:#10BAFC;" points="424.343,501.551 229.841,320.17 499.021,10.446 	"/>
+	<polygon style="fill:#10BAFC;" points="156.428,264.439 12.979,188.915 499.021,10.446 	"/>
 </g>
+<polygon style="fill:#0084FF;" points="156.428,264.439 156.428,439.952 229.841,320.17 499.021,10.446 "/>
+<path d="M409.804,197.83c-2.509,0-5.026-0.898-7.027-2.72c-4.269-3.883-4.582-10.491-0.699-14.76l0.164-0.181
+	c3.883-4.268,10.493-4.582,14.76-0.698c4.27,3.883,4.582,10.491,0.699,14.76l-0.164,0.181
+	C415.475,196.679,412.644,197.83,409.804,197.83z"/>
+<path d="M318.886,298.027c-2.505,0-5.018-0.895-7.019-2.71c-4.274-3.879-4.594-10.487-0.716-14.759l68.202-75.162
+	c3.877-4.274,10.487-4.594,14.759-0.717c4.274,3.878,4.594,10.487,0.717,14.759L326.627,294.6
+	C324.565,296.871,321.731,298.027,318.886,298.027z"/>
+<path d="M503.407,0.963c-2.579-1.193-5.448-1.251-7.987-0.32c0-0.002,0-0.003-0.001-0.005L9.377,179.106
+	c-3.876,1.423-6.551,4.996-6.823,9.116c-0.275,4.12,1.904,8.015,5.558,9.939l137.867,72.584v169.208
+	c0,4.679,3.111,8.787,7.612,10.057c0.94,0.265,1.895,0.393,2.837,0.393c1.992,0,3.923-0.589,5.586-1.644
+	c0.004,0.006,0.008,0.01,0.014,0.017l113.3-71.9l141.89,132.319c1.966,1.833,4.523,2.807,7.127,2.807c1.15,0,2.31-0.19,3.431-0.58
+	c3.66-1.273,6.315-4.467,6.898-8.299l74.678-491.105C510.051,7.42,507.628,2.915,503.407,0.963z M434.512,45.265l-279.03,206.867
+	l-116.834-61.51L434.512,45.265z M166.877,269.699l261.434-193.82L221.954,313.315c-0.386,0.444-0.718,0.915-1.016,1.401
+	c-0.002-0.003-0.004-0.004-0.007-0.007l-54.055,88.198V269.699H166.877z M188.896,406.972l43.17-70.439l27.465,25.613
+	L188.896,406.972z M416.988,480.407L244.345,319.408l238.93-274.914L416.988,480.407z"/>
 <g>
 </g>
 <g>
diff --git a/default/favicon.svg b/default/favicon.svg
--- a/default/favicon.svg
+++ b/default/favicon.svg
@@ -1,64 +1,27 @@
 <?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"/>
+	 viewBox="0 0 512.003 512.003" style="enable-background:new 0 0 512.003 512.003;" xml:space="preserve">
+<polygon style="fill:#0068FF;" points="276.547,363.724 156.428,439.952 229.841,320.17 "/>
 <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"/>
+	<polygon style="fill:#10BAFC;" points="424.343,501.551 229.841,320.17 499.021,10.446 	"/>
+	<polygon style="fill:#10BAFC;" points="156.428,264.439 12.979,188.915 499.021,10.446 	"/>
 </g>
+<polygon style="fill:#0084FF;" points="156.428,264.439 156.428,439.952 229.841,320.17 499.021,10.446 "/>
+<path d="M409.804,197.83c-2.509,0-5.026-0.898-7.027-2.72c-4.269-3.883-4.582-10.491-0.699-14.76l0.164-0.181
+	c3.883-4.268,10.493-4.582,14.76-0.698c4.27,3.883,4.582,10.491,0.699,14.76l-0.164,0.181
+	C415.475,196.679,412.644,197.83,409.804,197.83z"/>
+<path d="M318.886,298.027c-2.505,0-5.018-0.895-7.019-2.71c-4.274-3.879-4.594-10.487-0.716-14.759l68.202-75.162
+	c3.877-4.274,10.487-4.594,14.759-0.717c4.274,3.878,4.594,10.487,0.717,14.759L326.627,294.6
+	C324.565,296.871,321.731,298.027,318.886,298.027z"/>
+<path d="M503.407,0.963c-2.579-1.193-5.448-1.251-7.987-0.32c0-0.002,0-0.003-0.001-0.005L9.377,179.106
+	c-3.876,1.423-6.551,4.996-6.823,9.116c-0.275,4.12,1.904,8.015,5.558,9.939l137.867,72.584v169.208
+	c0,4.679,3.111,8.787,7.612,10.057c0.94,0.265,1.895,0.393,2.837,0.393c1.992,0,3.923-0.589,5.586-1.644
+	c0.004,0.006,0.008,0.01,0.014,0.017l113.3-71.9l141.89,132.319c1.966,1.833,4.523,2.807,7.127,2.807c1.15,0,2.31-0.19,3.431-0.58
+	c3.66-1.273,6.315-4.467,6.898-8.299l74.678-491.105C510.051,7.42,507.628,2.915,503.407,0.963z M434.512,45.265l-279.03,206.867
+	l-116.834-61.51L434.512,45.265z M166.877,269.699l261.434-193.82L221.954,313.315c-0.386,0.444-0.718,0.915-1.016,1.401
+	c-0.002-0.003-0.004-0.004-0.007-0.007l-54.055,88.198V269.699H166.877z M188.896,406.972l43.17-70.439l27.465,25.613
+	L188.896,406.972z M416.988,480.407L244.345,319.408l238.93-274.914L416.988,480.407z"/>
 <g>
 </g>
 <g>
diff --git a/default/index.md b/default/index.md
deleted file mode 100644
--- a/default/index.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# 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
--- a/default/index.yaml
+++ b/default/index.yaml
@@ -81,6 +81,11 @@
   headHtml: |
     <meta name="generator" content="Emanote" />
 
+  # Put anything that should below the <body> of these routes here:
+  # You can reference other metadata keys using <snippet var=".." />
+  # Or use the JS behaviour library below.
+  bodyHtml: |
+
 # Builtin JS behaviour library. Use them in `page.headHtml` of your .yaml or .md
 # frontmatter.
 js:
@@ -106,9 +111,9 @@
   # 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});
+    <script type="module">
+      import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
+      mermaid.initialize({ startOnLoad: false });
       mermaid.init(undefined,document.querySelectorAll(".mermaid"));
     </script>
   mathjax: |
diff --git a/default/templates/base.tpl b/default/templates/base.tpl
--- a/default/templates/base.tpl
+++ b/default/templates/base.tpl
@@ -23,13 +23,53 @@
   </ema:metadata>
   <tailwindCssShim />
 
-  <!-- Heist error element -->
   <style>
+    /* Heist error element */
     strong.error {
       color: lightcoral;
       font-size: 90%;
       font-family: monospace;
     }
+
+    /* External link icon */
+    a[data-linkicon=""]::after {
+      content: ""
+    }
+
+    a[data-linkicon=none]::after {
+      content: ""
+    }
+
+    a[data-linkicon="external"]::after {
+      content: url('data:image/svg+xml,\
+      <svg xmlns="http://www.w3.org/2000/svg" height="0.7em" viewBox="0 0 20 20"> \
+        <g style="stroke:gray;stroke-width:1"> \
+          <line x1="5" y1="5" x2="5" y2="14" /> \
+          <line x1="14" y1="9" x2="14" y2="14" /> \
+          <line x1="5" y1="14" x2="14" y2="14" /> \
+          <line x1="5" y1="5" x2="9" y2="5"  /> \
+          <line x1="10" y1="2" x2="17" y2="2"  /> \
+          <line x1="17" y1="2" x2="17" y2="9" /> \
+          <line x1="10" y1="9" x2="17" y2="2" style="stroke-width:1.0" /> \
+        </g> \
+      </svg>');
+    }
+
+    a[data-linkicon="external"][href^="mailto:"]::after {
+      content: url('data:image/svg+xml,\
+        <svg \
+          xmlns="http://www.w3.org/2000/svg" \
+          height="0.7em" \
+          fill="none" \
+          viewBox="0 0 24 24" \
+          stroke="gray" \
+          stroke-width="2"> \
+          <path \
+            stroke-linecap="round" \
+            stroke-linejoin="round" \
+            d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> \
+        </svg>');
+    }
   </style>
   <apply template="/templates/hooks/more-head" />
 
@@ -46,6 +86,9 @@
 <body class="${bodyClass}">
   <body-main />
   <apply template="components/stork/stork-search" />
+  <ema:metadata>
+    <snippet var="page.bodyHtml" />
+  </ema:metadata>
 </body>
 
 </html>
diff --git a/default/templates/components/pandoc.tpl b/default/templates/components/pandoc.tpl
--- a/default/templates/components/pandoc.tpl
+++ b/default/templates/components/pandoc.tpl
diff --git a/default/templates/filters/query-timeline.tpl b/default/templates/filters/query-timeline.tpl
--- a/default/templates/filters/query-timeline.tpl
+++ b/default/templates/filters/query-timeline.tpl
@@ -7,9 +7,9 @@
     <ul>
       <result>
         <li>
-          <div class="flex flex-wrap my-2">
+          <div class="flex flex-wrap flex-row my-2">
             <ema:note:metadata>
-              <span data-nosnippet class="mr-2 text-right text-gray-600">
+              <span data-nosnippet class="mr-2 text-right font-mono text-gray-600">
                 <value var="date" />
               </span>
             </ema:note:metadata>
diff --git a/default/templates/special/base.tpl b/default/templates/special/base.tpl
--- a/default/templates/special/base.tpl
+++ b/default/templates/special/base.tpl
@@ -5,9 +5,6 @@
         <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>
diff --git a/emanote.cabal b/emanote.cabal
--- a/emanote.cabal
+++ b/emanote.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.4
 name:               emanote
-version:            0.8.0.0
+version:            1.0.0.0
 license:            AGPL-3.0-only
 copyright:          2022 Sridhar Ratnakumar
 maintainer:         srid@srid.ca
@@ -13,6 +13,7 @@
 -- A URL where users can report bugs.
 bug-reports:        https://github.com/EmaApps/emanote/issues
 extra-source-files:
+  CHANGELOG.md
   LICENSE
   README.md
 
@@ -27,10 +28,13 @@
   _emanote-static/stork/stork.js
   _emanote-static/stork/stork.wasm
   favicon.svg
-  index.md
   index.yaml
   templates/**/*.tpl
 
+flag ghcid
+  default: False
+  manual:  True
+
 common haskell-common
   ghc-options:
     -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns
@@ -84,7 +88,6 @@
   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
@@ -94,20 +97,21 @@
     , blaze-html
     , bytestring
     , commonmark
-    , commonmark-extensions
+    , commonmark-extensions  <0.2.3.3
     , commonmark-pandoc
     , commonmark-simple
+    , commonmark-wikilink
     , containers
     , data-default
     , dependent-sum
     , directory
-    , ema                    >=0.8.2
+    , ema                    >=0.9
     , filepath
     , filepattern
     , fsnotify
     , hedgehog
-    , heist-emanote          >=1.2.1
-    , heist-extra
+    , heist                  >=1.1.1.0
+    , heist-extra            >=0.2.0.0
     , hspec
     , hspec-hedgehog
     , ixset-typed            >=0.5.1.0
@@ -149,10 +153,9 @@
     , xmlhtml
     , yaml
 
-  hs-source-dirs:   src
-
 library
   import:          library-common
+  hs-source-dirs:  src
   exposed-modules:
     Emanote
     Emanote.CLI
@@ -160,13 +163,11 @@
     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
@@ -175,11 +176,11 @@
     Emanote.Model.Title
     Emanote.Model.Type
     Emanote.Pandoc.BuiltinFilters
+    Emanote.Pandoc.ExternalLink
     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
@@ -203,12 +204,38 @@
     Emanote.View.TagIndex
     Emanote.View.TaskIndex
     Emanote.View.Template
-    Paths_emanote
-    Spec
 
+  other-modules:   Paths_emanote
   autogen-modules: Paths_emanote
 
+-- To profile: ghc-options: -fexternal-interpreter -prof -fprof-auto -rtsopts
 executable emanote
-  import:      library-common
-  main-is:     Main.hs
-  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  import:         library-common
+  hs-source-dirs: exe
+  main-is:        Main.hs
+  ghc-options:    -threaded -rtsopts -with-rtsopts=-N
+
+  if flag(ghcid)
+    hs-source-dirs: src
+
+  else
+    build-depends: emanote
+
+test-suite test
+  import:         library-common 
+  type:           exitcode-stdio-1.0
+  hs-source-dirs: test
+  main-is:        Spec.hs
+  build-depends:
+    , hspec
+    , relude
+
+  if flag(ghcid)
+    hs-source-dirs: src
+
+  else
+    build-depends: emanote
+    other-modules:
+      Emanote.Model.Link.RelSpec
+      Emanote.Model.QuerySpec
+      Emanote.Pandoc.ExternalLinkSpec
diff --git a/exe/Main.hs b/exe/Main.hs
new file mode 100644
--- /dev/null
+++ b/exe/Main.hs
@@ -0,0 +1,15 @@
+module Main where
+
+import Emanote qualified (defaultEmanoteConfig, run)
+import Emanote.CLI qualified as CLI
+import Emanote.Source.Dynamic (emanoteCompileTailwind)
+import Main.Utf8 (withUtf8)
+import Optics.Core ((.~))
+import Relude
+
+main :: IO ()
+main =
+  withUtf8 $ do
+    cli <- CLI.parseCli
+    let config = Emanote.defaultEmanoteConfig cli & emanoteCompileTailwind .~ True
+    Emanote.run config
diff --git a/src/Emanote/CLI.hs b/src/Emanote/CLI.hs
--- a/src/Emanote/CLI.hs
+++ b/src/Emanote/CLI.hs
@@ -19,7 +19,6 @@
 
 data Cli = Cli
   { layers :: NonEmpty FilePath,
-    test :: Bool,
     allowBrokenLinks :: Bool,
     cmd :: Cmd
   }
@@ -31,7 +30,6 @@
 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
diff --git a/src/Emanote/Model/Graph.hs b/src/Emanote/Model/Graph.hs
--- a/src/Emanote/Model/Graph.hs
+++ b/src/Emanote/Model/Graph.hs
@@ -1,5 +1,6 @@
 module Emanote.Model.Graph where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 import Data.IxSet.Typed ((@+), (@=))
 import Data.IxSet.Typed qualified as Ix
 import Data.Map.Strict qualified as Map
@@ -11,7 +12,6 @@
 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 ((^.))
diff --git a/src/Emanote/Model/Link/Rel.hs b/src/Emanote/Model/Link/Rel.hs
--- a/src/Emanote/Model/Link/Rel.hs
+++ b/src/Emanote/Model/Link/Rel.hs
@@ -3,6 +3,7 @@
 
 module Emanote.Model.Link.Rel where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 import Data.Aeson (ToJSON)
 import Data.IxSet.Typed (Indexable (..), IxSet, ixFun, ixList)
 import Data.IxSet.Typed qualified as Ix
@@ -10,7 +11,6 @@
 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
@@ -74,7 +74,8 @@
 
 unresolvedRelsTo :: ModelRoute -> [UnresolvedRelTarget]
 unresolvedRelsTo r =
-  let wls = either (R.withLmlRoute WL.allowedWikiLinks) WL.allowedWikiLinks $ R.modelRouteCase r
+  let allowedWikiLinks = WL.allowedWikiLinks . R.unRoute
+      wls = either (R.withLmlRoute allowedWikiLinks) allowedWikiLinks $ R.modelRouteCase r
    in (URTWikiLink <$> toList wls)
         <> [URTResource r]
 
diff --git a/src/Emanote/Model/Link/RelSpec.hs b/src/Emanote/Model/Link/RelSpec.hs
deleted file mode 100644
--- a/src/Emanote/Model/Link/RelSpec.hs
+++ /dev/null
@@ -1,32 +0,0 @@
-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
--- a/src/Emanote/Model/Link/Resolve.hs
+++ b/src/Emanote/Model/Link/Resolve.hs
@@ -1,11 +1,11 @@
 module Emanote.Model.Link.Resolve where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 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
diff --git a/src/Emanote/Model/Note.hs b/src/Emanote/Model/Note.hs
--- a/src/Emanote/Model/Note.hs
+++ b/src/Emanote/Model/Note.hs
@@ -5,6 +5,8 @@
 
 module Emanote.Model.Note where
 
+import Commonmark.Extensions.WikiLink (plainify)
+import Commonmark.Extensions.WikiLink qualified as WL
 import Control.Monad.Logger (MonadLogger)
 import Control.Monad.Writer (MonadWriter (tell), WriterT, runWriterT)
 import Data.Aeson qualified as Aeson
@@ -19,8 +21,6 @@
 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)
@@ -87,7 +87,7 @@
     routeSelfRefs :: R.LMLRoute -> NonEmpty WL.WikiLink
     routeSelfRefs =
       fmap snd
-        . R.withLmlRoute WL.allowedWikiLinks
+        . R.withLmlRoute (WL.allowedWikiLinks . R.unRoute)
 
 noteAncestors :: Note -> [RAncestor]
 noteAncestors =
diff --git a/src/Emanote/Model/QuerySpec.hs b/src/Emanote/Model/QuerySpec.hs
deleted file mode 100644
--- a/src/Emanote/Model/QuerySpec.hs
+++ /dev/null
@@ -1,19 +0,0 @@
-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/StaticFile.hs b/src/Emanote/Model/StaticFile.hs
--- a/src/Emanote/Model/StaticFile.hs
+++ b/src/Emanote/Model/StaticFile.hs
@@ -3,10 +3,10 @@
 
 module Emanote.Model.StaticFile where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 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
@@ -34,6 +34,7 @@
 staticFileSelfRefs =
   fmap snd
     . WL.allowedWikiLinks
+    . R.unRoute
     . _staticFileRoute
 
 makeLenses ''StaticFile
diff --git a/src/Emanote/Model/Title.hs b/src/Emanote/Model/Title.hs
--- a/src/Emanote/Model/Title.hs
+++ b/src/Emanote/Model/Title.hs
@@ -15,8 +15,8 @@
   )
 where
 
+import Commonmark.Extensions.WikiLink (plainify)
 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
diff --git a/src/Emanote/Model/Type.hs b/src/Emanote/Model/Type.hs
--- a/src/Emanote/Model/Type.hs
+++ b/src/Emanote/Model/Type.hs
@@ -4,6 +4,7 @@
 
 module Emanote.Model.Type where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 import Data.Aeson qualified as Aeson
 import Data.Default (Default (def))
 import Data.IxSet.Typed ((@=))
@@ -32,7 +33,6 @@
 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
@@ -109,7 +109,8 @@
   modelNotes
     %~ ( Ix.updateIx r note
            -- Insert folder placeholder automatically for ancestor paths
-           >>> flip (foldr injectAncestor) (N.noteAncestors note)
+           >>> injectAncestors (N.noteAncestors note)
+           >>> dropRedundantAncestor r
        )
     >>> modelRels
       %~ updateIxMulti r (Rel.noteRels note)
@@ -120,6 +121,41 @@
   where
     r = note ^. N.noteRoute
 
+-- | If a placeholder route was added already, but the newly added note is a
+-- non-Markdown, removce that markdown placeholder route.
+dropRedundantAncestor :: LMLRoute -> IxNote -> IxNote
+dropRedundantAncestor recentNoteRoute ns =
+  case recentNoteRoute of
+    R.LMLRoute_Md _ -> ns
+    R.LMLRoute_Org r ->
+      case N.lookupNotesByRoute (R.LMLRoute_Md $ coerce r) ns of
+        Nothing -> ns
+        Just placeholderNote -> Ix.deleteIx (placeholderNote ^. N.noteRoute) ns
+
+injectAncestors :: [N.RAncestor] -> IxNote -> IxNote
+injectAncestors ancs' =
+  case nonEmpty ancs' of
+    Nothing ->
+      injectRoot
+    Just ancs ->
+      flip (foldr injectAncestor) ancs
+
+-- 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
+restoreAncestor :: Maybe N.RAncestor -> IxNote -> IxNote
+restoreAncestor =
+  maybe injectRoot injectAncestor
+
+injectRoot :: IxNote -> IxNote
+injectRoot ns =
+  case resolveLmlRouteIfExists ns idxR of
+    Just _ -> ns
+    Nothing ->
+      let r = R.defaultLmlRoute idxR
+       in Ix.updateIx r (N.ancestorPlaceholderNote $ coerce idxR) ns
+  where
+    idxR = R.indexRoute
+
 injectAncestor :: N.RAncestor -> IxNote -> IxNote
 injectAncestor (N.unRAncestor -> folderR) ns =
   case resolveLmlRouteIfExists ns folderR of
@@ -133,9 +169,7 @@
   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
+             >>> restoreAncestor (N.RAncestor <$> mFolderR)
          )
     & modelRels
       %~ deleteIxMulti k
@@ -150,8 +184,6 @@
       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 ::
@@ -260,6 +292,7 @@
 resolveLmlRoute model r =
   fromMaybe (R.defaultLmlRoute r) $ resolveLmlRouteIfExists (model ^. modelNotes) r
 
+-- | Lookup a LML route, returning the less popular LML format if there are ambiguities.
 resolveLmlRouteIfExists :: forall ext. IxNote -> R ext -> Maybe LMLRoute
 resolveLmlRouteIfExists notes r = do
   -- TODO: Refactor using `[minBound..maxBound] :: [LML]`
diff --git a/src/Emanote/Pandoc/BuiltinFilters.hs b/src/Emanote/Pandoc/BuiltinFilters.hs
--- a/src/Emanote/Pandoc/BuiltinFilters.hs
+++ b/src/Emanote/Pandoc/BuiltinFilters.hs
@@ -5,6 +5,7 @@
 where
 
 import Emanote.Model.Note qualified as N
+import Emanote.Pandoc.ExternalLink (setExternalLinkIcon)
 import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT
 import Emanote.Route (encodeRoute)
 import Emanote.Route.SiteRoute.Type (encodeTagIndexR)
@@ -22,6 +23,7 @@
 preparePandoc =
   linkifyInlineTags
     >>> fixEmojiFontFamily
+    >>> setExternalLinkIcon
 
 -- HashTag.hs generates a Span for inline tags.
 -- Here, we must link them to the special tag index page.
@@ -40,7 +42,7 @@
     tagUrl =
       toText . encodeRoute . encodeTagIndexR . toList . HT.deconstructTag
 
--- Undo font-familly on emoji spans, so the browser uses an emoji font.
+-- Undo font-family 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 =
diff --git a/src/Emanote/Pandoc/ExternalLink.hs b/src/Emanote/Pandoc/ExternalLink.hs
new file mode 100644
--- /dev/null
+++ b/src/Emanote/Pandoc/ExternalLink.hs
@@ -0,0 +1,53 @@
+module Emanote.Pandoc.ExternalLink
+  ( setExternalLinkIcon,
+  )
+where
+
+import Relude
+import Text.Pandoc.Definition qualified as B
+import Text.Pandoc.Walk qualified as W
+import Text.Parsec qualified as P
+import Text.Parsec.Char qualified as PC
+
+-- Add a data-linkicon=external attribute to external links that contain some
+-- text in their description, provided that they do not already have a
+-- data-linkicon attribute.
+setExternalLinkIcon :: W.Walkable B.Inline b => b -> b
+setExternalLinkIcon =
+  W.walk $ \case
+    B.Link (id', classes, attrs) inlines (url, title)
+      | hasURIScheme url && containsText inlines ->
+          let showLinkIconAttr = ("data-linkicon", "external")
+              newAttrs = insert attrs showLinkIconAttr
+           in B.Link (id', classes, newAttrs) inlines (url, title)
+    x -> x
+  where
+    -- Inserts an element in a key-value list if the element's key is not
+    -- already in the list.
+    insert :: Eq a => [(a, b)] -> (a, b) -> [(a, b)]
+    insert as a
+      | fst a `elem` (fst <$> as) = as
+      | otherwise = a : as
+    -- Checks whether the given text begins with an RFC 3986 compliant URI
+    -- scheme.
+    hasURIScheme :: Text -> Bool
+    hasURIScheme =
+      isRight . P.parse schemeP ""
+      where
+        schemeP = do
+          c <- PC.letter
+          cs <- P.many $ PC.alphaNum P.<|> P.oneOf ".-+"
+          void $ PC.char ':'
+          return (c : cs)
+    -- Checks whether a list of inlines contains a (perhaps nested) "textual
+    -- element", understood as a Pandoc `Str`, `Code` or `Math`.
+    containsText :: [B.Inline] -> Bool
+    containsText =
+      getAny
+        . W.query
+          ( \case
+              B.Str _ -> Any True
+              B.Code _ _ -> Any True
+              B.Math _ _ -> Any True
+              _ -> Any False
+          )
diff --git a/src/Emanote/Pandoc/Link.hs b/src/Emanote/Pandoc/Link.hs
--- a/src/Emanote/Pandoc/Link.hs
+++ b/src/Emanote/Pandoc/Link.hs
@@ -1,6 +1,6 @@
 module Emanote.Pandoc.Link where
 
-import Emanote.Pandoc.Markdown.Syntax.WikiLink qualified as WL
+import Commonmark.Extensions.WikiLink qualified as WL
 import Relude
 import Text.Pandoc.Definition qualified as B
 
diff --git a/src/Emanote/Pandoc/Markdown/Parser.hs b/src/Emanote/Pandoc/Markdown/Parser.hs
--- a/src/Emanote/Pandoc/Markdown/Parser.hs
+++ b/src/Emanote/Pandoc/Markdown/Parser.hs
@@ -5,11 +5,11 @@
 
 import Commonmark qualified as CM
 import Commonmark.Extensions qualified as CE
+import Commonmark.Extensions.WikiLink qualified as WL
 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)
 
diff --git a/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs b/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs
deleted file mode 100644
--- a/src/Emanote/Pandoc/Markdown/Syntax/WikiLink.hs
+++ /dev/null
@@ -1,275 +0,0 @@
-{-# 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/Embed.hs b/src/Emanote/Pandoc/Renderer/Embed.hs
--- a/src/Emanote/Pandoc/Renderer/Embed.hs
+++ b/src/Emanote/Pandoc/Renderer/Embed.hs
@@ -1,5 +1,6 @@
 module Emanote.Pandoc.Renderer.Embed where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 import Data.Map.Syntax ((##))
 import Data.Text qualified as T
 import Emanote.Model (Model)
@@ -10,7 +11,6 @@
 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
diff --git a/src/Emanote/Pandoc/Renderer/Url.hs b/src/Emanote/Pandoc/Renderer/Url.hs
--- a/src/Emanote/Pandoc/Renderer/Url.hs
+++ b/src/Emanote/Pandoc/Renderer/Url.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Commonmark.Extensions.WikiLink qualified as WL
 import Data.Text qualified as T
 import Emanote.Model (Model)
 import Emanote.Model qualified as M
@@ -13,7 +14,6 @@
 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
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
--- a/src/Spec.hs
+++ /dev/null
@@ -1,12 +0,0 @@
-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
diff --git a/test/Emanote/Model/Link/RelSpec.hs b/test/Emanote/Model/Link/RelSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/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/test/Emanote/Model/QuerySpec.hs b/test/Emanote/Model/QuerySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/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/test/Emanote/Pandoc/ExternalLinkSpec.hs b/test/Emanote/Pandoc/ExternalLinkSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Emanote/Pandoc/ExternalLinkSpec.hs
@@ -0,0 +1,56 @@
+module Emanote.Pandoc.ExternalLinkSpec where
+
+import Emanote.Pandoc.ExternalLink (setExternalLinkIcon)
+import Emanote.Pandoc.Markdown.Parser (parseMarkdown)
+import Hedgehog
+import Relude
+import Test.Hspec
+import Test.Hspec.Hedgehog (hedgehog)
+import Text.Pandoc.Definition (Inline (..))
+import Text.Pandoc.Walk qualified as W
+
+spec :: Spec
+spec = do
+  describe "setExternalLinkIcon" $ do
+    it "respects user-specified data-linkicon attribute" . hedgehog $ do
+      getDataLinkIconAttrs "[test](https://www.test.com){data-linkicon=abc}" === Right ["abc"]
+      getDataLinkIconAttrs "[test](https://www.test.com){data-linkicon=\"\"}" === Right [""]
+      getDataLinkIconAttrs "[[foo]]{data-linkicon=external}" === Right ["external"]
+      getDataLinkIconAttrs "[abc](x/y/z){data-linkicon=none}" === Right ["none"]
+    it "does not add attribute if link is internal" . hedgehog $ do
+      getDataLinkIconAttrs "[[foo]]" === Right []
+      getDataLinkIconAttrs "[abc](x/y/z)" === Right []
+      getDataLinkIconAttrs "![[image.jpg]]" === Right []
+      getDataLinkIconAttrs "[![](path/to/image.png)](foo/bar/baz)" === Right []
+      getDataLinkIconAttrs "[[bar|baz]]" === Right []
+      getDataLinkIconAttrs "[`abc`](./test.txt)" === Right []
+      getDataLinkIconAttrs "[$$abc$$](/just/test#ing)" === Right []
+      getDataLinkIconAttrs "#tag" === Right []
+      getDataLinkIconAttrs "[[abc|def]]" === Right []
+    it "adds attribute if link is external and its description contains text, code or math" . hedgehog $ do
+      getDataLinkIconAttrs "[Emanote](https://github.com/EmaApps/emanote)" === Right ["external"]
+      getDataLinkIconAttrs "[`text`](http://somehost:1234/test)" === Right ["external"]
+      getDataLinkIconAttrs "[$$E=mc^2$$](ssh://user@host.abc/~/path/)" === Right ["external"]
+      getDataLinkIconAttrs "[![[picture.png]] ~~**`code`**~~](scheme://host:port/path?query)" === Right ["external"]
+      getDataLinkIconAttrs "[==$$e^{i \\pi} + 1 = 0$$== ![[image.svg]]](git://host.xz/path/to/repo.git/)" === Right ["external"]
+      getDataLinkIconAttrs "example@example.com" === Right ["external"]
+      getDataLinkIconAttrs "https://www.test.gov" === Right ["external"]
+      getDataLinkIconAttrs "[**![[image.jpg]] *qwerty* ![[image.jpg]]**](doi:10.1000/182)" === Right ["external"]
+      getDataLinkIconAttrs "[:video_game:](bolo://hostname/)" === Right ["external"]
+    it "does not add attribute if link description contains no text, code or math" . hedgehog $ do
+      getDataLinkIconAttrs "[](http://nothing.interesting.here)" === Right []
+      getDataLinkIconAttrs "[![[image.png]]](https://www.example.com)" === Right []
+      getDataLinkIconAttrs "[==*_**~~![[img.png]]~~**_*==](http://something.info)" === Right []
+
+-- | Extract "data-linkicon" attributes present in the given Markdown content.
+getDataLinkIconAttrs :: Text -> Either Text [Text]
+getDataLinkIconAttrs =
+  fmap (W.query $ getLinkAttr "data-linkicon") . parseEmanoteMarkdown
+  where
+    parseEmanoteMarkdown = fmap (setExternalLinkIcon . snd) . parseMarkdown "<test>"
+
+getLinkAttr :: Text -> Inline -> [Text]
+getLinkAttr name (Link (_, _, attrs) _ (_, _)) =
+  snd <$> filter ((== name) . fst) attrs
+getLinkAttr _ _ =
+  []
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
