about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Makefile10
-rw-r--r--README.md3
-rw-r--r--adapter/format/md2html.nim380
-rw-r--r--res/mime.types1
-rw-r--r--src/config/config.nim35
5 files changed, 413 insertions, 16 deletions
diff --git a/Makefile b/Makefile
index 7b13deb1..ba29138f 100644
--- a/Makefile
+++ b/Makefile
@@ -45,7 +45,8 @@ all: $(OUTDIR_BIN)/cha $(OUTDIR_BIN)/mancha $(OUTDIR_CGI_BIN)/http \
 	$(OUTDIR_CGI_BIN)/cha-finger $(OUTDIR_CGI_BIN)/about \
 	$(OUTDIR_CGI_BIN)/data $(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp \
 	$(OUTDIR_CGI_BIN)/man $(OUTDIR_CGI_BIN)/spartan \
-	$(OUTDIR_LIBEXEC)/urldec $(OUTDIR_LIBEXEC)/urlenc
+	$(OUTDIR_LIBEXEC)/urldec $(OUTDIR_LIBEXEC)/urlenc \
+	$(OUTDIR_LIBEXEC)/md2html
 
 $(OUTDIR_BIN)/cha: lib/libquickjs.a src/*.nim src/**/*.nim src/**/*.c res/* \
 		res/**/* res/map/idna_gen.nim
@@ -81,6 +82,11 @@ $(OUTDIR_LIBEXEC)/gopher2html: adapter/format/gopher2html.nim \
 	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/gopher2html" \
 		-o:"$(OUTDIR_LIBEXEC)/gopher2html" adapter/format/gopher2html.nim
 
+$(OUTDIR_LIBEXEC)/md2html: adapter/format/md2html.nim
+	@mkdir -p "$(OUTDIR_LIBEXEC)"
+	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/md2html" \
+		-o:"$(OUTDIR_LIBEXEC)/md2html" adapter/format/md2html.nim
+
 GMIFETCH_CFLAGS = -Wall -Wextra -std=c89 -pedantic -lcrypto -lssl -g -O3
 $(OUTDIR_CGI_BIN)/gmifetch: adapter/protocol/gmifetch.c
 	@mkdir -p "$(OUTDIR_CGI_BIN)"
@@ -216,6 +222,7 @@ install:
 	install -m755 "$(OUTDIR_CGI_BIN)/ftp" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_CGI_BIN)/gopher" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_LIBEXEC)/gopher2html" $(LIBEXECDIR_CHAWAN)
+	install -m755 "$(OUTDIR_LIBEXEC)/md2html" $(LIBEXECDIR_CHAWAN)
 	install -m755 "$(OUTDIR_LIBEXEC)/gmi2html" $(LIBEXECDIR_CHAWAN)
 	install -m755 "$(OUTDIR_CGI_BIN)/gmifetch" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_CGI_BIN)/cha-finger" $(LIBEXECDIR_CHAWAN)/cgi-bin
@@ -253,6 +260,7 @@ uninstall:
 	rm -f $(LIBEXECDIR_CHAWAN)/cgi-bin/spartan
 	rmdir $(LIBEXECDIR_CHAWAN)/cgi-bin || true
 	rm -f $(LIBEXECDIR_CHAWAN)/gopher2html
+	rm -f $(LIBEXECDIR_CHAWAN)/md2html
 	rm -f $(LIBEXECDIR_CHAWAN)/gmi2html
 	rm -f $(LIBEXECDIR_CHAWAN)/urldec
 	rm -f $(LIBEXECDIR_CHAWAN)/urlenc
diff --git a/README.md b/README.md
index 696ed4ec..11facc87 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,8 @@ the configuration; please consult [doc/config.md](doc/config.md) for details.)
 
 ### Can I view Markdown files using Chawan?
 
-[Yes.](doc/mailcap.md)
+Yes; Chawan now has a built-in markdown converter. If you don't like it, you
+can always [replace it](doc/mailcap.md) with e.g. pandoc.
 
 ### Why write another web browser?
 
diff --git a/adapter/format/md2html.nim b/adapter/format/md2html.nim
new file mode 100644
index 00000000..1d68e7b9
--- /dev/null
+++ b/adapter/format/md2html.nim
@@ -0,0 +1,380 @@
+import std/strutils
+
+proc toggle[T](s: var set[T], t: T): bool =
+  result = t notin s
+  if result:
+    s.incl(t)
+  else:
+    s.excl(t)
+
+type BracketState = enum
+  bsNone, bsInBracketRef, bsInBracket, bsAfterBracket, bsInParen, bsInImage
+
+proc getId(line: openArray[char]): string =
+  result = ""
+  var i = 0
+  var bs = bsNone
+  var escape = false
+  while i < line.len:
+    let c = line[i]
+    if bs == bsInParen:
+      if escape:
+        escape = false
+        inc i
+        continue
+      if c == ')':
+        bs = bsNone
+      elif c == '\\':
+        escape = true
+      inc i
+      continue
+    case c
+    of 'A'..'Z': result &= char(int(c) - int('A') + int('a'))
+    of 'a'..'z', '-', '_', '.': result &= c
+    of ' ': result &= '-'
+    of '[':
+      if bs != bsNone:
+        bs = bsInBracket
+    of ']':
+      if bs == bsInBracket:
+        bs = bsAfterBracket
+    of '(':
+      if bs == bsAfterBracket:
+        bs = bsInParen
+    else: discard
+    inc i
+
+type InlineState = enum
+  isItalic, isBold, isCode, isComment
+
+const AsciiWhitespace = {' ', '\t', '\n', '\r'}
+proc parseInline(line: openArray[char]) =
+  var state: set[InlineState] = {}
+  var bs = bsNone
+  var i = 0
+  var bracketChars = ""
+  var quote = false
+  var image = false
+  template append(s: untyped) =
+    if bs in {bsInBracketRef, bsInBracket}:
+      bracketChars &= s
+    else:
+      stdout.write(s)
+  while i < line.len:
+    let c = line[i]
+    if bs == bsAfterBracket and c != '(':
+      stdout.write("[" & bracketChars & "]")
+      bracketChars = ""
+      bs = bsNone
+      image = false
+    if quote:
+      append c
+    elif isComment in state:
+      if i + 2 < line.len and line.toOpenArray(i, i + 2) == "-->":
+        state.excl(isComment)
+        append "-->"
+        i += 2
+      else:
+        append c
+    elif isCode in state:
+      case c
+      of '<': append "&lt;"
+      of '>': append "&gt;"
+      of '"': append "&quot;"
+      of '\'': append "&apos;"
+      of '`':
+        append "</CODE>"
+        state.excl(isCode)
+      else: append c
+    elif c == '\\':
+      quote = true
+    elif c == '*' or c == '_' and (i == 0 or line[i - 1] in AsciiWhitespace):
+      if i + 1 < line.len and line[i + 1] == c:
+        if state.toggle(isBold):
+          append "<B>"
+        else:
+          append "</B>"
+        inc i
+      else:
+        if state.toggle(isItalic):
+          stdout.write("<I>")
+        else:
+          stdout.write("</I>")
+    elif c == '`':
+      state.incl(isCode)
+      append "<CODE>"
+    elif c == '!' and bs == bsNone and i + 1 < line.len and line[i + 1] == '[':
+      image = true
+    elif c == '[' and bs == bsNone:
+      bs = bsInBracket
+      if i + 1 < line.len and line[i + 1] == '^':
+        inc i
+        bs = bsInBracketRef
+    elif c == ']' and bs == bsInBracketRef:
+      let id = bracketChars.getId()
+      stdout.write("<A HREF='#" & id & "'>" & bracketChars & "</A>")
+      bracketChars = ""
+    elif c == ']' and bs == bsInBracket:
+      bs = bsAfterBracket
+    elif c == '(' and bs == bsAfterBracket:
+      if image:
+        stdout.write("<IMG SRC='")
+      else:
+        stdout.write("<A HREF='")
+      bs = bsInParen
+    elif c == ')' and bs == bsInParen:
+      if image:
+        stdout.write("' ALT='" & bracketChars & "'>")
+      else:
+        stdout.write("'>" & bracketChars & "</A>")
+      image = false
+      bracketChars = ""
+      bs = bsNone
+    elif c == '\'' and bs == bsInParen:
+      stdout.write("&apos;")
+    elif i + 4 < line.len and line.toOpenArray(i, i + 3) == "<!--":
+      append "<!--"
+      i += 3
+      state.incl(isComment)
+    else:
+      append c
+    inc i
+  if bracketChars != "":
+    stdout.write(bracketChars)
+  if isBold in state:
+    stdout.write("</B>")
+  if isItalic in state:
+    stdout.write("</I>")
+
+proc parseHash(line: openArray[char]): bool =
+  var n = -1
+  for i, c in line:
+    if line[i] != '#':
+      if line[i] != ' ':
+        return false
+      n = i + 1
+      break
+  if n == -1:
+    return false
+  n = min(n, 6)
+  let L = n
+  var H = line.high
+  for i in countdown(line.high, L):
+    if line[i] != '#':
+      if line[i] != ' ':
+        break
+      H = i - 1
+      break
+  H = max(L - 1, H)
+  let id = line.toOpenArray(L, H).getId()
+  stdout.write("<H" & $n & " id='" & id & "'>")
+  line.toOpenArray(L, H).parseInline()
+  stdout.write("</H" & $n & ">\n")
+  return true
+
+type ListType = enum
+  ltOl, ltUl
+
+proc getListDepth(line: string): tuple[depth, len: int, ol: ListType] =
+  var depth = 0
+  for i, c in line:
+    if c == '\t':
+      depth += 8
+    elif c == ' ':
+      inc depth
+    elif c == '*':
+      let i = i + 1
+      if i < line.len and line[i] in {'\t', ' '}:
+        return (depth, i, ltUl)
+      break
+    elif c in {'0'..'9'}:
+      let i = i + 1
+      if i < line.len and line[i] == '.':
+        let i = i + 1
+        if i < line.len and line[i] in {'\t', ' '}:
+          return (depth, i, ltOl)
+      break
+    else:
+      break
+  return (-1, -1, ltUl)
+
+proc matchHTMLPreStart(line: string): bool =
+  var tagn = ""
+  for i, c in line:
+    if i == 0:
+      if c != '<':
+        return false
+      continue
+    if c in {' ', '\t', '>'}:
+      break
+    if c notin {'A'..'Z', 'a'..'z'}:
+      return false
+    tagn &= c.toLowerAscii()
+  return tagn in ["pre", "script", "style", "textarea"]
+
+proc matchHTMLPreEnd(line: string): bool =
+  var tagn = ""
+  for i, c in line:
+    if i == 0:
+      if c != '<':
+        return false
+      continue
+    if i == 1:
+      if c != '/':
+        return false
+      continue
+    if c in {' ', '\t', '>'}:
+      break
+    if c notin {'A'..'Z', 'a'..'z'}:
+      return false
+    tagn &= c.toLowerAscii()
+  return tagn in ["pre", "script", "style", "textarea"]
+
+type
+  BlockType = enum
+    btNone, btPar, btList, btPre, btHTML, btHTMLPre, btComment
+
+  ParseState = object
+    blockType: BlockType
+    blockData: string
+    listDepth: int
+    lists: seq[ListType]
+    hasp: bool
+    reprocess: bool
+
+proc pushList(state: var ParseState, t: ListType) =
+  case t
+  of ltOl: stdout.write("<OL>\n<LI>")
+  of ltUl: stdout.write("<UL>\n<LI>")
+  state.lists.add(t)
+
+proc popList(state: var ParseState) =
+  case state.lists.pop()
+  of ltOl: stdout.write("</OL>\n")
+  of ltUl: stdout.write("</UL>\n")
+
+proc parseNone(state: var ParseState, line: string) =
+  if line == "":
+    discard
+  elif line[0] == '#' and line.toOpenArray(1, line.high).parseHash():
+    discard
+  elif line.startsWith("<!--"):
+    state.blockType = btComment
+    state.reprocess = true
+  elif line[0] == '<' and line.find('>') == line.high:
+    state.blockType = if line.matchHTMLPreStart(): btHTMLPre else: btHTML
+    state.reprocess = true
+  elif line.len >= 3 and line.startsWith("```"):
+    state.blockType = btPre
+    stdout.write("<PRE>")
+  elif (let (n, len, t) = line.getListDepth(); n != -1):
+    state.blockType = btList
+    state.listDepth = n
+    state.hasp = false
+    state.pushList(t)
+    state.blockData = line.substr(len) & "\n"
+  else:
+    state.blockType = btPar
+    state.hasp = true
+    stdout.write("<P>\n")
+    state.reprocess = true
+
+proc parsePre(state: var ParseState, line: string) =
+  if line.startsWith("```"):
+    state.blockType = btNone
+    stdout.write("</PRE>\n")
+  else:
+    stdout.write(line & "\n")
+
+proc parseList(state: var ParseState, line: string) =
+  if line == "":
+    state.blockData.parseInline()
+    state.blockData = ""
+    while state.lists.len > 0:
+      state.popList()
+    state.blockType = btNone
+  elif (let (n, len, t) = line.getListDepth(); n != -1):
+    state.blockData.parseInline()
+    state.blockData = ""
+    if n < state.listDepth:
+      if state.lists.len > 0:
+        state.popList()
+      else:
+        state.pushList(t)
+    elif n > state.listDepth:
+      state.pushList(t)
+    stdout.write("<LI>")
+    state.listDepth = n
+    state.blockData = line.substr(len) & "\n"
+  else:
+    state.blockData &= line & "\n"
+
+proc parsePar(state: var ParseState, line: string) =
+  if line == "":
+    state.blockData.parseInline()
+    state.blockData = ""
+    state.blockType = btNone
+  elif line[0] == '<' and line.find('>') == line.high:
+    state.blockData.parseInline()
+    state.blockData = ""
+    if line.matchHTMLPreStart():
+      state.blockType = btHTMLPre
+    else:
+      state.blockType = btHTML
+    state.reprocess = true
+  elif line.len >= 3 and line.startsWith("```"):
+    state.blockData.parseInline()
+    state.blockData = ""
+    state.blockType = btPre
+    state.hasp = false
+    stdout.write("<PRE>")
+  else:
+    state.blockData &= line & "\n"
+
+proc parseHTML(state: var ParseState, line: string) =
+  if state.hasp:
+    state.hasp = false
+    stdout.write("</P>\n")
+  if line == "":
+    state.blockData.parseInline()
+    state.blockData = ""
+    state.blockType = btNone
+  else:
+    state.blockData &= line & "\n"
+
+proc parseHTMLPre(state: var ParseState, line: string) =
+  if state.hasp:
+    state.hasp = false
+    stdout.write("</P>\n")
+  if line.matchHTMLPreEnd():
+    stdout.write(state.blockData)
+    state.blockData = ""
+    state.blockType = btNone
+  else:
+    state.blockData &= line & "\n"
+
+proc parseComment(state: var ParseState, line: string) =
+  let i = line.find("-->")
+  if i != -1:
+    stdout.write(line.substr(0, i + 2))
+    state.blockType = btNone
+    line.substr(i + 3).parseInline()
+  else:
+    stdout.write(line & "\n")
+
+proc main() =
+  var line: string
+  var state = ParseState(listDepth: -1)
+  while state.reprocess or stdin.readLine(line):
+    state.reprocess = false
+    case state.blockType
+    of btNone: state.parseNone(line)
+    of btPre: state.parsePre(line)
+    of btList: state.parseList(line)
+    of btPar: state.parsePar(line)
+    of btHTML: state.parseHTML(line)
+    of btHTMLPre: state.parseHTMLPre(line)
+    of btComment: state.parseComment(line)
+  state.blockData.parseInline()
+
+main()
diff --git a/res/mime.types b/res/mime.types
index cb3ad6cc..b4a9065f 100644
--- a/res/mime.types
+++ b/res/mime.types
@@ -8,3 +8,4 @@ application/xhtml+xml	xhtml	xhtm	xht
 text/plain		txt
 text/css		css
 image/png		png
+text/markdown		md
diff --git a/src/config/config.nim b/src/config/config.nim
index 59cdad50..96b78bb7 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -356,20 +356,9 @@ proc getMailcap*(config: Config): tuple[mailcap: Mailcap, errs: seq[string]] =
   let gopherPath = gopherPath0.unquote().get
   const geminiPath0 = ChaPath("${%CHA_LIBEXEC_DIR}/gmi2html")
   let geminiPath = geminiPath0.unquote().get
-  var mailcap = @[
-    MailcapEntry(
-      mt: "text",
-      subt: "gopher",
-      cmd: gopherPath,
-      flags: {HTMLOUTPUT}
-    ),
-    MailcapEntry(
-      mt: "text",
-      subt: "gemini",
-      cmd: geminiPath,
-      flags: {HTMLOUTPUT}
-    )
-  ]
+  const mdPath0 = ChaPath("${%CHA_LIBEXEC_DIR}/md2html")
+  let mdPath = mdPath0.unquote().get
+  var mailcap: Mailcap = @[]
   var errs: seq[string]
   var found = false
   for p in config.external.mailcap:
@@ -381,6 +370,24 @@ proc getMailcap*(config: Config): tuple[mailcap: Mailcap, errs: seq[string]] =
       else:
         errs.add(res.error)
       found = true
+  mailcap.add(MailcapEntry(
+      mt: "text",
+      subt: "gopher",
+      cmd: gopherPath,
+      flags: {HTMLOUTPUT}
+  ))
+  mailcap.add(MailcapEntry(
+    mt: "text",
+    subt: "gemini",
+    cmd: geminiPath,
+    flags: {HTMLOUTPUT}
+  ))
+  mailcap.add(MailcapEntry(
+    mt: "text",
+    subt: "markdown",
+    cmd: mdPath,
+    flags: {HTMLOUTPUT}
+  ))
   if not found:
     mailcap.insert(MailcapEntry(
       mt: "*",