Repository: tlienart/Franklin.jl
Pull request #461 (2020-04-22 20:29:54)

[wip] Taxonomy
cc: @Wikunia this is based on your PR #458 ; I've tested it briefly and it seems ok but I need to write fuller tests for it. 

In the mean time please have  a  look and let me know  what you think! and thanks again for looking into this :) taxonomy and chronological pagination are the two big missing features remaining before we can start copying everyone's themes 

* [x] basic functionality ok
* [x] tests
  * [x] adding, removing tags
  * [x] tag page generation and deletion
  * [x] dict inversion
* [x] make tag line customisable (hfun) 
  * [x] test

**Note** @Wikunia, btw I think you were completely right to build the page with `head` and `foot`, it's of course much simpler, and someone could just do  `{{ispage /tag/*}} ... {{end}}` to toggle a specific layout for those pages :) :+1: 


Patch:
diff --git a/Project.toml b/Project.toml
index d672f4d0..e79a8ac8 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,7 +1,7 @@
 name = "Franklin"
 uuid = "713c75ef-9fc9-4b05-94a9-213340da978e"
 authors = ["Thibaut Lienart <tlienart@me.com>"]
-version = "0.7.5"
+version = "0.7.6"
 
 [deps]
 Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
diff --git a/src/Franklin.jl b/src/Franklin.jl
index 80935f69..3fe3b3e0 100644
--- a/src/Franklin.jl
+++ b/src/Franklin.jl
@@ -142,6 +142,7 @@ include("eval/literate.jl")
 include("converter/markdown/blocks.jl")
 include("converter/markdown/utils.jl")
 include("converter/markdown/mddefs.jl")
+include("converter/markdown/tags.jl")
 include("converter/markdown/md.jl")
 # > latex
 include("converter/latex/latex.jl")
diff --git a/src/converter/html/functions.jl b/src/converter/html/functions.jl
index 7feb8d37..1743a7e0 100644
--- a/src/converter/html/functions.jl
+++ b/src/converter/html/functions.jl
@@ -186,15 +186,38 @@ end
 
 
 """
-HTML_FUNCTIONS
+$(SIGNATURES)
 
-Dictionary for special html functions. They can take two variables, the first
-one `π` refers to the arguments passed to the function, the second one `ν`
-refers to the page variables (i.e. the context) available to the function.
+H-Function of the form `{{list tag}}`.
 """
-const HTML_FUNCTIONS = LittleDict{String, Function}(
-    "fill"   => hfun_fill,
-    "insert" => hfun_insert,
-    "href"   => hfun_href,
-    "toc"    => hfun_toc,
-    )
+function hfun_list(params::Vector{String})::String
+    if length(params) != 1
+        throw(HTMLFunctionError("I found a {{list ...}} block and expected 1 " *
+                                "parameter but got $(length(params)). Verify."))
+    end
+    tag = params[1]
+    TAG_PAGES = globvar("fd_tag_pages")
+
+    c = IOBuffer()
+    write(c, "<h1>Tag: $tag</h1>")
+    write(c, "<ul>")
+
+    rpaths = TAG_PAGES[tag]
+    sorter(p) = begin
+        pvd = pagevar(p, "date")
+        if isnothing(pvd)
+            return Date(Dates.unix2datetime(stat(p * ".md").ctime))
+        end
+        return pvd
+    end
+    sort!(rpaths, by=sorter, rev=true)
+    for rpath in rpaths
+        title = pagevar(rpath, "title")
+        if isnothing(title)
+            title = "/$rpath/"
+        end
+        write(c, "<li><a href=\"/$rpath/\">$title</a></li>")
+    end
+    write(c, "</ul>")
+    return String(take!(c))
+end
diff --git a/src/converter/html/html.jl b/src/converter/html/html.jl
index bf9ded22..86e9f178 100644
--- a/src/converter/html/html.jl
+++ b/src/converter/html/html.jl
@@ -28,7 +28,7 @@ function convert_html(hs::AS; isoptim::Bool=false)::String
 
     isempty(fhs) && return ""
 
-    if !isempty(GLOBAL_VARS["prepath"].first) && isoptim
+    if !isempty(globvar("prepath")) && isoptim
         fhs = fix_links(fhs)
     end
 
diff --git a/src/converter/markdown/md.jl b/src/converter/markdown/md.jl
index a22cd612..0ab9edb9 100644
--- a/src/converter/markdown/md.jl
+++ b/src/converter/markdown/md.jl
@@ -26,6 +26,7 @@ function convert_md(mds::AS,
                     isinternal::Bool=false,
                     isconfig::Bool=false,
                     has_mddefs::Bool=true,
+                    pagevar::Bool=false, # whether it's called from pagevar
                     )::String
     # instantiate page dictionaries
     isrecursive || isinternal || set_page_env()
@@ -105,7 +106,7 @@ function convert_md(mds::AS,
     # ------------------------------------------------------------------------
     #> 4. Page variable definition (mddefs), also if in config, update lxdefs
     if has_mddefs
-        process_mddefs(blocks, isconfig)
+        process_mddefs(blocks, isconfig, pagevar)
     end
 
     #> 4.b if config, update global lxdefs as well
diff --git a/src/converter/markdown/mddefs.jl b/src/converter/markdown/mddefs.jl
index f4339580..fe553ec6 100644
--- a/src/converter/markdown/mddefs.jl
+++ b/src/converter/markdown/mddefs.jl
@@ -10,7 +10,8 @@ Depending on `isconfig`, will update `GLOBAL_VARS` or `LOCAL_VARS`.
 * `isconfig`:  whether the file being processed is the config file
                 (--> global page variables)
 """
-function process_mddefs(blocks::Vector{OCBlock}, isconfig::Bool)::Nothing
+function process_mddefs(blocks::Vector{OCBlock}, isconfig::Bool,
+                        pagevar::Bool=false)::Nothing
     # Find all markdown definitions (MD_DEF) blocks
     mddefs = filter(β -> (β.name == :MD_DEF), blocks)
     # empty container for the assignments
@@ -29,13 +30,66 @@ function process_mddefs(blocks::Vector{OCBlock}, isconfig::Bool)::Nothing
         push!(assignments, (String(vname) => String(vdef)))
     end
     # if in config file, update `GLOBAL_VARS` and `GLOBAL_LXDEFS`
+    rpath = splitext(locvar("fd_rpath"))[1]
     if isconfig
         set_vars!(GLOBAL_VARS, assignments)
+        return nothing
+    end
+    set_vars!(LOCAL_VARS, assignments)
+    rpath = splitext(locvar("fd_rpath"))[1]
+
+    # is hascode or hasmath set explicitly? if not and if the global
+    # autocode and/or automath are left to true, then check here to see
+    # if there are any blocks and set the variable automatically (#419)
+    acm = filter(p -> p.first in ("hascode", "hasmath"), assignments)
+    if globvar("autocode") &&
+            (isempty(acm) || !any(p -> p.first == "hascode", acm))
+        # check and set hascode automatically
+        code = any(b -> startswith(string(b.name), "CODE_BLOCK"), blocks)
+        set_var!(LOCAL_VARS, "hascode", code)
+    end
+    if globvar("automath") &&
+            (isempty(acm) || !any(p -> p.first == "hasmath", acm))
+        # check and set hasmath automatically
+        math = any(b -> b.name in MATH_BLOCKS_NAMES, blocks)
+        set_var!(LOCAL_VARS, "hasmath", math)
+    end
+
+    # copy the page vars to ALL_PAGE_VARS so that they can be accessed
+    # by other pages via `pagevar`.
+    ALL_PAGE_VARS[rpath] = deepcopy(LOCAL_VARS)
+
+    # TAGS
+    tags = Set(unique(locvar("tags")))
+    # Cases:
+    # 1. that page did not have tags
+    #   a. tags is empty --> do nothing
+    #   b. tags is not empty register them and update all
+    # 2. that page did have tags
+    #   a. tags are unchanged --> do nothing
+    #   b. check which ones change and update those
+    refresh_tags = tags
+    PAGE_TAGS    = globvar("fd_page_tags")
+    if isnothing(PAGE_TAGS)
+        isempty(tags) && return nothing
+        set_var!(GLOBAL_VARS, "fd_page_tags", DTAG((rpath => tags,)))
+    elseif !haskey(PAGE_TAGS, rpath)
+        isempty(tags) && return nothing
+        PAGE_TAGS[rpath] = tags
     else
-        set_vars!(LOCAL_VARS, assignments)
-        # copy the page vars to ALL_PAGE_VARS so that they can be accessed
-        # by other pages via `pagevar`.
-        ALL_PAGE_VARS[splitext(locvar("fd_rpath"))[1]] = deepcopy(LOCAL_VARS)
+        old_tags = PAGE_TAGS[rpath]
+        refresh_tags = setdiff(old_tags, tags)
+        isempty(refresh_tags) && return nothing
+        if isempty(tags)
+            delete!(PAGE_TAGS, rpath)
+        else
+            PAGE_TAGS[rpath] = tags
+        end
     end
+    # In the full pass each page is processed first (without generating tag
+    # pages) and then, when all tags have been gathered, generate_tag_pages
+    # is called (see `fd_fullpass`).
+    # During the serve loop, we want to trigger on page change.
+    pagevar || FD_ENV[:FULL_PASS] || generate_tag_pages(refresh_tags)
     return nothing
 end
diff --git a/src/converter/markdown/tags.jl b/src/converter/markdown/tags.jl
new file mode 100644
index 00000000..d34d1da8
--- /dev/null
+++ b/src/converter/markdown/tags.jl
@@ -0,0 +1,51 @@
+"""
+$(SIGNATURES)
+
+Create and/or update `__site/tag` folders/files. It takes the set of tags to
+refresh (in which case only the pages associated to those tags will be
+refreshed) or an empty set in which case all tags will be (re)-generated.
+"""
+function generate_tag_pages(refresh_tags=Set{String}())::Nothing
+    # filter out pages that may not exist anymore
+    PAGE_TAGS = globvar("fd_page_tags")
+    isnothing(PAGE_TAGS) && return nothing
+    for rpath in keys(PAGE_TAGS)
+        isfile(rpath * ".md") || delete!(PAGE_TAGS, rpath)
+    end
+    isempty(PAGE_TAGS) && return nothing
+
+    # Get the dictionary tag -> [rp1, rp2...]
+    TAG_PAGES = invert_dict(PAGE_TAGS)
+    # store it in globvar
+    set_var!(GLOBAL_VARS, "fd_tag_pages", TAG_PAGES)
+    all_tags = collect(keys(TAG_PAGES))
+
+    # check if the tag dir is there
+    isdir(path(:tag)) || mkpath(path(:tag))
+    # cleanup any page that may still be there but shouldn't
+    for dirname in setdiff(readdir(path(:tag)), all_tags)
+        rm(joinpath(path(:tag), dirname), recursive=true)
+    end
+    update_tags = isempty(refresh_tags) ? all_tags : refresh_tags
+    write_tag_pages(update_tags)
+    return nothing
+end
+
+function write_tag_pages(update_tags)::Nothing
+    layout_key  = ifelse(FD_ENV[:STRUCTURE] < v"0.2", :src_html, :layout)
+    layout      = path(layout_key)
+    head        = read(joinpath(layout, "head.html"),      String)
+    pg_foot     = read(joinpath(layout, "page_foot.html"), String)
+    foot        = read(joinpath(layout, "foot.html"),      String)
+    for tag in update_tags
+        # check if `tag/$tag` exists otherwise create it
+        dir = joinpath(path(:tag), tag)
+        isdir(dir) || mkdir(dir)
+        # assemble the page using the hfun list (which can be overwritten
+        # by the user)
+        page = build_page(head, "{{list $tag}}", pg_foot, foot)
+        # write the processed page.
+        write(joinpath(dir, "index.html"), convert_html(page))
+    end
+    return nothing
+end
diff --git a/src/eval/run.jl b/src/eval/run.jl
index 27377382..767bbe02 100644
--- a/src/eval/run.jl
+++ b/src/eval/run.jl
@@ -12,7 +12,7 @@ Consumes a string with Julia code, returns a vector of expression(s).
 Note: this function was adapted from the `parse_input` function from Weave.jl.
 """
 function parse_code(code::AS)
-    exs = Expr[]
+    exs = Any[] # Expr, Symbol or Any Julia core value
     n   = sizeof(code)
     pos = 1
     while pos ≤ n
@@ -83,6 +83,8 @@ function run_code(mod::Module, code::AS, out_path::AS;
     end
     # if last bit ends with `;` return nothing (no display)
     code[end] == ';' && return nothing
+    # if last line is a Julia value return
+    isa(exs[end], Expr) || return res
     # if last line of the code is a `show`
     if length(exs[end].args) > 1 && exs[end].args[1] == Symbol("@show")
         return nothing
diff --git a/src/manager/file_utils.jl b/src/manager/file_utils.jl
index 81309dad..26071cdb 100644
--- a/src/manager/file_utils.jl
+++ b/src/manager/file_utils.jl
@@ -227,7 +227,7 @@ $(SIGNATURES)
 Convenience function to assemble the html out of its parts.
 """
 build_page(head::String, content::String, pgfoot::String, foot::String) =
-    "$head\n<div class=\"$(locvar("div_content"))\">\n$content\n$pgfoot\n</div>\n$foot"
+    head * html_div(locvar("div_content"), content * pgfoot) * foot
 
 
 """
diff --git a/src/manager/franklin.jl b/src/manager/franklin.jl
index 9d746e84..9efa80cd 100644
--- a/src/manager/franklin.jl
+++ b/src/manager/franklin.jl
@@ -3,7 +3,17 @@ $SIGNATURES
 
 Clear the environment dictionaries.
 """
-clear_dicts() = empty!.((GLOBAL_LXDEFS, GLOBAL_VARS, LOCAL_VARS, ALL_PAGE_VARS))
+function clear_dicts()
+    recursive_empty!.((GLOBAL_LXDEFS, GLOBAL_VARS, LOCAL_VARS, ALL_PAGE_VARS))
+end
+
+function recursive_empty!(d::AbstractDict)
+    for (k, v) in d
+        v isa AbstractDict && recursive_empty!(d[k])
+    end
+    empty!(d)
+end
+
 
 """
 $(SIGNATURES)
@@ -242,6 +252,9 @@ function fd_fullpass(watched_files::NamedTuple; clear::Bool=false,
 
     # generate RSS if appropriate
     globvar("generate_rss") && rss_generator()
+    # generate tags if appropriate
+    generate_tag_pages()
+    # done
     FD_ENV[:FULL_PASS] = false
     # return -1 if any page
     return ifelse(s<0, -1, 0)
diff --git a/src/utils/misc.jl b/src/utils/misc.jl
index d28fe0a2..1de2ae31 100644
--- a/src/utils/misc.jl
+++ b/src/utils/misc.jl
@@ -232,3 +232,25 @@ function check_ping(ipaddr)
     opt = ifelse(Sys.iswindows(), "-n", "-c")
     return success(`ping $opt 1 -t 1 $ipaddr`)
 end
+
+"""
+    invert_dict(dict)
+
+Invert a dictionary i.e transform a=>[1,2],b=>[1] to 1=>[a,b], 2=>[a]
+"""
+function invert_dict(dict)
+    fe = first(dict)
+    TK = typeof(fe.first)
+    TV = Vector{eltype(fe.second)}
+    inv_dict = LittleDict{TK, Vector{eltype(dict).types[1]}}()
+    for (key, val) in dict
+        for nkey in val
+            if haskey(inv_dict, nkey)
+                push!(inv_dict[nkey], key)
+            else
+                inv_dict[nkey] = [key]
+            end
+        end
+    end
+    return inv_dict
+end
diff --git a/src/utils/paths.jl b/src/utils/paths.jl
index 8da86ff3..ceadb9ca 100644
--- a/src/utils/paths.jl
+++ b/src/utils/paths.jl
@@ -57,6 +57,7 @@ function set_paths!()::LittleDict{Symbol,String}
         PATHS[:libs]      = joinpath(PATHS[:folder], "libs")
         PATHS[:assets]    = joinpath(PATHS[:folder], "assets")
         PATHS[:literate]  = joinpath(PATHS[:folder], "scripts")
+        PATHS[:tag]       = joinpath(PATHS[:pub],    "tag")
     else
         PATHS[:folder]   = normpath(FOLDER_PATH[])
         PATHS[:site]     = joinpath(PATHS[:folder], "__site")    # mandatory
@@ -65,6 +66,7 @@ function set_paths!()::LittleDict{Symbol,String}
         PATHS[:layout]   = joinpath(PATHS[:folder], "_layout")   # mandatory
         PATHS[:libs]     = joinpath(PATHS[:folder], "_libs")     # mandatory
         PATHS[:literate] = joinpath(PATHS[:folder], "_literate") # optional
+        PATHS[:tag]      = joinpath(PATHS[:site],   "tag")
     end
 
     return PATHS
diff --git a/src/utils/vars.jl b/src/utils/vars.jl
index 4f767665..41a352bf 100644
--- a/src/utils/vars.jl
+++ b/src/utils/vars.jl
@@ -1,3 +1,6 @@
+const DTAG  = LittleDict{String,Set{String}}
+const DTAGI = LittleDict{String,Vector{String}}
+
 """
 Dictionary of variables accessible to all pages. Used as an initialiser for
 `LOCAL_VARS` and modified by `config.md`.
@@ -31,6 +34,9 @@ const GLOBAL_VARS_DEFAULT = [
     # auto detection of code / math (see hasmath/hascode)
     "autocode"         => Pair(true, (Bool,)),
     "automath"         => Pair(true, (Bool,)),
+    # keep track page=>tags and tag=>pages
+    "fd_page_tags"     => Pair(nothing, (DTAG,  Nothing)),
+    "fd_tag_pages"     => Pair(nothing, (DTAGI, Nothing)),
     ]
 
 """
@@ -52,13 +58,14 @@ const LOCAL_VARS = PageVars()
 
 const LOCAL_VARS_DEFAULT = [
     # General
-    "title"         => Pair(nothing, (String, Nothing)),
-    "hasmath"       => Pair(true,    (Bool,)),
-    "hascode"       => Pair(false,   (Bool,)),
-    "date"          => Pair(Date(1), (String, Date, Nothing)),
-    "lang"          => Pair("julia", (String,)), # default lang indented code
-    "reflinks"      => Pair(true,    (Bool,)),   # are there reflinks?
-    "indented_code" => Pair(false,   (Bool,)),   # support indented code?
+    "title"         => Pair(nothing,    (String, Nothing)),
+    "hasmath"       => Pair(true,       (Bool,)),
+    "hascode"       => Pair(false,      (Bool,)),
+    "date"          => Pair(Date(1),    (String, Date, Nothing)),
+    "lang"          => Pair("julia",    (String,)), # default lang indented code
+    "reflinks"      => Pair(true,       (Bool,)),   # are there reflinks?
+    "indented_code" => Pair(false,      (Bool,)),   # support indented code?
+    "tags"          => Pair(String[],   (Vector{String},)),
     # -----------------
     # TABLE OF CONTENTS
     "mintoclevel" => Pair(1,  (Int,)), # set to 2 to ignore h1
@@ -146,6 +153,7 @@ end
 """
 Dict to keep track of all pages and their vars. Each key is a relative path
 to a page, values are PageVars.
+The keys don't have the file extension so `"blog/pg1 => PageVars"`.
 """
 const ALL_PAGE_VARS = Dict{String,PageVars}()
 
@@ -179,7 +187,7 @@ function pagevar(rpath::AS, name::Union{Symbol,String})
         # set temporary cur path (so that defs go to the right place)
         set_cur_rpath(fpath, isrelative=true)
         # effectively we only care about the mddefs
-        convert_md(read(fpath, String))
+        convert_md(read(fpath, String), pagevar=true)
         # re-set the cur path to what it was before
         set_cur_rpath(bk_path, isrelative=true)
         # re-set local vars
@@ -195,7 +203,6 @@ Keep track of the names declared in the Utils module.
 """
 const UTILS_NAMES = Vector{String}()
 
-
 """
 Keep track of seen headers. The key is the refstring, the value contains the
 title, the occurence number for the first appearance of that title and the
@@ -213,7 +220,6 @@ Empties `PAGE_HEADERS`.
     return nothing
 end
 
-
 """
 PAGE_FNREFS
 


Test patch:
diff --git a/test/converter/html.jl b/test/converter/html/html.jl
similarity index 99%
rename from test/converter/html.jl
rename to test/converter/html/html.jl
index 4f8e261f..d49518fe 100644
--- a/test/converter/html.jl
+++ b/test/converter/html/html.jl
@@ -1,4 +1,6 @@
 @testset "Cblock+h-fill" begin
+    F.def_GLOBAL_VARS!()
+    F.def_LOCAL_VARS!()
     F.set_vars!(F.LOCAL_VARS, ["v1"=>"\"INPUT1\"", "b1"=>"false", "b2"=>"true"])
     hs = raw"""
         Some text then {{ fill v1 }} and
diff --git a/test/converter/html2.jl b/test/converter/html/html2.jl
similarity index 100%
rename from test/converter/html2.jl
rename to test/converter/html/html2.jl
diff --git a/test/converter/html_for.jl b/test/converter/html/html_for.jl
similarity index 100%
rename from test/converter/html_for.jl
rename to test/converter/html/html_for.jl
diff --git a/test/converter/lx_input.jl b/test/converter/lx/input.jl
similarity index 100%
rename from test/converter/lx_input.jl
rename to test/converter/lx/input.jl
diff --git a/test/converter/lx_simple.jl b/test/converter/lx/simple.jl
similarity index 100%
rename from test/converter/lx_simple.jl
rename to test/converter/lx/simple.jl
diff --git a/test/converter/lx_simple_fs2.jl b/test/converter/lx/simple_fs2.jl
similarity index 100%
rename from test/converter/lx_simple_fs2.jl
rename to test/converter/lx/simple_fs2.jl
diff --git a/test/converter/hyperref.jl b/test/converter/md/hyperref.jl
similarity index 100%
rename from test/converter/hyperref.jl
rename to test/converter/md/hyperref.jl
diff --git a/test/converter/markdown.jl b/test/converter/md/markdown.jl
similarity index 100%
rename from test/converter/markdown.jl
rename to test/converter/md/markdown.jl
diff --git a/test/converter/markdown2.jl b/test/converter/md/markdown2.jl
similarity index 100%
rename from test/converter/markdown2.jl
rename to test/converter/md/markdown2.jl
diff --git a/test/converter/markdown3.jl b/test/converter/md/markdown3.jl
similarity index 100%
rename from test/converter/markdown3.jl
rename to test/converter/md/markdown3.jl
diff --git a/test/converter/markdown4.jl b/test/converter/md/markdown4.jl
similarity index 100%
rename from test/converter/markdown4.jl
rename to test/converter/md/markdown4.jl
diff --git a/test/converter/md_defs.jl b/test/converter/md/md_defs.jl
similarity index 100%
rename from test/converter/md_defs.jl
rename to test/converter/md/md_defs.jl
diff --git a/test/converter/md/tags.jl b/test/converter/md/tags.jl
new file mode 100644
index 00000000..190b42dc
--- /dev/null
+++ b/test/converter/md/tags.jl
@@ -0,0 +1,105 @@
+@testset "tagpages" begin
+    fs2()
+    write(joinpath("_layout", "head.html"), "")
+    write(joinpath("_layout", "foot.html"), "")
+    write(joinpath("_layout", "page_foot.html"), "")
+    write("config.md", "")
+    write("index.md", """
+        Hello
+        """)
+    F.def_GLOBAL_VARS!()
+    F.def_LOCAL_VARS!()
+    write("pg1.md", "")
+    write("pg2.md", "")
+    F.set_var!(F.GLOBAL_VARS, "fd_page_tags", F.DTAG(("pg1" => Set(["aa", "bb"]),)))
+    F.globvar("fd_page_tags")["pg2"] = Set(["bb", "cc"])
+
+    @test Set(keys(F.globvar("fd_page_tags"))) == Set(["pg1", "pg2"])
+    @test Set(union(values(F.globvar("fd_page_tags"))...)) == Set(["aa", "bb", "cc"])
+
+    F.generate_tag_pages()
+
+    @test F.globvar("fd_tag_pages")["aa"] == ["pg1"]
+    @test F.globvar("fd_tag_pages")["bb"] == ["pg1","pg2"]
+    @test F.globvar("fd_tag_pages")["cc"] == ["pg2"]
+
+    @test isdir(F.path(:tag))
+    @test isfile(joinpath(F.path(:tag), "aa", "index.html"))
+    @test isfile(joinpath(F.path(:tag), "bb", "index.html"))
+    @test isfile(joinpath(F.path(:tag), "cc", "index.html"))
+
+    F.clear_dicts()
+end
+
+# ======= INTEGRATION ============
+
+@testset "tags" begin
+    fs2()
+    write(joinpath("_layout", "head.html"), "")
+    write(joinpath("_layout", "foot.html"), "")
+    write(joinpath("_layout", "page_foot.html"), "")
+    write("config.md", "")
+    write("index.md", """
+        Hello
+        """)
+    F.def_GLOBAL_VARS!()
+    F.def_LOCAL_VARS!()
+    isdir("blog") && rm("blog", recursive=true)
+    mkdir("blog")
+    write(joinpath("blog", "pg1.md"), """
+        @def tags = ["aa", "bb", "cc"]
+        @def date = Date(2000, 01, 01)
+        @def title = "Page 1"
+        """)
+    write(joinpath("blog", "pg2.md"), """
+        @def tags = ["bb", "cc"]
+        @def date = Date(2001, 01, 01)
+        @def title = "Page 2"
+        """)
+    write(joinpath("blog", "pg3.md"), """
+        @def tags = ["bb", "cc", "ee", "dd"]
+        @def date = Date(2002, 01, 01)
+        @def title = "Page 3"
+        """)
+    write(joinpath("blog", "pg4.md"), """
+        @def tags = ["aa", "dd", "ee"]
+        @def date = Date(2003, 01, 01)
+        @def title = "Page 4"
+        """)
+    serve(clear=true, single=true, cleanup=false, nomess=true)
+    @test isdir(joinpath("__site", "tag"))
+    for tag in ("aa", "bb", "cc", "dd", "ee")
+        local p
+        p = joinpath("__site", "tag", tag, "index.html")
+        @test isfile(p)
+    end
+
+    p = joinpath("__site", "tag", "aa", "index.html")
+    c = read(p, String)
+    @test isapproxstr(c, """
+        <div class="franklin-content">
+        <h1>Tag: aa</h1>
+        <ul>
+          <li><a href="/blog/pg4/">Page 4</a></li>
+          <li><a href="/blog/pg1/">Page 1</a></li>
+        </ul>
+        </div>
+        """)
+
+    # Now remove some pages; we use bash commands so that they're blocking
+    success(`rm $(joinpath("blog", "pg4.md"))`)
+    success(`rm $(joinpath("blog", "pg3.md"))`)
+
+    serve(clear=true, single=true, nomess=true)
+    c = read(p, String)
+    @test isapproxstr(c, """
+        <div class="franklin-content">
+        <h1>Tag: aa</h1>
+        <ul>
+          <li><a href="/blog/pg1/">Page 1</a></li>
+        </ul>
+        </div>
+        """)
+
+    F.clear_dicts()
+end
diff --git a/test/eval/run.jl b/test/eval/run.jl
index 0f80771a..5c9cf7d9 100644
--- a/test/eval/run.jl
+++ b/test/eval/run.jl
@@ -76,3 +76,26 @@ end
             """
     end
 end
+
+@testset "i462" begin
+    s = raw"""
+       A
+       ```julia:ex
+       1 # hide
+       ```
+       \show{ex}
+       B""" |> fd2html_td
+    @test isapproxstr(s, """
+        <p>A <pre><code class="plaintext">1</code></pre> B</p>
+        """)
+    s = raw"""
+       A
+       ```julia:ex
+       "hello" # hide
+       ```
+       \show{ex}
+       B""" |> fd2html_td
+    @test isapproxstr(s, """
+        <p>A <pre><code class="plaintext">"hello"</code></pre> B</p>
+        """)
+end
diff --git a/test/manager/utils.jl b/test/manager/utils.jl
index 8026f092..6c4d9962 100644
--- a/test/manager/utils.jl
+++ b/test/manager/utils.jl
@@ -65,7 +65,14 @@ end
     F.write_page(F.PATHS[:src], "index.md", head, pg_foot, foot, out_file)
 
     @test isfile(out_file)
-    @test read(out_file, String) == "head\n<div class=\"franklin-content\">\n<p>blah blah</p>\n\n\npage_foot\n</div>\nfoot  Stefan Zweig"
+    @test isapproxstr(read(out_file, String), """
+        head
+        <div class=\"franklin-content\">
+        <p>blah blah</p>
+        page_foot
+        </div>
+        foot  Stefan Zweig
+        """)
 end
 
 temp_config = joinpath(F.PATHS[:src], "config.md")
@@ -99,7 +106,6 @@ rm(temp_index2)
 
     @test issubset(["css", "libs", "index.html"], readdir(F.PATHS[:folder]))
     @test issubset(["temp.html", "temp.rnd"], readdir(F.PATHS[:pub]))
-    @test all(split(read(joinpath(F.PATHS[:folder], "index.html"), String)) .== split("<!doctype html>\n<html lang=\"en-UK\">\n\t<head>\n\t\t<meta charset=\"UTF-8\">\n\t\t<link rel=\"stylesheet\" href=\"/css/main.css\">\n\t</head>\n<body>\n<div class=\"franklin-content\">\n<p>blah blah</p>\n\n<div class=\"page-foot\">\n\t\t<div class=\"copyright\">\n\t\t\t\t&copy; All rights reserved.\n\t\t</div>\n</div>\n</div>\n    </body>\n</html>"))
 end
 
 @testset "Err procfile" begin
diff --git a/test/manager/utils_fs2.jl b/test/manager/utils_fs2.jl
index 283546d1..2e315445 100644
--- a/test/manager/utils_fs2.jl
+++ b/test/manager/utils_fs2.jl
@@ -66,7 +66,8 @@ end
     F.write_page(F.PATHS[:folder], "index.md", head, pg_foot, foot, out_file)
 
     @test isfile(out_file)
-    @test read(out_file, String) == "head\n<div class=\"franklin-content\">\n<p>blah blah</p>\n\n\npage_foot\n</div>\nfoot  Stefan Zweig"
+    @test isapproxstr(read(out_file, String), """
+        head\n<div class=\"franklin-content\">\n<p>blah blah</p>\n\n\npage_foot\n</div>\nfoot  Stefan Zweig""")
 end
 
 temp_config = joinpath(F.PATHS[:folder], "config.md")
@@ -102,8 +103,6 @@ rm(temp_index2)
     @test issubset(["temp", "temp.rnd"], readdir(F.PATHS[:site]))
     @test issubset(["index.html"], readdir(joinpath(F.PATHS[:site], "temp")))
     @test issubset(["index.html"], readdir(joinpath(F.PATHS[:site], "blah")))
-
-    @test all(split(read(joinpath(F.PATHS[:site], "index.html"), String)) .== split("<!doctype html>\n<html lang=\"en-UK\">\n\t<head>\n\t\t<meta charset=\"UTF-8\">\n\t\t<link rel=\"stylesheet\" href=\"/css/main.css\">\n\t</head>\n<body>\n<div class=\"franklin-content\">\n<p>blah blah</p>\n\n<div class=\"page-foot\">\n\t\t<div class=\"copyright\">\n\t\t\t\t&copy; All rights reserved.\n\t\t</div>\n</div>\n</div>\n    </body>\n</html>"))
 end
 
 @testset "Err procfile" begin
diff --git a/test/runtests.jl b/test/runtests.jl
index 7cda0941..a4c5dfe8 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -54,22 +54,23 @@ include("eval/extras.jl")
 
 # CONVERTER folder
 println("CONVERTER/MD")
-include("converter/markdown.jl")
-include("converter/markdown2.jl")
-include("converter/markdown3.jl")
-include("converter/markdown4.jl")
-include("converter/hyperref.jl")
-include("converter/md_defs.jl")
+include("converter/md/markdown.jl")
+include("converter/md/markdown2.jl")
+include("converter/md/markdown3.jl")
+include("converter/md/markdown4.jl")
+include("converter/md/hyperref.jl")
+include("converter/md/md_defs.jl")
+include("converter/md/tags.jl")
 println("🍺")
 println("CONVERTER/HTML")
-include("converter/html.jl")
-include("converter/html2.jl")
-include("converter/html_for.jl")
+include("converter/html/html.jl")
+include("converter/html/html2.jl")
+include("converter/html/html_for.jl")
 println("🍺")
 println("CONVERTER/LX")
-include("converter/lx_input.jl")
-include("converter/lx_simple.jl")
-include("converter/lx_simple_fs2.jl")
+include("converter/lx/input.jl")
+include("converter/lx/simple.jl")
+include("converter/lx/simple_fs2.jl")
 println("🍺")
 
 fs2()
diff --git a/test/utils/folder_structure.jl b/test/utils/folder_structure.jl
index c8ff551f..305e671d 100644
--- a/test/utils/folder_structure.jl
+++ b/test/utils/folder_structure.jl
@@ -1,13 +1,13 @@
 @testset "set_paths!" begin
     root = F.FOLDER_PATH[] = mktempdir()
     empty!(F.PATHS); F.FD_ENV[:STRUCTURE] = v"0.1"; F.set_paths!()
-    @test Set(keys(F.PATHS)) == Set([:folder, :src, :src_pages, :src_css, :src_html, :pub, :css, :libs, :assets, :literate])
+    @test Set(keys(F.PATHS)) == Set([:folder, :src, :src_pages, :src_css, :src_html, :pub, :css, :libs, :assets, :literate, :tag])
     @test F.PATHS[:folder] == root
     @test F.PATHS[:src_pages] == joinpath(F.PATHS[:src], "pages")
 
     # ================================================
     empty!(F.PATHS); F.FD_ENV[:STRUCTURE] = v"0.2"; F.set_paths!()
-    @test Set(keys(F.PATHS)) == Set([:folder, :assets, :css, :layout, :libs, :literate, :site])
+    @test Set(keys(F.PATHS)) == Set([:folder, :assets, :css, :layout, :libs, :literate, :site, :tag])
     @test F.PATHS[:folder]   == root
     @test F.PATHS[:site]     == joinpath(root, "__site")
     @test F.PATHS[:assets]   == joinpath(root, "_assets")
@@ -15,6 +15,7 @@
     @test F.PATHS[:layout]   == joinpath(root, "_layout")
     @test F.PATHS[:libs]     == joinpath(root, "_libs")
     @test F.PATHS[:literate] == joinpath(root, "_literate")
+    @test F.PATHS[:tag]      == joinpath(root, "__site", "tag")
 
     # ================================================
     # reset the structure to legacy for further tests
diff --git a/test/utils/misc.jl b/test/utils/misc.jl
index 2df789a1..ca76b638 100644
--- a/test/utils/misc.jl
+++ b/test/utils/misc.jl
@@ -130,7 +130,7 @@ end
     s = raw"""
     @def date_format = "e, d u Y"
     @def date_months = ["janvier", "février", "mars", "avril", "mai", "juin",
-                        "juillet", "août", "septembre", "octobre", "novembre", 
+                        "juillet", "août", "septembre", "octobre", "novembre",
                         "décembre"];
     @def date_shortmonths = ["janv","févr","mars","avril","mai","juin",
                                 "juil","août","sept","oct","nov","déc"];
@@ -148,7 +148,7 @@ end
     s = raw"""
     @def date_format = "d u Y"
     @def date_months = ["janvier", "février", "mars", "avril", "mai", "juin",
-                        "juillet", "août", "septembre", "octobre", "novembre", 
+                        "juillet", "août", "septembre", "octobre", "novembre",
                         "décembre"];
     ```julia:ex2
     #hideall
@@ -188,3 +188,17 @@ end
     """ |> fd2html_td
     @test isapproxstr(s, "1996年1月1日　月曜日") # japanese and unicode
 end
+
+@testset "dict-inv" begin
+    d = F.LittleDict(
+        "blog/pg1" => ["aa", "bb", "cc"],
+        "blog/pg2" => ["cc", "bb"],
+        "blog/pg3" => ["aa", "ee"],
+        )
+    id = F.invert_dict(d)
+    @test id isa F.LittleDict{String,Vector{String}}
+    @test Set(collect(keys(id))) == Set(collect(vcat(values(d)...)))
+    @test Set(collect(keys(d))) == Set(collect(vcat(values(id)...)))
+    @test Set(id["aa"]) == Set(["blog/pg1", "blog/pg3"])
+    @test Set(id["ee"]) == Set(["blog/pg3"])
+end