Samhain 21

Converter
- type selection
- subdir conversion
- htm extension

Gemini
- index.gmi
- topics and latest
- gmi.atom feed

Add pull (http(s)) operation
- peers.pub.conf and peers.priv.conf

HTML5 format & fixes by Novaburst
Phony target (thanks Gergely)

May

Basic unit renamed from Note to Text.
New modular text-parser, internal to Logarion, for generic notation parsing. The default input format is now a much plainer text.
Logarion created texts have part of the UUID in filename.
Logarion's index re-written in Messagepack format. Removed `indices` command. They are generated during `convert`.

git-svn-id: file:///srv/svn/repo/kosuzu/trunk@2 eb64cd80-c68d-6f47-b6a3-0ada418499da
This commit is contained in:
fox
2021-11-03 22:29:57 +00:00
parent ba5cfde0d3
commit d89a2c061d
56 changed files with 1256 additions and 1583 deletions

57
cli/atom.ml Normal file
View File

@@ -0,0 +1,57 @@
let esc = Converter.Html.esc
let element tag content = "<" ^ tag ^ ">" ^ content ^ "</" ^ tag ^ ">"
let opt_element tag_name content =
if content <> ""
then element tag_name content
else ""
module P = Parsers.Plain_text.Make (Converter.Html)
let id txt = "<id>urn:uuid:" ^ Logarion.(Id.to_string txt.Text.uuid) ^ "</id>"
let title text = "<title>" ^ esc text.Logarion.Text.title ^ "</title>"
let authors text =
let u acc addr = acc ^ element "uri" (Uri.to_string addr) in
let open Logarion in
let fn txt a =
a ^ "<author>" ^ (opt_element "name" @@ esc txt.Person.name)
^ (List.fold_left u "" txt.Person.addresses)
^ "</author>" in
Person.Set.fold fn text.Text.authors ""
let updated txt = let open Logarion in
"<updated>"^ Date.(txt.Text.date |> listing |> rfc_string) ^"</updated>"
let htm_entry base_url text =
let open Logarion in
let u = Text.short_id text in
"<entry><link rel=\"alternate\" href=\"" ^ base_url ^ "/" ^ u ^ ".htm\" />"
^ title text ^ id text ^ updated text ^ authors text
^ (opt_element "summary" @@ esc @@ Text.str "abstract" text)
^ String_set.fold (fun elt a -> a ^ "<category term=\"" ^ esc elt ^ "\"/>") (Text.set "topics" text) ""
^ "<content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">"
^ P.of_string text.body ""
^ "</div></content></entry>\n"
let gmi_entry base_url text =
let open Logarion in
let u = Text.short_id text in
"<entry><link rel=\"alternate\" href=\"" ^ base_url ^ "/" ^ u ^ ".gmi\" />"
^ title text ^ id text ^ updated text ^ authors text
^ (opt_element "summary" @@ esc @@ Text.str "abstract" text)
^ String_set.fold (fun elt a -> a ^ "<category term=\"" ^ elt ^ "\"/>") (Text.set "topics" text) ""
^ "</entry>\n"
let feed title archive_id base_url alternate_type texts =
let entry, self = match alternate_type with
| "text/gemini" -> gmi_entry, base_url^"/gmi.atom"
| "text/html" | _ -> htm_entry, base_url^"/feed.atom" in
{|<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:base="|} ^ base_url ^ {|"><title>|}
^ title ^ {|</title><link rel="alternate" type="|} ^ alternate_type ^ {|" href="|}
^ base_url ^ {|/" /><link rel="self" type="application/atom+xml" href="|}
^ self ^ {|" /><id>urn:uuid:|} ^ Logarion.Id.to_string archive_id ^ "</id><updated>"
^ Ptime.to_rfc3339 (Ptime_clock.now ()) ^ "</updated>\n"
^ List.fold_left (fun acc t -> acc ^ entry base_url t) "" texts
^ "</feed>"

142
cli/cli.ml Normal file
View File

@@ -0,0 +1,142 @@
let version = "%%VERSION%%"
open Cmdliner
open Logarion
module A = Logarion.Archive.Make(File_store)
(* TODO: merge in lib/ so other modules can use (.e.g HTTP pull) *)
let text_list order_opt reverse_opt number_opt values_opt authors_opt topics_opt =
match A.of_path (Sys.getcwd ()) with
| Error msg -> prerr_endline msg
| Ok archive ->
let predicates = A.predicate A.authored authors_opt @ A.predicate A.topics topics_opt in
let predicate text = List.fold_left (fun a e -> a && e text) true predicates in
let print_fold ~predicate fn =
let ts = A.fold ~predicate fn String_set.empty archive in
String_set.iter (print_endline) ts
in
let list_text (t, fname) = print_endline (Text.short_id t ^ " " ^ t.Text.title ^ "\t" ^ fname) in
match values_opt with
| Some "topics" -> print_fold ~predicate (fun a (e,_) -> (String_set.union a (Text.set "topics" e)))
| Some "authors" ->
let s = A.fold ~predicate (fun a (e,_) -> Person.Set.union a e.Text.authors) Person.Set.empty archive in
print_endline @@ Person.Set.to_string s
| Some x -> prerr_endline @@ "Unrecognised field: " ^ x
| None -> match order_opt with
| false -> A.iter ~predicate list_text archive
| true ->
let order = match reverse_opt with true -> A.newest | false -> A.oldest in
match number_opt with
| Some number -> A.iter ~predicate ~order ~number list_text archive
| None -> A.iter ~predicate ~order list_text archive
let list_term =
let reverse = Arg.(value & flag & info ["r"] ~doc:"reverse order") in
let time = Arg.(value & flag & info ["t"] ~doc:"Sort by time, newest first") in
let number = Arg.(value & opt (some int) None & info ["n"] ~docv:"NUMBER" ~doc:"number of entries to list") in
let values = Arg.(value & opt (some string) None & info ["values"] ~docv:"HEADER-FIELD" ~doc:"unique values for header field") in
let authed = Arg.(value & opt (some string) None & info ["authored"] ~docv:"AUTHORS" ~doc:"texts by authors") in
let topics = Arg.(value & opt (some string) None & info ["topics"] ~docv:"TOPICS" ~doc:"texts with topics") in
Term.(const text_list $ time $ reverse $ number $ values $ authed $ topics),
Term.info "list" ~doc:"list texts" ~man:[ `S "DESCRIPTION"; `P "List texts" ]
let print_last search_mine =
let last a ((t,_) as pair) = match a with None -> Some pair
| Some (t', _) as pair' -> if Text.newest t t' > 0 then Some pair else pair' in
match A.of_path (Sys.getcwd ()) with
| Error msg -> prerr_endline msg
| Ok archive ->
let last_mine a ((t,_) as pair) =
let open Text in
match a with None ->
if Person.Set.subset archive.A.archivists t.authors then Some pair else None
| Some (t', _) as pair' ->
if Text.newest t t' > 0 && Person.Set.subset archive.A.archivists t'.authors
then Some pair else pair'
in
match A.fold (if search_mine then last_mine else last) None archive with
| Some (_,f) -> print_endline f | None -> ()
let last_term =
let mine = Arg.(value & flag & info ["mine"] ~doc:"last text authored by me") in
Term.(const print_last $ mine),
Term.info "last" ~doc:"most recent test" ~man:[ `S "DESCRIPTION"; `P "Print the filename of most recent text" ]
let split_filetypes files =
let acc (dirs, files) x = if Sys.is_directory x then (x::dirs, files) else (dirs, x::files) in
List.fold_left acc ([],[]) files
let file files = match A.of_path "." with
| Error msg -> prerr_endline msg
| Ok _archive ->
let dirs, files = split_filetypes files in
let _link_as_named dir file = Unix.link file (dir ^"/"^ file) in
let link_with_id dir file =
match File_store.to_text file with Error s -> prerr_endline s
| Ok t -> Unix.link file (dir ^"/"^ String.sub (Id.to_string (t.Text.uuid)) 0 8 ^".txt")
in
let link = link_with_id in
List.iter (fun d -> List.iter (link d) files) dirs
let file_term =
let files = Arg.(value & pos_all string [] & info [] ~doc:"filenames") in
let doc = "file texts in directories" in
let man = [ `S "DESCRIPTION"; `P doc ] in
Term.(const file $ files), Term.info "file" ~doc ~man
let unfile files = match A.of_path "." with
| Error msg -> prerr_endline msg
| Ok _archive ->
let dirs, files = split_filetypes files in
let unlink dir file = try Unix.unlink (dir ^"/"^ file) with Unix.(Unix_error(ENOENT,_,_))-> () in
List.iter (fun d -> List.iter (unlink d) files) dirs
let unfile_term =
let files = Arg.(value & pos_all string [] & info [] ~doc:"filenames") in
let doc = "unfile texts from directories" in
let man = [ `S "DESCRIPTION"; `P doc ] in
Term.(const unfile $ files), Term.info "unfile" ~doc ~man
let init _force = File_store.init ()
let init_term =
let force = Arg.(value & flag & info ["f"; "force"] ~doc:"Initialise even if directory is not empty") in
let doc = "initialise a text repository in present directory" in
let man = [ `S "DESCRIPTION"; `P "Start an archive in current directory" ] in
Term.(const init $ force), Term.info "init" ~doc ~man
let new_term =
let f title topics_opt interactive =
match A.of_path "." with
| Error m -> prerr_endline m
| Ok archive ->
let t = match title with "" -> "Draft" | _ -> title in
let authors = archive.archivists in
let date = Date.({ created = Some (Ptime_clock.now ()); edited = None }) in
let text = { (Text.blank ()) with title = t; authors; date } in
let text = try Text.with_str_set text "Topics" (Option.get topics_opt) with _ -> text in
match File_store.with_text archive text with
| Error s -> prerr_endline s
| Ok (filepath, _note) ->
match interactive with false -> print_endline filepath
| true ->
print_endline @@ "Created: " ^ filepath;
let _code = Sys.command ("$EDITOR " ^ filepath) in
()
in
let title = Arg.(value & pos 0 string "" & info [] ~docv:"TITLE" ~doc:"Title for new article") in
let topics= Arg.(value & opt (some string) None & info ["t"; "topics"] ~docv:"TOPICS" ~doc:"Topics for new article") in
let inter = Arg.(value & flag & info ["i"; "interactive"] ~doc:"Prompts through the steps of creation and publication") in
let man = [ `S "DESCRIPTION"; `P "Create a new article, with title 'Draft' when none provided"] in
Term.(const f $ title $ topics $ inter), Term.info "new" ~doc:"create a new article" ~man
let default_cmd =
let doc = "text archival & publishing" in
let man = [ `S "BUGS"; `P "Submit bugs <mailto:logarion@lists.orbitalfox.eu?subject=Issue: " ] in
Term.(ret (const (`Help (`Pager, None)))), Term.info "txt" ~version ~doc ~man
let cmds = [ init_term; new_term; file_term; unfile_term; list_term; last_term; Convert.term; Http.pull_term ]
let () =
Random.self_init();
match Term.eval_choice default_cmd cmds with `Error _ -> exit 1 | _ -> exit 0

90
cli/convert.ml Normal file
View File

@@ -0,0 +1,90 @@
open Logarion
module A = Archive.Make (Logarion.File_store)
let convert_modified source dest fn title text =
if (try Unix.((stat dest).st_mtime < (stat source).st_mtime) with _-> true)
then (File_store.file dest (fn title text); true) else false
let word_fname dir text = dir ^ "/" ^ Text.alias text
let id_fname dir text = dir ^ "/" ^ Text.short_id text
let writer types dir name (text,store_item) = (* todo: single_parser -> [files] *)
(* convert_modified store_item idfilename (fun _title -> Text.to_string) text.title text;*)
let h = if "htm" = types || "all" = types then
convert_modified store_item (id_fname dir text ^ ".htm") Html.page name text
else false in
let g = if "gmi" = types || "all" = types then
convert_modified store_item (id_fname dir text ^ ".gmi") Gemini.page name text
else false in
h || g
let index_writer types noindex dir archive topic_roots topic_map texts =
let name = archive.A.name in
let file path = File_store.file (dir ^ path) in
file "/index.pck" (Header_pack.pack archive texts);
if not noindex && ("htm" = types || "all" = types) then (
let index_name = try Store.KV.find "HTML-index" archive.File_store.kv
with Not_found -> "index.html" in
if index_name <> "" then
file ("/"^index_name) (Html.topic_main_index name topic_roots texts);
file "/index.date.htm" (Html.date_index name texts);
List.iter
(fun topic -> file ("/index." ^ topic ^ ".htm")
(Html.topic_sub_index name topic_map topic texts))
topic_roots;
let base_url = try Store.KV.find "HTTP-URL" archive.File_store.kv
with Not_found -> prerr_endline "Missing `HTTP-URL:` in config"; "" in
file "/feed.atom" (Atom.feed archive.A.name archive.A.id base_url "text/html" texts)
);
if not noindex && ("gmi" = types || "all" = types) then (
let index_name = try Store.KV.find "Gemini-index" archive.File_store.kv
with Not_found -> "index.gmi" in
if index_name <> "" then
file ("/"^index_name) (Gemini.topic_main_index name topic_roots texts);
file "/index.date.gmi" (Gemini.date_index name texts);
List.iter
(fun topic -> file ("/index." ^ topic ^ ".gmi")
(Gemini.topic_sub_index name topic_map topic texts))
topic_roots;
let base_url = try Store.KV.find "GEMINI-URL" archive.File_store.kv
with Not_found -> prerr_endline "Missing `GEMINI-URL:` in config"; "" in
file "/gmi.atom" (Atom.feed archive.A.name archive.A.id base_url "text/gemini" texts)
)
let txt_writer types dir name ((text, _store_item) as r) =
match Text.str "Content-Type" text with
| "" | "text/plain" -> writer types dir name r
| x -> prerr_endline ("Can't convert Content-Type: "^x^" file: " ^text.Text.title); false
let convert_all types noindex dir archive =
let name = archive.A.name in
let fn (ts,ls,acc) ((elt,_) as r) =
(Topic_set.to_map ts (Text.set "topics" elt)),
elt::ls, if txt_writer types dir name r then acc+1 else acc in
let empty = Topic_set.Map.empty in
let topic_map, texts, count = A.(fold ~order:newest fn (empty,[],0) archive) in
let topic_roots = Topic_set.roots topic_map in
index_writer types noindex dir archive topic_roots topic_map texts;
print_endline @@ "Converted: " ^ string_of_int (count)
^ "\nIndexed: " ^ string_of_int (List.length texts);
Ok ()
let convert_dir types noindex cmd_dir =
let (>>=) = Result.bind in
let with_dir dir =
Result.map_error (function `Msg m -> m)
Logarion.File_store.Directory.(directory dir |> print ~descr:"export" dir) in
(A.of_path "."
>>= fun archive -> (match cmd_dir with "" -> Error "unspecified export dir" | x -> Ok x)
>>= fun dir -> with_dir dir
>>= fun _ -> convert_all types noindex dir { archive with store = dir })
|> function Ok () -> () | Error x -> prerr_endline x
open Cmdliner
let term =
let directory = Arg.(value & pos 0 string "" & info [] ~docv:"target directory" ~doc:"Directory to convert into") in
let types = Arg.(value & opt string "all" & info ["t"; "type"] ~docv:"TYPES" ~doc:"Convert to type") in
let noindex = Arg.(value & flag & info ["noindex"] ~doc:"don't write an index when converting") in
Term.(const convert_dir $ types $ noindex $ directory),
Term.info "convert" ~doc:"convert archive" ~man:[ `S "DESCRIPTION"; `P "Convert Logarion archive" ]

5
cli/dune Normal file
View File

@@ -0,0 +1,5 @@
(executable
(name cli)
(public_name txt)
(modules cli convert html atom gemini)
(libraries logarion logarion.http re.str cmdliner bos ptime ptime.clock.os text_parse.converter text_parse.parsers msgpck))

73
cli/gemini.ml Normal file
View File

@@ -0,0 +1,73 @@
let page _archive_title text =
let open Logarion.Text in
"# " ^ text.title
^ "\nAuthors: " ^ Logarion.Person.Set.to_string text.authors
^ "\nDate: " ^ Logarion.Date.(pretty_date @@ listing text.date)
^ let module T = Parsers.Plain_text.Make (Converter.Gemini) in
"\n" ^ T.of_string text.body ""
let date_index title meta_list =
List.fold_left
(fun a m ->
a ^ "=> " ^ Logarion.Text.short_id m ^ ".gmi " ^
Logarion.(Date.(pretty_date (listing m.date)) ^ " " ^ m.title) ^ "\n")
("# " ^ title ^ "\n\n## Posts by date\n\n") meta_list
let to_dated_links ?(limit) meta_list =
let meta_list = match limit with
| None -> meta_list
| Some limit->
let rec reduced acc i = function
| [] -> acc
| h::t -> if i < limit then reduced (h::acc) (i+1) t else acc in
List.rev @@ reduced [] 0 meta_list
in
List.fold_left
(fun a m ->
a
^ "=> " ^ Logarion.Text.short_id m ^ ".gmi "
^ Logarion.(Date.(pretty_date (listing m.Text.date))) ^ " "
^ m.Logarion.Text.title ^ "\n")
"" meta_list
let topic_link root topic =
"=> index." ^ root ^ ".gmi " ^ String.capitalize_ascii topic ^ "\n"
let text_item path meta =
let open Logarion in
"=> " ^ path ^ Text.short_id meta ^ ".gmi "
^ Date.(pretty_date (listing meta.Text.date)) ^ " "
^ meta.Text.title ^ "\n"
let listing_index topic_map topic_roots path metas =
let rec item_group topics =
List.fold_left (fun acc topic -> acc ^ sub_groups topic ^ items topic) "" topics
and sub_groups topic = match Logarion.Topic_set.Map.find_opt topic topic_map with
| None -> ""
| Some (_, subtopics) -> item_group (Logarion.String_set.elements subtopics)
and items topic =
let items =
let open Logarion in
List.fold_left
(fun a e ->
if String_set.mem topic (String_set.map (Logarion.Topic_set.topic) (Text.set "Topics" e))
then text_item path e ^ a else a) "" metas in
match items with
| "" -> ""
| x -> "## " ^ String.capitalize_ascii topic ^ "\n\n" ^ x
in
item_group topic_roots
let fold_topic_roots topic_roots =
let list_item root t = topic_link root t in
List.fold_left (fun a x -> a ^ list_item x x) "" (List.rev topic_roots)
let topic_main_index title topic_roots metas =
"# " ^ title ^ "\n\n"
^ (if topic_roots <> [] then ("## Main topics\n\n" ^ fold_topic_roots topic_roots) else "")
^ "\n## Latest\n\n" ^ to_dated_links ~limit:10 metas
^ "\n=> index.date.gmi More by date\n"
let topic_sub_index title topic_map topic_root metas =
"# " ^ title ^ "\n\n"
^ listing_index topic_map [topic_root] "" metas

125
cli/html.ml Normal file
View File

@@ -0,0 +1,125 @@
let wrap (title:string) (subtitle:string) body =
{|<!DOCTYPE HTML>|}
^ {|<html><head><title>|}
^ subtitle ^ " | " ^ title
^ {|</title><link rel="stylesheet" href="main.css">|}
^ {|<link rel="alternate" href="feed.atom" type="application/atom+xml">|}
^ {|<meta charset="utf-8"/>|}
^ {|<meta name="viewport" content="width=device-width, initial-scale=1.0">|}
^ {|</head><body><header><a href=".">|} ^ title
^ {|</a> <nav><a href="feed.atom" id="feed">feed</a></nav></header>|} ^ body
^ "</body></html>"
let topic_link root topic =
let replaced_space = String.map (function ' '->'+' | x->x) in
{|<a href="index.|} ^ root ^ {|.htm#|} ^ replaced_space topic ^ {|">|}
^ String.capitalize_ascii topic ^ "</a>"
let page archive_title text =
let open Logarion in
let open Text in
let module T = Parsers.Plain_text.Make (Converter.Html) in
let sep_append ?(sep=", ") a x = match a,x with "",_ -> x | _, "" -> a | _ -> a ^ sep ^ x in
let opt_kv key value = if String.length value > 0 then "<dt>" ^ key ^ "<dd>" ^ value else "" in
(* let author acc auth = sep_append acc Person.(auth.name ^ " ") in*)
let authors = (Person.Set.to_string text.authors ^ " ") in
let keywords = str_set "keywords" text in
let header =
let time x = {|<time datetime="|} ^ x ^ {|">|} ^ x ^ "</time>" in
let topic_links x =
let to_linked t a =
let ts = Topic_set.of_string t in
sep_append a (List.fold_left (fun a t -> sep_append ~sep:" > " a (topic_link (List.hd ts) t)) "" ts) in
String_set.fold to_linked x "" in
"<article><header><dl>"
^ opt_kv "Title:" text.title
^ opt_kv "Authors:" authors
^ opt_kv "Date: " (time (Date.(pretty_date @@ listing text.date)))
^ opt_kv "Series: " (str_set "series" text)
^ opt_kv "Topics: " (topic_links (set "topics" text))
^ opt_kv "Keywords: " keywords
^ opt_kv "Id: " (Id.to_string text.uuid)
^ {|</dl></header><pre style="white-space:pre-wrap">|} in
wrap archive_title text.title ((T.of_string text.body header) ^ "</pre></article>")
let to_dated_links ?(limit) meta_list =
let meta_list = match limit with
| None -> meta_list
| Some limit->
let rec reduced acc i = function
| [] -> acc
| h::t -> if i < limit then reduced (h::acc) (i+1) t else acc in
List.rev @@ reduced [] 0 meta_list
in
List.fold_left
(fun a m ->
a ^ Logarion.(Date.(pretty_date (listing m.Text.date)) ^ " ")
^ {|<a href="|} ^ Logarion.Text.short_id m ^ {|.htm">|} ^ m.Logarion.Text.title ^ "</a><br>")
"" meta_list
let date_index ?(limit) title meta_list =
match limit with
| Some limit -> wrap title "Index" (to_dated_links ~limit meta_list)
| None -> wrap title "Index" (to_dated_links meta_list)
let fold_topic_roots topic_roots =
let list_item root t = "<li>" ^ topic_link root t in
"<nav><h2>Main topics</h2>"
^ List.fold_left (fun a x -> a ^ list_item x x) "<ul>" (List.rev topic_roots)
^ "</ul></nav>"
let fold_topics topic_map topic_roots metas =
let open Logarion in
let rec unordered_list root topic =
List.fold_left (fun a x -> a ^ list_item root x) "<ul>" topic
^ "</ul>"
and sub_items root topic = match Topic_set.Map.find_opt topic topic_map with
| None -> ""
| Some (_, subtopics) -> unordered_list root (String_set.elements subtopics)
and list_item root t =
let item =
if List.exists (fun x -> String_set.mem t (String_set.map Topic_set.topic (Text.set "topics" x))) metas
then topic_link root t else String.capitalize_ascii t
in
"<li>" ^ item ^ sub_items root t
in
"<nav><h2>Topics</h2>"
^ List.fold_left (fun a x -> a ^ list_item x x) "<ul>" (List.rev topic_roots)
^ "</ul></nav>"
let text_item path meta =
let open Logarion in
"<time>" ^ Date.(pretty_date (listing meta.Text.date))
^ {|</time> <a href="|} ^ path ^ Text.short_id meta ^ {|.htm">|} ^ meta.Text.title
^ "</a><br>"
let listing_index topic_map topic_roots path metas =
let rec item_group topics =
List.fold_left (fun acc topic -> acc ^ sub_groups topic ^ items topic) "" topics
and sub_groups topic = match Logarion.Topic_set.Map.find_opt topic topic_map with
| None -> ""
| Some (_, subtopics) -> item_group (Logarion.String_set.elements subtopics)
and items topic =
let items =
let open Logarion in
List.fold_left
(fun a e ->
if String_set.mem topic (String_set.map (Logarion.Topic_set.topic) (Text.set "Topics" e))
then text_item path e ^ a else a) "" metas in
match items with
| "" -> ""
| x -> {|<h2 id="|} ^ topic ^ {|">|} ^ String.capitalize_ascii topic ^ "</h2>" ^ x
in
"<nav><h1>Texts</h1>" ^ item_group topic_roots ^ "</nav>"
let topic_main_index title topic_roots metas =
wrap title "Topics"
(fold_topic_roots topic_roots
^ "<nav><h1>Latest</h1>" ^ to_dated_links ~limit:10 metas
^ {|<a href="index.date.htm">More by date</a></nav>|} )
let topic_sub_index title topic_map topic_root metas =
wrap title topic_root
(fold_topics topic_map [topic_root] metas
(* ^ {|<a href=".atom" id="feed">|}^ String.capitalize_ascii topic_root ^{| feed </a>|}*)
^ listing_index topic_map [topic_root] "" metas)