فهرست منبع

feat: editor + page rendering improvements

NGPixel 2 سال پیش
والد
کامیت
80b1cbff5c

+ 5 - 5
server/renderers/markdown.mjs

@@ -10,9 +10,9 @@ import mdSub from 'markdown-it-sub'
 import mdMark from 'markdown-it-mark'
 import mdMark from 'markdown-it-mark'
 import mdMultiTable from 'markdown-it-multimd-table'
 import mdMultiTable from 'markdown-it-multimd-table'
 import mdFootnote from 'markdown-it-footnote'
 import mdFootnote from 'markdown-it-footnote'
-// import mdImsize from 'markdown-it-imsize'
 import katex from 'katex'
 import katex from 'katex'
-import underline from './modules/markdown-it-underline.mjs'
+import mdImsize from './modules/markdown-it-imsize.mjs'
+import mdUnderline from './modules/markdown-it-underline.mjs'
 // import 'katex/dist/contrib/mhchem'
 // import 'katex/dist/contrib/mhchem'
 import twemoji from 'twemoji'
 import twemoji from 'twemoji'
 import plantuml from './modules/plantuml.mjs'
 import plantuml from './modules/plantuml.mjs'
@@ -51,7 +51,7 @@ export async function render (input, config) {
       } else if (['mermaid', 'plantuml'].includes(lang)) {
       } else if (['mermaid', 'plantuml'].includes(lang)) {
         return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
         return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
       } else {
       } else {
-        const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : hljs.highlightAuto(str)
+        const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : { value: str }
         const lineCount = highlighted.value.match(/\n/g).length
         const lineCount = highlighted.value.match(/\n/g).length
         const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
         const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
         return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
         return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
@@ -70,10 +70,10 @@ export async function render (input, config) {
     .use(mdSub)
     .use(mdSub)
     .use(mdMark)
     .use(mdMark)
     .use(mdFootnote)
     .use(mdFootnote)
-    // .use(mdImsize)
+    .use(mdImsize)
 
 
   if (config.underline) {
   if (config.underline) {
-    md.use(underline)
+    md.use(mdUnderline)
   }
   }
 
 
   if (config.mdmultiTable) {
   if (config.mdmultiTable) {

+ 259 - 0
server/renderers/modules/markdown-it-imsize.mjs

@@ -0,0 +1,259 @@
+// Adapted from markdown-it-imsize plugin by @tatsy
+// Original source https://github.com/tatsy/markdown-it-imsize/blob/master/lib/index.js
+
+function renderImSize (state, silent) {
+  let attrs
+  let code
+  let label
+  let pos
+  let ref
+  let res
+  let title
+  let width = ''
+  let height = ''
+  let token
+  let tokens
+  let start
+  let href = ''
+  const oldPos = state.pos
+  const max = state.posMax
+
+  if (state.src.charCodeAt(state.pos) !== 0x21/* ! */) { return false }
+  if (state.src.charCodeAt(state.pos + 1) !== 0x5B/* [ */) { return false }
+
+  const labelStart = state.pos + 2
+  const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false)
+
+  // parser failed to find ']', so it's not a valid link
+  if (labelEnd < 0) { return false }
+
+  pos = labelEnd + 1
+  if (pos < max && state.src.charCodeAt(pos) === 0x28/* ( */) {
+    //
+    // Inline link
+    //
+
+    // [link](  <href>  "title"  )
+    //        ^^ skipping these spaces
+    pos++
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+    if (pos >= max) { return false }
+
+    // [link](  <href>  "title"  )
+    //          ^^^^^^ parsing link destination
+    start = pos
+    res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
+    if (res.ok) {
+      href = state.md.normalizeLink(res.str)
+      if (state.md.validateLink(href)) {
+        pos = res.pos
+      } else {
+        href = ''
+      }
+    }
+
+    // [link](  <href>  "title"  )
+    //                ^^ skipping these spaces
+    start = pos
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+
+    // [link](  <href>  "title"  )
+    //                  ^^^^^^^ parsing link title
+    res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax)
+    if (pos < max && start !== pos && res.ok) {
+      title = res.str
+      pos = res.pos
+
+      // [link](  <href>  "title"  )
+      //                         ^^ skipping these spaces
+      for (; pos < max; pos++) {
+        code = state.src.charCodeAt(pos)
+        if (code !== 0x20 && code !== 0x0A) { break }
+      }
+    } else {
+      title = ''
+    }
+
+    // [link](  <href>  "title" =WxH  )
+    //                          ^^^^ parsing image size
+    if (pos - 1 >= 0) {
+      code = state.src.charCodeAt(pos - 1)
+
+      // there must be at least one white spaces
+      // between previous field and the size
+      if (code === 0x20) {
+        res = parseImageSize(state.src, pos, state.posMax)
+        if (res.ok) {
+          width = res.width
+          height = res.height
+          pos = res.pos
+
+          // [link](  <href>  "title" =WxH  )
+          //                              ^^ skipping these spaces
+          for (; pos < max; pos++) {
+            code = state.src.charCodeAt(pos)
+            if (code !== 0x20 && code !== 0x0A) { break }
+          }
+        }
+      }
+    }
+
+    if (pos >= max || state.src.charCodeAt(pos) !== 0x29/* ) */) {
+      state.pos = oldPos
+      return false
+    }
+    pos++
+  } else {
+    //
+    // Link reference
+    //
+    if (typeof state.env.references === 'undefined') { return false }
+
+    // [foo]  [bar]
+    //      ^^ optional whitespace (can include newlines)
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+
+    if (pos < max && state.src.charCodeAt(pos) === 0x5B/* [ */) {
+      start = pos + 1
+      pos = state.md.helpers.parseLinkLabel(state, pos)
+      if (pos >= 0) {
+        label = state.src.slice(start, pos++)
+      } else {
+        pos = labelEnd + 1
+      }
+    } else {
+      pos = labelEnd + 1
+    }
+
+    // covers label === '' and label === undefined
+    // (collapsed reference link and shortcut reference link respectively)
+    if (!label) { label = state.src.slice(labelStart, labelEnd) }
+
+    ref = state.env.references[state.md.utils.normalizeReference(label)]
+    if (!ref) {
+      state.pos = oldPos
+      return false
+    }
+    href = ref.href
+    title = ref.title
+  }
+
+  //
+  // We found the end of the link, and know for a fact it's a valid link;
+  // so all that's left to do is to call tokenizer.
+  //
+  if (!silent) {
+    state.pos = labelStart
+    state.posMax = labelEnd
+
+    const newState = new state.md.inline.State(
+      state.src.slice(labelStart, labelEnd),
+      state.md,
+      state.env,
+      tokens = []
+    )
+    newState.md.inline.tokenize(newState)
+
+    token = state.push('image', 'img', 0)
+    token.attrs = attrs = [['src', href],
+      ['alt', '']]
+    token.children = tokens
+    if (title) {
+      attrs.push(['title', title])
+    }
+
+    if (width !== '') {
+      attrs.push(['width', width])
+    }
+
+    if (height !== '') {
+      attrs.push(['height', height])
+    }
+  }
+
+  state.pos = pos
+  state.posMax = max
+  return true
+}
+
+function parseNextNumber (str, pos, max) {
+  let code
+  const start = pos
+  const result = {
+    ok: false,
+    pos,
+    value: ''
+  }
+
+  code = str.charCodeAt(pos)
+
+  while ((pos < max && (code >= 0x30 /* 0 */ && code <= 0x39 /* 9 */)) || code === 0x25 /* % */) {
+    code = str.charCodeAt(++pos)
+  }
+
+  result.ok = true
+  result.pos = pos
+  result.value = str.slice(start, pos)
+
+  return result
+}
+
+function parseImageSize (str, pos, max) {
+  let code
+  const result = {
+    ok: false,
+    pos: 0,
+    width: '',
+    height: ''
+  }
+
+  if (pos >= max) { return result }
+
+  code = str.charCodeAt(pos)
+
+  if (code !== 0x3d /* = */) { return result }
+
+  pos++
+
+  // size must follow = without any white spaces as follows
+  // (1) =300x200
+  // (2) =300x
+  // (3) =x200
+  code = str.charCodeAt(pos)
+  if (code !== 0x78 /* x */ && (code < 0x30 || code > 0x39) /* [0-9] */) {
+    return result
+  }
+
+  // parse width
+  const resultW = parseNextNumber(str, pos, max)
+  pos = resultW.pos
+
+  // next charactor must be 'x'
+  code = str.charCodeAt(pos)
+  if (code !== 0x78 /* x */) { return result }
+
+  pos++
+
+  // parse height
+  const resultH = parseNextNumber(str, pos, max)
+  pos = resultH.pos
+
+  result.width = resultW.value
+  result.height = resultH.value
+  result.pos = pos
+  result.ok = true
+  return result
+}
+
+export default (md) => {
+  md.inline.ruler.before('emphasis', 'image', renderImSize)
+}

+ 24 - 14
ux/src/components/PageHeader.vue

@@ -116,14 +116,16 @@
         :href='siteStore.docsBase + `/editor/${editorStore.editor}`'
         :href='siteStore.docsBase + `/editor/${editorStore.editor}`'
         target='_blank'
         target='_blank'
         type='a'
         type='a'
-      )
+        )
+        q-tooltip {{ t(`common.actions.viewDocs`) }}
       q-btn.q-ml-sm.acrylic-btn(
       q-btn.q-ml-sm.acrylic-btn(
         icon='las la-cog'
         icon='las la-cog'
         flat
         flat
         color='grey'
         color='grey'
         :aria-label='t(`editor.settings`)'
         :aria-label='t(`editor.settings`)'
         @click='openEditorSettings'
         @click='openEditorSettings'
-      )
+        )
+        q-tooltip {{ t(`editor.settings`) }}
     template(v-if='editorStore.isActive || editorStore.hasPendingChanges')
     template(v-if='editorStore.isActive || editorStore.hasPendingChanges')
       q-btn.acrylic-btn.q-ml-sm(
       q-btn.acrylic-btn.q-ml-sm(
         flat
         flat
@@ -139,8 +141,8 @@
         flat
         flat
         icon='las la-check'
         icon='las la-check'
         color='positive'
         color='positive'
-        label='Create Page'
-        aria-label='Create Page'
+        :label='t(`editor.createPage`)'
+        :aria-label='t(`editor.createPage`)'
         no-caps
         no-caps
         @click='createPage'
         @click='createPage'
       )
       )
@@ -149,19 +151,21 @@
         flat
         flat
         icon='las la-check'
         icon='las la-check'
         color='positive'
         color='positive'
-        label='Save Changes'
-        aria-label='Save Changes'
+        :label='t(`common.actions.saveChanges`)'
+        :aria-label='t(`common.actions.saveChanges`)'
         :disabled='!editorStore.hasPendingChanges'
         :disabled='!editorStore.hasPendingChanges'
         no-caps
         no-caps
-        @click='saveChanges'
-      )
+        @click.exact='saveChanges(false)'
+        @click.ctrl.exact='saveChanges(true)'
+        )
+        q-tooltip {{ t(`editor.saveAndCloseTip`) }}
     template(v-else-if='userStore.can(`edit:pages`)')
     template(v-else-if='userStore.can(`edit:pages`)')
       q-btn.acrylic-btn.q-ml-md(
       q-btn.acrylic-btn.q-ml-md(
         flat
         flat
         icon='las la-edit'
         icon='las la-edit'
         color='deep-orange-9'
         color='deep-orange-9'
-        label='Edit'
-        aria-label='Edit'
+        :label='t(`common.actions.edit`)'
+        :aria-label='t(`common.actions.edit`)'
         no-caps
         no-caps
         @click='editPage'
         @click='editPage'
       )
       )
@@ -258,7 +262,7 @@ async function discardChanges () {
   $q.loading.hide()
   $q.loading.hide()
 }
 }
 
 
-async function saveChanges () {
+async function saveChanges (closeAfter = false) {
   if (siteStore.features.reasonForChange !== 'off') {
   if (siteStore.features.reasonForChange !== 'off') {
     $q.dialog({
     $q.dialog({
       component: defineAsyncComponent(() => import('../components/PageReasonForChangeDialog.vue')),
       component: defineAsyncComponent(() => import('../components/PageReasonForChangeDialog.vue')),
@@ -269,14 +273,14 @@ async function saveChanges () {
       editorStore.$patch({
       editorStore.$patch({
         reasonForChange: reason
         reasonForChange: reason
       })
       })
-      saveChangesCommit()
+      saveChangesCommit(closeAfter)
     })
     })
   } else {
   } else {
-    saveChangesCommit()
+    saveChangesCommit(closeAfter)
   }
   }
 }
 }
 
 
-async function saveChangesCommit () {
+async function saveChangesCommit (closeAfter = false) {
   $q.loading.show()
   $q.loading.show()
   try {
   try {
     await pageStore.pageSave()
     await pageStore.pageSave()
@@ -284,6 +288,12 @@ async function saveChangesCommit () {
       type: 'positive',
       type: 'positive',
       message: 'Page saved successfully.'
       message: 'Page saved successfully.'
     })
     })
+    if (closeAfter) {
+      editorStore.$patch({
+        isActive: false,
+        editor: ''
+      })
+    }
   } catch (err) {
   } catch (err) {
     $q.notify({
     $q.notify({
       type: 'negative',
       type: 'negative',

+ 95 - 18
ux/src/css/page-contents.scss

@@ -1,6 +1,6 @@
 .page-contents {
 .page-contents {
   color: #424242;
   color: #424242;
-  font-size: 14px;
+  font-size: 16px;
 
 
   > *:first-child {
   > *:first-child {
     margin-top: 0;
     margin-top: 0;
@@ -15,7 +15,7 @@
   // ---------------------------------
   // ---------------------------------
 
 
   a {
   a {
-    color: $blue;
+    color: $blue-8;
 
 
     &.is-internal-link.is-invalid-page {
     &.is-internal-link.is-invalid-page {
       color: $red-8;
       color: $red-8;
@@ -40,7 +40,7 @@
     }
     }
 
 
     @at-root .body--dark & {
     @at-root .body--dark & {
-      color: $blue-2;
+      color: $blue-4;
     }
     }
   }
   }
 
 
@@ -51,6 +51,7 @@
   h1, h2, h3, h4, h5, h6 {
   h1, h2, h3, h4, h5, h6 {
     padding: 0;
     padding: 0;
     margin: 0;
     margin: 0;
+    font-weight: 400;
     position: relative;
     position: relative;
     line-height: normal;
     line-height: normal;
 
 
@@ -65,14 +66,11 @@
     }
     }
   }
   }
 
 
-  P + h2 {
-    margin-top: 12px;
-  }
-
   h1 {
   h1 {
     font-size: 3em;
     font-size: 3em;
     font-weight: 500;
     font-weight: 500;
     padding: 12px 0;
     padding: 12px 0;
+    // color: var(--q-primary);
   }
   }
   h2 {
   h2 {
     font-size: 2.4em;
     font-size: 2.4em;
@@ -92,6 +90,29 @@
     font-size: 1.25em;
     font-size: 1.25em;
   }
   }
 
 
+  * + h1 {
+    margin-top: .5em;
+    padding-top: .5em;
+    // border-top: 2px solid var(--q-primary);
+    position: relative;
+
+    &::before {
+      position: absolute;
+      width: 100%;
+      height: 1px;
+      content: ' ';
+      background: linear-gradient(to right, var(--q-primary), transparent);
+      top: 0;
+      left: -16px;
+    }
+  }
+
+  *:not(h1) + h2 {
+    margin-top: .5em;
+    padding-top: .5em;
+    border-top: 1px dotted #CCC;
+  }
+
   .toc-anchor {
   .toc-anchor {
     display: none;
     display: none;
     position: absolute;
     position: absolute;
@@ -107,12 +128,8 @@
   // ---------------------------------
   // ---------------------------------
 
 
   p {
   p {
-    padding: 1rem 0 0 0;
-    margin: 0;
-
-    @at-root .page-contents > div > p:first-child {
-      padding-top: 0;
-    }
+    padding: 0;
+    margin: .3em 0 1em 0;
   }
   }
 
 
   // ---------------------------------
   // ---------------------------------
@@ -120,7 +137,7 @@
   // ---------------------------------
   // ---------------------------------
 
 
   blockquote {
   blockquote {
-    padding: 0 1rem 1rem 1rem;
+    padding: 1em 1em .3em 1em;
     background-color: $blue-grey-1;
     background-color: $blue-grey-1;
     border-left: 55px solid $blue-grey-5;
     border-left: 55px solid $blue-grey-5;
     border-radius: .5rem;
     border-radius: .5rem;
@@ -239,21 +256,29 @@
   // ---------------------------------
   // ---------------------------------
 
 
   ol, ul:not(.tabset-tabs) {
   ol, ul:not(.tabset-tabs) {
-    padding-top: 1rem;
     width: 100%;
     width: 100%;
 
 
+    li > p {
+      &:first-child {
+        margin-top: 0;
+      }
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
     @at-root .is-rtl & {
     @at-root .is-rtl & {
       padding-left: 0;
       padding-left: 0;
-      padding-right: 1rem;
+      padding-right: 1em;
     }
     }
 
 
     li > ul, li > ol {
     li > ul, li > ol {
       padding-top: .5rem;
       padding-top: .5rem;
-      padding-left: 1rem;
+      padding-left: 1em;
 
 
       @at-root .is-rtl & {
       @at-root .is-rtl & {
         padding-left: 0;
         padding-left: 0;
-        padding-right: 1rem;
+        padding-right: 1em;
       }
       }
     }
     }
 
 
@@ -413,6 +438,58 @@
     }
     }
   }
   }
 
 
+    // ---------------------------------
+  // TASK LISTS
+  // ---------------------------------
+
+  .contains-task-list {
+    padding-left: 1em;
+  }
+
+  .task-list-item {
+    position: relative;
+    list-style-type: none;
+
+    &-checkbox[disabled] {
+      width: 1.1rem;
+      height: 1.1rem;
+      top: 2px;
+      position: relative;
+      margin-right: .4em;
+      background-color: $dark-5;
+      border-width: 0;
+
+      &::after {
+        position: absolute;
+        left: 0;
+        top: 0;
+        font-family: "Material Design Icons";
+        font-size: 1.25em;
+        font-weight: normal;
+        content: '\F0131';
+        color: $grey-10;
+        display: block;
+        border: none;
+        background-color: #FFF;
+        line-height: 1em;
+        cursor: default;
+
+        @at-root .body--dark & {
+          color: #FFF;
+          background-color: $dark-6;
+        }
+      }
+
+      &[checked]::after  {
+        content: '\F0C52';
+      }
+    }
+
+    .contains-task-list {
+      padding: .5rem 0 0 1.5rem;
+    }
+  }
+
   // ---------------------------------
   // ---------------------------------
   // CODE
   // CODE
   // ---------------------------------
   // ---------------------------------

+ 2 - 0
ux/src/i18n/locales/en.json

@@ -1417,6 +1417,7 @@
   "editor.conflict.whatToDo": "What do you want to do?",
   "editor.conflict.whatToDo": "What do you want to do?",
   "editor.conflict.whatToDoLocal": "Use your current local version and ignore the latest changes.",
   "editor.conflict.whatToDoLocal": "Use your current local version and ignore the latest changes.",
   "editor.conflict.whatToDoRemote": "Use the remote version (latest) and discard your changes.",
   "editor.conflict.whatToDoRemote": "Use the remote version (latest) and discard your changes.",
+  "editor.createPage": "Create Page",
   "editor.markup.admonitionDanger": "Danger / Important Admonition",
   "editor.markup.admonitionDanger": "Danger / Important Admonition",
   "editor.markup.admonitionInfo": "Info / Note Admonition",
   "editor.markup.admonitionInfo": "Info / Note Admonition",
   "editor.markup.admonitionSuccess": "Tip / Success Admonition",
   "editor.markup.admonitionSuccess": "Tip / Success Admonition",
@@ -1554,6 +1555,7 @@
   "editor.save.processing": "Rendering",
   "editor.save.processing": "Rendering",
   "editor.save.saved": "Saved",
   "editor.save.saved": "Saved",
   "editor.save.updateSuccess": "Page updated successfully.",
   "editor.save.updateSuccess": "Page updated successfully.",
+  "editor.saveAndCloseTip": "Ctrl / Cmd + Click to save and close",
   "editor.select.cannotChange": "This cannot be changed once the page is created.",
   "editor.select.cannotChange": "This cannot be changed once the page is created.",
   "editor.select.customView": "or create a custom view?",
   "editor.select.customView": "or create a custom view?",
   "editor.select.title": "Which editor do you want to use for this page?",
   "editor.select.title": "Which editor do you want to use for this page?",

+ 2 - 2
ux/src/pages/Index.vue

@@ -229,12 +229,12 @@ const state = reactive({
 const thumbStyle = {
 const thumbStyle = {
   right: '2px',
   right: '2px',
   borderRadius: '5px',
   borderRadius: '5px',
-  backgroundColor: '#000',
+  backgroundColor: $q.dark.isActive ? '#FFF' : '#000',
   width: '5px',
   width: '5px',
   opacity: 0.15
   opacity: 0.15
 }
 }
 const barStyle = {
 const barStyle = {
-  backgroundColor: '#FAFAFA',
+  backgroundColor: $q.dark.isActive ? '#161b22' : '#FAFAFA',
   width: '9px',
   width: '9px',
   opacity: 1
   opacity: 1
 }
 }

+ 5 - 5
ux/src/renderers/markdown.js

@@ -10,9 +10,9 @@ import mdSub from 'markdown-it-sub'
 import mdMark from 'markdown-it-mark'
 import mdMark from 'markdown-it-mark'
 import mdMultiTable from 'markdown-it-multimd-table'
 import mdMultiTable from 'markdown-it-multimd-table'
 import mdFootnote from 'markdown-it-footnote'
 import mdFootnote from 'markdown-it-footnote'
-// import mdImsize from 'markdown-it-imsize'
 import katex from 'katex'
 import katex from 'katex'
-import underline from './modules/markdown-it-underline'
+import mdUnderline from './modules/markdown-it-underline'
+import mdImsize from './modules/markdown-it-imsize'
 import 'katex/dist/contrib/mhchem'
 import 'katex/dist/contrib/mhchem'
 import twemoji from 'twemoji'
 import twemoji from 'twemoji'
 import plantuml from './modules/plantuml'
 import plantuml from './modules/plantuml'
@@ -52,7 +52,7 @@ export class MarkdownRenderer {
         } else if (['mermaid', 'plantuml'].includes(lang)) {
         } else if (['mermaid', 'plantuml'].includes(lang)) {
           return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
           return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
         } else {
         } else {
-          const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : hljs.highlightAuto(str)
+          const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : { value: str }
           const lineCount = highlighted.value.match(/\n/g).length
           const lineCount = highlighted.value.match(/\n/g).length
           const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
           const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
           return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
           return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
@@ -71,10 +71,10 @@ export class MarkdownRenderer {
       .use(mdSub)
       .use(mdSub)
       .use(mdMark)
       .use(mdMark)
       .use(mdFootnote)
       .use(mdFootnote)
-      // .use(mdImsize)
+      .use(mdImsize)
 
 
     if (config.underline) {
     if (config.underline) {
-      this.md.use(underline)
+      this.md.use(mdUnderline)
     }
     }
 
 
     if (config.mdmultiTable) {
     if (config.mdmultiTable) {

+ 259 - 0
ux/src/renderers/modules/markdown-it-imsize.js

@@ -0,0 +1,259 @@
+// Adapted from markdown-it-imsize plugin by @tatsy
+// Original source https://github.com/tatsy/markdown-it-imsize/blob/master/lib/index.js
+
+function renderImSize (state, silent) {
+  let attrs
+  let code
+  let label
+  let pos
+  let ref
+  let res
+  let title
+  let width = ''
+  let height = ''
+  let token
+  let tokens
+  let start
+  let href = ''
+  const oldPos = state.pos
+  const max = state.posMax
+
+  if (state.src.charCodeAt(state.pos) !== 0x21/* ! */) { return false }
+  if (state.src.charCodeAt(state.pos + 1) !== 0x5B/* [ */) { return false }
+
+  const labelStart = state.pos + 2
+  const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false)
+
+  // parser failed to find ']', so it's not a valid link
+  if (labelEnd < 0) { return false }
+
+  pos = labelEnd + 1
+  if (pos < max && state.src.charCodeAt(pos) === 0x28/* ( */) {
+    //
+    // Inline link
+    //
+
+    // [link](  <href>  "title"  )
+    //        ^^ skipping these spaces
+    pos++
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+    if (pos >= max) { return false }
+
+    // [link](  <href>  "title"  )
+    //          ^^^^^^ parsing link destination
+    start = pos
+    res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
+    if (res.ok) {
+      href = state.md.normalizeLink(res.str)
+      if (state.md.validateLink(href)) {
+        pos = res.pos
+      } else {
+        href = ''
+      }
+    }
+
+    // [link](  <href>  "title"  )
+    //                ^^ skipping these spaces
+    start = pos
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+
+    // [link](  <href>  "title"  )
+    //                  ^^^^^^^ parsing link title
+    res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax)
+    if (pos < max && start !== pos && res.ok) {
+      title = res.str
+      pos = res.pos
+
+      // [link](  <href>  "title"  )
+      //                         ^^ skipping these spaces
+      for (; pos < max; pos++) {
+        code = state.src.charCodeAt(pos)
+        if (code !== 0x20 && code !== 0x0A) { break }
+      }
+    } else {
+      title = ''
+    }
+
+    // [link](  <href>  "title" =WxH  )
+    //                          ^^^^ parsing image size
+    if (pos - 1 >= 0) {
+      code = state.src.charCodeAt(pos - 1)
+
+      // there must be at least one white spaces
+      // between previous field and the size
+      if (code === 0x20) {
+        res = parseImageSize(state.src, pos, state.posMax)
+        if (res.ok) {
+          width = res.width
+          height = res.height
+          pos = res.pos
+
+          // [link](  <href>  "title" =WxH  )
+          //                              ^^ skipping these spaces
+          for (; pos < max; pos++) {
+            code = state.src.charCodeAt(pos)
+            if (code !== 0x20 && code !== 0x0A) { break }
+          }
+        }
+      }
+    }
+
+    if (pos >= max || state.src.charCodeAt(pos) !== 0x29/* ) */) {
+      state.pos = oldPos
+      return false
+    }
+    pos++
+  } else {
+    //
+    // Link reference
+    //
+    if (typeof state.env.references === 'undefined') { return false }
+
+    // [foo]  [bar]
+    //      ^^ optional whitespace (can include newlines)
+    for (; pos < max; pos++) {
+      code = state.src.charCodeAt(pos)
+      if (code !== 0x20 && code !== 0x0A) { break }
+    }
+
+    if (pos < max && state.src.charCodeAt(pos) === 0x5B/* [ */) {
+      start = pos + 1
+      pos = state.md.helpers.parseLinkLabel(state, pos)
+      if (pos >= 0) {
+        label = state.src.slice(start, pos++)
+      } else {
+        pos = labelEnd + 1
+      }
+    } else {
+      pos = labelEnd + 1
+    }
+
+    // covers label === '' and label === undefined
+    // (collapsed reference link and shortcut reference link respectively)
+    if (!label) { label = state.src.slice(labelStart, labelEnd) }
+
+    ref = state.env.references[state.md.utils.normalizeReference(label)]
+    if (!ref) {
+      state.pos = oldPos
+      return false
+    }
+    href = ref.href
+    title = ref.title
+  }
+
+  //
+  // We found the end of the link, and know for a fact it's a valid link;
+  // so all that's left to do is to call tokenizer.
+  //
+  if (!silent) {
+    state.pos = labelStart
+    state.posMax = labelEnd
+
+    const newState = new state.md.inline.State(
+      state.src.slice(labelStart, labelEnd),
+      state.md,
+      state.env,
+      tokens = []
+    )
+    newState.md.inline.tokenize(newState)
+
+    token = state.push('image', 'img', 0)
+    token.attrs = attrs = [['src', href],
+      ['alt', '']]
+    token.children = tokens
+    if (title) {
+      attrs.push(['title', title])
+    }
+
+    if (width !== '') {
+      attrs.push(['width', width])
+    }
+
+    if (height !== '') {
+      attrs.push(['height', height])
+    }
+  }
+
+  state.pos = pos
+  state.posMax = max
+  return true
+}
+
+function parseNextNumber (str, pos, max) {
+  let code
+  const start = pos
+  const result = {
+    ok: false,
+    pos,
+    value: ''
+  }
+
+  code = str.charCodeAt(pos)
+
+  while ((pos < max && (code >= 0x30 /* 0 */ && code <= 0x39 /* 9 */)) || code === 0x25 /* % */) {
+    code = str.charCodeAt(++pos)
+  }
+
+  result.ok = true
+  result.pos = pos
+  result.value = str.slice(start, pos)
+
+  return result
+}
+
+function parseImageSize (str, pos, max) {
+  let code
+  const result = {
+    ok: false,
+    pos: 0,
+    width: '',
+    height: ''
+  }
+
+  if (pos >= max) { return result }
+
+  code = str.charCodeAt(pos)
+
+  if (code !== 0x3d /* = */) { return result }
+
+  pos++
+
+  // size must follow = without any white spaces as follows
+  // (1) =300x200
+  // (2) =300x
+  // (3) =x200
+  code = str.charCodeAt(pos)
+  if (code !== 0x78 /* x */ && (code < 0x30 || code > 0x39) /* [0-9] */) {
+    return result
+  }
+
+  // parse width
+  const resultW = parseNextNumber(str, pos, max)
+  pos = resultW.pos
+
+  // next charactor must be 'x'
+  code = str.charCodeAt(pos)
+  if (code !== 0x78 /* x */) { return result }
+
+  pos++
+
+  // parse height
+  const resultH = parseNextNumber(str, pos, max)
+  pos = resultH.pos
+
+  result.width = resultW.value
+  result.height = resultH.value
+  result.pos = pos
+  result.ok = true
+  return result
+}
+
+export default (md) => {
+  md.inline.ruler.before('emphasis', 'image', renderImSize)
+}