changeset 476:71114096433c

core: Add support for Markdown extensions, add header anchor extension. - New configuration option to specify Markdown extensions. - Enable some extensions by default. - Add CSS to make tables pretty. - Add extension to generate anchors next to each HTML heading. - Provide CSS to show those anchors on mouse-hover.
author Ludovic Chabant <ludovic@chabant.com>
date Thu, 11 Oct 2018 23:24:06 -0700
parents 0ecf8303a135
children 71c9259de019
files wikked/assets/css/wikked/page.less wikked/formatter.py wikked/resolver.py wikked/resources/defaults.cfg wikked/utils.py wikked/wiki.py
diffstat 6 files changed, 158 insertions(+), 63 deletions(-) [+]
line wrap: on
line diff
--- a/wikked/assets/css/wikked/page.less	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/assets/css/wikked/page.less	Thu Oct 11 23:24:06 2018 -0700
@@ -1,5 +1,7 @@
+@wikked-icon-font: "Font Awesome 5 Free";
 @wikked-external-link-icon: @fa-var-external-link-alt;
 //@wikked-external-link-icon: @fa-var-external-link-square-alt;
+@wikked-anchor-icon: @fa-var-paragraph;
 
 
 // Article
@@ -28,15 +30,52 @@
         color: @color-orange;
         &:hover { color: @color-orange; text-decoration: underline; }
     }
+
+    table {
+        display: block;
+        margin-top: 0;
+        margin-bottom: 1.5em;
+
+        th, td { padding: 0.5em 1em; border: 1px solid @color-gray-medium; }
+        th { font-weight: bold; }
+        th, tr:nth-child(2n) { background: darken(@color-gray-light, 5%); }
+    }
 }
 article.wiki-page {
-    a {
-        &::after { font-family: 'Font Awesome 5 Free'; font-weight: 900; content: ' @{wikked-external-link-icon}';  }
+    a[href] {
+        &::after {
+            font-family: @wikked-icon-font;
+            font-size: 0.6em;
+            font-weight: 900;
+            vertical-align: middle;
+            content: ' @{wikked-external-link-icon}';
+        }
     }
     a.wiki-link,
     a.wiki-meta-link {
         &::after { content: none; }
     }
+
+    h1>a.wiki-header-link::after,
+    h2>a.wiki-header-link::after,
+    h3>a.wiki-header-link::after,
+    h4>a.wiki-header-link::after,
+    h5>a.wiki-header-link::after,
+    h6>a.wiki-header-link::after {
+        font-family: @wikked-icon-font;
+        font-size: 0.8em;
+        font-weight: 900;
+        content: ' @{wikked-anchor-icon}';
+        visibility: hidden;
+    }
+    h1:hover>a.wiki-header-link::after,
+    h2:hover>a.wiki-header-link::after,
+    h3:hover>a.wiki-header-link::after,
+    h4:hover>a.wiki-header-link::after,
+    h5:hover>a.wiki-header-link::after,
+    h6:hover>a.wiki-header-link::after {
+        visibility: visible;
+    }
 }
 
 // Page title decorators
--- a/wikked/formatter.py	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/formatter.py	Thu Oct 11 23:24:06 2018 -0700
@@ -4,7 +4,8 @@
 import logging
 import jinja2
 from io import StringIO
-from .utils import get_meta_name_and_modifiers, html_escape, split_page_url
+from .utils import (
+    get_meta_name_and_modifiers, html_escape, split_page_url, get_url_tail)
 
 
 RE_FILE_FORMAT = re.compile(r'\r\n?', re.MULTILINE)
@@ -18,13 +19,15 @@
     re.MULTILINE | re.DOTALL)
 
 RE_LINK_ENDPOINT = re.compile(
-    r'\[\[(\w[\w\d]+)?\:([^\]]*/)?([^\]]+)\]\]')
+    r'(?<!\\)\[\[(\w[\w\d]+)?\:([^\#\]]+)(\#[\w\d\-]+)?\]\]')
 RE_LINK_ENDPOINT_DISPLAY = re.compile(
-    r'\[\[([^\|\]]+)\|\s*(\w[\w\d]+)?\:([^\]]+)\]\]')
+    r'(?<!\\)\[\[([^\|\]]+)\|\s*(\w[\w\d]+)?\:([^\#\]]+)(\#[\w\d\-]+)?\]\]')
 RE_LINK_DISPLAY = re.compile(
-    r'\[\[([^\|\]]+)\|([^\]]+)\]\]')
+    r'(?<!\\)\[\[([^\|\]]+)\|([^\#\]]+)(\#[\w\d\-]+)?\]\]')
 RE_LINK = re.compile(
-    r'\[\[([^\]]*/)?([^/\]]+)\]\]')
+    r'(?<!\\)\[\[([^\#\]]+)(\#[\w\d\-]+)?\]\]')
+RE_ESCAPED_LINK = re.compile(
+    r'(?<!\\)\\\[\[')
 
 
 logger = logging.getLogger(__name__)
@@ -128,13 +131,13 @@
         # [[endpoint:Something/Blah.ext]]
         def repl1(m):
             endpoint = m.group(1)
-            a, b = m.group(2, 3)
-            value = b if a is None else (a + b)
+            value, frag = m.group(2, 3)
+            name = get_url_tail(value)
             if endpoint in self.endpoints:
                 return self.endpoints[endpoint](
-                    ctx, endpoint, b.strip(), value.strip())
+                    ctx, endpoint, name.strip(), value.strip(), frag)
             return self._formatEndpointLink(
-                ctx, endpoint, b.strip(), value.strip())
+                ctx, endpoint, name.strip(), value.strip(), frag)
         text = RE_LINK_ENDPOINT.sub(repl1, text)
 
         # [[display name|endpoint:Something/Whatever]]
@@ -142,9 +145,10 @@
             display = m.group(1).strip()
             endpoint = m.group(2)
             value = m.group(3).strip()
+            frag = m.group(4)
             if endpoint in self.endpoints:
                 return self.endpoints[endpoint](
-                    ctx, endpoint, display, value)
+                    ctx, endpoint, display, value, frag)
             return self._formatEndpointLink(
                 ctx, endpoint, display, value)
         text = RE_LINK_ENDPOINT_DISPLAY.sub(repl2, text)
@@ -152,16 +156,20 @@
         # [[display name|Whatever/PageName]]
         def repl3(m):
             return s._formatWikiLink(ctx, m.group(1).strip(),
-                                     m.group(2).strip())
+                                     m.group(2).strip(),
+                                     m.group(3))
         text = RE_LINK_DISPLAY.sub(repl3, text)
 
         # [[Namespace/PageName]]
         def repl4(m):
-            a, b = m.group(1, 2)
-            url = b if a is None else (a + b)
-            return s._formatWikiLink(ctx, b.strip(), url.strip())
+            value, frag = m.group(1, 2)
+            name = get_url_tail(value)
+            return s._formatWikiLink(ctx, name.strip(), value.strip(), frag)
         text = RE_LINK.sub(repl4, text)
 
+        # \[[Escaped Link]]
+        text = RE_ESCAPED_LINK.sub('[[', text)
+
         return text
 
     def _coerceInclude(self, ctx, value):
@@ -214,7 +222,7 @@
         return '<div class="wiki-query"%s>%s</div>\n' % (
                 mod_attr, '|'.join(processed_args))
 
-    def _formatFileLink(self, ctx, endpoint, display, value):
+    def _formatFileLink(self, ctx, endpoint, display, value, fragment):
         if value.startswith('./'):
             abs_url = os.path.join('/pagefiles', ctx.url.lstrip('/'),
                                    value[2:])
@@ -223,40 +231,27 @@
         abs_url = os.path.normpath(abs_url).replace('\\', '/')
         return abs_url
 
-    def _formatImageLink(self, ctx, endpoint, display, value):
-        abs_url = self._formatFileLink(ctx, endpoint, display, value)
+    def _formatImageLink(self, ctx, endpoint, display, value, fragment):
+        abs_url = self._formatFileLink(ctx, endpoint, display, value, fragment)
         return ('<img class="wiki-image" src="%s" alt="%s"></img>' %
                 (abs_url, display))
 
-    def _formatEndpointLink(self, ctx, endpoint, display, local_url):
-        if True:  # endpoint:
-            endpoint = endpoint or ''
-            url = '%s:%s' % (endpoint, local_url)
-            ctx.out_links.append(url)
-            return ('<a class="wiki-link" data-wiki-url="%s" '
-                    'data-wiki-endpoint="%s">%s</a>' % (url, endpoint,
-                                                        display))
-        else:
-            # Endpoint link was actually: `[[:/Something/Blah]]`, which
-            # forces going back out of the endpoints.
-            # Render this like a normal wiki link.
-            ctx.out_links.append(local_url)
-            return '<a class="wiki-link" data-wiki-url="%s">%s</a>' % (
-                local_url, display)
+    def _formatEndpointLink(self, ctx, endpoint, display, local_url, fragment):
+        endpoint = endpoint or ''
+        url = '%s:%s' % (endpoint, local_url)
+        fragpart = (' data-wiki-fragment="%s"' % fragment) if fragment else ''
+        ctx.out_links.append(url)
+        return ('<a class="wiki-link" data-wiki-url="%s"'
+                '%s'
+                ' data-wiki-endpoint="%s">%s</a>' %
+                (url, fragpart, endpoint, display))
 
-    def _formatWikiLink(self, ctx, display, url):
-        if True:  # not ctx.endpoint:
-            ctx.out_links.append(url)
-            return '<a class="wiki-link" data-wiki-url="%s">%s</a>' % (
-                    url, display)
-        else:
-            # The link is a relative link from a page that lives in an
-            # endpoint, so the generated link must be in the same endpoint.
-            url = '%s:%s' % (ctx.endpoint, url)
-            ctx.out_links.append(url)
-            return ('<a class="wiki-link" data-wiki-url="%s" '
-                    'data-wiki-endpoint="%s">%s</a>' % (url, ctx.endpoint,
-                                                        display))
+    def _formatWikiLink(self, ctx, display, url, fragment):
+        fragpart = (' data-wiki-fragment="%s"' % fragment) if fragment else ''
+        ctx.out_links.append(url)
+        return ('<a class="wiki-link" data-wiki-url="%s"'
+                '%s>%s</a>' %
+                (url, fragpart, display))
 
     @staticmethod
     def parseWikiLinks(text):
--- a/wikked/resolver.py	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/resolver.py	Thu Oct 11 23:24:06 2018 -0700
@@ -23,6 +23,7 @@
 re_wiki_link = re.compile(
     r'<a class="wiki-link(?P<isedit>-edit)?" '
     r'data-wiki-url="(?P<url>[^"]+)"'
+    r'( data-wiki-fragment="(?P<frag>[^"]*)")?'
     r'( data-wiki-endpoint="(?P<endpoint>[^"]*)")?')
 
 re_wiki_include_param = re.compile(
@@ -230,6 +231,7 @@
             # Resolve link states.
             def repl1(m):
                 raw_url = m.group('url')
+                fragment = m.group('frag') or ''
                 endpoint = m.group('endpoint')
                 is_edit = bool(m.group('isedit'))
                 url = self.ctx.getAbsoluteUrl(raw_url, force_endpoint=endpoint)
@@ -253,14 +255,18 @@
                 if validated_url:
                     # The DB has confirmed that the target page exists,
                     # so make a "real" link.
-                    actual_url = '/%s/%s' % (action, quoted_url.lstrip('/'))
+                    actual_url = '/%s/%s%s' % (action,
+                                               quoted_url.lstrip('/'),
+                                               fragment)
                     return ('<a class="wiki-link" data-wiki-url="%s" '
                             'href="%s"' % (quoted_url, actual_url) +
                             endpoint_markup)
 
                 # The DB doesn't know about the target page, so render
                 # a link with the "missing" class so it shows up red and all.
-                actual_url = '/%s/%s' % (action, quoted_url.lstrip('/'))
+                actual_url = '/%s/%s%s' % (action,
+                                           quoted_url.lstrip('/'),
+                                           fragment)
                 return ('<a class="wiki-link missing" data-wiki-url="%s" '
                         'href="%s"' % (quoted_url, actual_url) +
                         endpoint_markup)
--- a/wikked/resources/defaults.cfg	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/resources/defaults.cfg	Thu Oct 11 23:24:06 2018 -0700
@@ -8,6 +8,9 @@
 database=sql
 database_url=sqlite:///%(root)s/.wiki/wiki.db
 
+[markdown]
+extensions=abbr,def_list,fenced_code,footnotes,tables,toc
+
 [endpoint:templates]
 query=False
 
--- a/wikked/utils.py	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/utils.py	Thu Oct 11 23:24:06 2018 -0700
@@ -96,6 +96,10 @@
     return '/'
 
 
+def get_url_tail(url):
+    return os.path.basename(url)
+
+
 def is_endpoint_url(url):
     return endpoint_prefix_regex.match(url) is not None
 
--- a/wikked/wiki.py	Thu Oct 11 23:18:45 2018 -0700
+++ b/wikked/wiki.py	Thu Oct 11 23:24:06 2018 -0700
@@ -46,14 +46,12 @@
             root = os.getcwd()
         self.root = root
         self.context = ctx
-        self.formatters = {}
         self.custom_heads = {}
         self.wiki_updater = synchronous_wiki_updater
         self._config = None
         self._index_factory = None
         self._scm_factory = None
-
-        self._build()
+        self._formatters = None
 
     @property
     def config(self):
@@ -79,15 +77,20 @@
     def auth_factory(self):
         return UserManager(self.config)
 
-    def _build(self):
-        self.formatters[passthrough_formatter] = ['txt', 'html']
-        self.tryAddFormatter('markdown', 'markdown',
-                             ['md', 'mdown', 'markdown'])
-        self.tryAddFormatter('textile', 'textile',
-                             ['tl', 'text', 'textile'])
-        self.tryAddFormatter('creole', 'creole2html',
-                             ['cr', 'creole'])
-        self.tryAddFountainFormatter()
+    @property
+    def formatters(self):
+        if self._formatters is None:
+            self._formatters = {}
+
+            self.formatters[passthrough_formatter] = ['txt', 'html']
+            self.tryAddMarkdownFormatter()
+            self.tryAddFormatter('textile', 'textile',
+                                 ['tl', 'text', 'textile'])
+            self.tryAddFormatter('creole', 'creole2html',
+                                 ['cr', 'creole'])
+            self.tryAddFountainFormatter()
+
+        return self._formatters
 
     def getSpecialFilenames(self):
         yield '.wikirc'
@@ -101,10 +104,55 @@
         try:
             module = importlib.import_module(module_name)
             func = getattr(module, module_func)
-            self.formatters[func] = extensions
+            self._formatters[func] = extensions
         except ImportError:
             pass
 
+    def tryAddMarkdownFormatter(self,):
+        try:
+            import markdown
+        except ImportError:
+            return
+
+        from markdown.util import etree
+
+        class HeaderAnchorsTreeprocessor(
+                markdown.treeprocessors.Treeprocessor):
+            HEADER_TAGS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
+
+            def run(self, root):
+                hd_tags = self.HEADER_TAGS
+                for elem in root.iter():
+                    if elem.tag in hd_tags:
+                        hd_id = elem.text.lower().replace(' ', '-')
+                        hd_id = elem.attrib.setdefault('id', hd_id)
+                        elem.append(etree.Element(
+                            'a',
+                            {'class': 'wiki-header-link',
+                             'href': '#%s' % hd_id}))
+
+        class HeaderAnchorsExtension(markdown.extensions.Extension):
+            def extendMarkdown(self, md, *args, **kwargs):
+                md.treeprocessors.register(
+                    HeaderAnchorsTreeprocessor(md),
+                    'header_anchors',
+                    100)
+
+        class _MarkdownWrapper:
+            def __init__(self, md):
+                self._md = md
+
+            def __call__(self, text):
+                self._md.reset()
+                return self._md.convert(text)
+
+        exts = self.config.get('markdown', 'extensions').split(',')
+        exts.append(HeaderAnchorsExtension())
+        md = markdown.Markdown(extensions=exts)
+
+        md_wrapper = _MarkdownWrapper(md)
+        self._formatters[md_wrapper] = ['md', 'mdown', 'markdown']
+
     def tryAddFountainFormatter(self):
         try:
             from jouvence.parser import JouvenceParser
@@ -122,7 +170,7 @@
                 rdr.render_doc(document, fp)
                 return fp.getvalue()
 
-        self.formatters[_jouvence_to_html] = ['fountain']
+        self._formatters[_jouvence_to_html] = ['fountain']
 
         head_css = ('<link rel="stylesheet" type="text/css" '
                     'href="/static/css/jouvence.css" />\n')