~kungtotte/dtt

ref: f7dd054d3641384d1fe390f3f772fd3c505a71cb dtt/src/dtt.nim -rw-r--r-- 9.6 KiB View raw
f7dd054dThomas Landin Find URL targets for blog posts 4 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import os, strutils, strformat
import parsecfg, strscans
import unicode, times
import algorithm

import docopt
import mustache
import markdown

import tmpl

const
  bin = "dtt"
  css_dir = "css"
  content_dir = "content"
  template_dir = "templates"
  output_dir = "output"
  blog_dirs = ["blog", "blogs", "post", "posts", "articles", "journal", "journals"]
  outFilePerms = {fpUserExec, fpUserWrite, fpUserRead, fpGroupExec,
                  fpGRoupRead, fpOthersExec, fpOthersRead}

let
  version = "0.2.0"

type
  DttError {.pure.} = enum
    CreateDirectoryFailed,
    DirectoryNotFound,
    DirectoryNotEmpty,
    NotADttDirectory,
    RemoveDirectoryFailed,
    UnknownError

proc initCmd(dir: string, force: bool = false) =
  let wd = if dir == "nil": getCurrentDir() else: getCurrentDir() / dir
  if not existsDir(wd):
    createDir(wd)

  # TODO: Update this so that init will re-create any -missing- files
  # but won't overwrite existing files.
  # My hunch is that the best way to do this is to have a list of things
  # to create and as we walk that list we check them one by one if the target
  # already exists, overriding it with `force`.
  if not force:
    for f in walkDirRec(wd, {pcFile, pcDir}, relative = true):
      case getFileInfo(wd / f).kind:
        of pcDir:
          if os.existsDir(wd / f):
            stderr.writeLine(fmt"{bin}: working directory not empty, add -f/--force to overwrite")
            quit(ord(DttError.DirectoryNotEmpty))
        of pcFile:
          if os.existsFile(wd / f):
            stderr.writeLine(fmt"{bin}: working directory not empty, add -f/--force to overwrite")
            quit(ord(DttError.DirectoryNotEmpty))
        else:
          stderr.writeLine(fmt"Unknown error occurred: {f} is neither file nor directory")
          quit(ord(DttError.UnknownError))
  try:
    os.createDir(wd / content_dir)
    os.createDir(wd / template_dir)
    os.createDir(wd / output_dir)
    os.createDir(wd / output_dir / css_dir)
  except OSError:
    let
      e = repr(getCurrentException())
      msg = getCurrentExceptionMsg()
    stderr.writeLine(fmt"{bin}: Exception: {e} with message: {msg}")
    quit(ord(DttError.CreateDirectoryFailed))

  writeFile(wd / "config.cfg", tmpl.cfg)
  writeFile(wd / template_dir / "page.mustache", tmpl.page)
  writeFile(wd / template_dir / "post.mustache", tmpl.post)
  writeFile(wd / template_dir / "header.mustache", tmpl.header)
  writeFile(wd / template_dir / "footer.mustache", tmpl.footer)
  writeFile(wd / output_dir / css_dir / "style.css", tmpl.style)
  setFilePermissions(wd / output_dir / css_dir / "style.css", outFilePerms)


proc cleanCmd(all: bool = false, force: bool = false) =
  let wd = if all: os.getCurrentDir() else: os.getCurrentDir() / output_dir
  if not force and not existsFile(os.getCurrentDir() / "config.cfg"):
    stderr.writeLine(fmt"""{bin}: config.cfg not found, this doesn't look like a {bin} directory. are you sure you wish to clean it? Confirm action with -f/--force.""")
    quit(ord(DttError.NotADttDirectory))
  if os.existsDir(wd):
    try:
      os.removeDir(wd)
    except OSError:
      let
        e = repr(getCurrentException())
        msg = getCurrentExceptionMsg()
      stderr.writeLine(fmt"{bin}: Exception {e} with message: {msg}")
      quit(ord(DttError.RemoveDirectoryFailed))
  else:
    stderr.writeLine(fmt"{bin}: {wd} does not exist.")
    quit(ord(DttError.DirectoryNotFound))

proc loadMetaData(cfgdict: Config, mcontext: var Context) =
  # TODO: Make dtt move backwards up the hierarchy to look for config.cfg
  # so we don't error out if someone happens to run `dtt build` inside the
  # content  directory instead of the root dtt directory.

  # Load meta-data from config file into the supplied
  # mustache context
  mcontext["charset"] = cfgdict.getSectionValue("Site", "charset")
  mcontext["language"] = cfgdict.getSectionValue("Site","language")
  mcontext["page_title"] = cfgdict.getSectionValue("Site","title")
  mcontext["content_license"] = cfgdict.getSectionValue("Site","license")
  mcontext["content_license_url"] = cfgdict.getSectionValue("Site","license_url")
  mcontext["email"] = cfgdict.getSectionValue("Author", "email")
  mcontext["author"] = cfgdict.getSectionValue("Author", "name")

proc renderTemplate(context: Context, file: string,
                    tmpl: string = "page",
                    tmpldir: string = "templates"): string =
  let
    tpl = readFile(tmpldir / tmpl & ".mustache")
    (_,_,ext) = splitFile(file)
  if ext == ".md":
    context["content"] = markdown(readFile(file))
    result = tpl.render(context)

proc findLinks(content_dir: string, excludes: seq[Table[string,string]]): seq[Table[string, string]] =
  var blog_ids: seq[string]
  for b in excludes:
    blog_ids.add(b["id"])

  for f in walkDirRec(content_dir, relative = true):
    let (dir, name, _) = splitFile(f)
    let
      target = if dir != "": dir & "/" & name else: name
      title = name.title()
    if not (name in blog_ids):
      result.add({"target": target, "title": title}.toTable)

proc findTemplate(filename: string, tmpldir: string, isBlog: bool = false): string =
  let tmpl = case isBlog:
    of true:
      "post"
    of false:
      if existsFile(tmpldir / filename & ".mustache"): filename else: "page"

  result = tmpl

proc getSlug(filename: string, hasYMD: bool): string =
  result = filename
  if hasYMD:
    result = result[11..^1]
  result = result.title().multiReplace(("-", " "), ("_", " "))

proc getBlogDate(file: string, datestring: tuple[year, month, day: int]): string =
  if datestring.year > 0:
    let
      # fmt will use parseInt and kill leading zeroes, and this is the easiest
      # way to get a proper yyyy-MM-dd format using format strings
      y = intToStr(datestring.year)
      m = intToStr(datestring.month, 2)
      d = intToStr(datestring.day, 2)
    result = fmt"{y}-{m}-{d}"
  else:
    # We guess that the creation time of the post is usually what you want as
    # the datestamp of the blog. If you don't want this, you can rename the file
    # using the yyyy-MM-dd-slug.md format.
    result = getCreationTime(file).format("yyyy-MM-dd")

proc dateSort(x, y: Table[string, string]): int =
  let xdate = x["date"].replace("-","").parseInt
  let ydate = y["date"].replace("-","").parseInt
  if xdate > ydate or xdate == ydate:
    1
  else:
    -1

proc findBlogPosts(directory: string, tmpldir: string): seq[Table[string, string]] =
  for f in walkDirRec(directory):
    let (dir, name, ext) = splitFile(f)
    if ext == ".md":
      var
        year, month, day: int
      discard scanf(name, "$i-$i-$i", year, month, day)
      let
        basedir = lastPathPart(dir)
        isBlog = if basedir in blog_dirs or year > 0: true else: false
      if isBlog:
        let
          date = getBlogDate(f, (year, month, day))
          tmpl = findTemplate(name, tmpldir, isBlog)
        var blogContext = newContext(searchDirs = @[tmpldir])
        let
          subdir = if dir != directory: lastPathPart(dir) else: ""
          target = if subdir != "": subdir & "/" & name & ".html" else: name & ".html"
        blogContext["slug"] = getSlug(name, year > 0)
        blogContext["date"] = date
        blogContext["blog_link"] = target
        let
          rendered = blogContext.renderTemplate(f, tmpl)
        let tbl = {"id": name, "post": rendered, "date": date}.toTable
        result.add(tbl)
  sort(result, dateSort, Descending)

proc buildCmd() =
  let
    wd = os.getCurrentDir()
    condir = wd / content_dir
    tmpldir = wd / template_dir
    outdir = wd / output_dir
  var
    mcontext = newContext(searchDirs = @[tmpldir])

  if not existsDir(outdir):
    createDir(outdir)

  let config = loadConfig(wd / "config.cfg")
  loadMetaData(config, mcontext)
  let blogs = findBlogPosts(condir, tmpldir)
  mcontext["links"] = findLinks(condir, excludes=blogs)

  for f in walkDirRec(condir, {pcFile, pcDir}, relative = true):
    case getFileInfo(condir / f).kind:
      of pcDir:
        createDir(outdir / f)
      of pcFile:
        let
          (dir, fname, ext) = splitFile(f)
          savepath = joinPath(outdir, dir)
        if ext == ".md":
          let tmpl = findTemplate(fname, tmpldir)
          if blogs.len > 0:
            let
              cfg_bcount = config.getSectionValue("Site", "blogs_per_page").parseInt
              bcount = min(blogs.len - 1, cfg_bcount)
            mcontext["blog_posts"] = blogs[0..bcount]
          let
            output  = mcontext.renderTemplate(condir / f, tmpl, tmpldir = tmpldir)
            outfile = savepath / fname & ".html"
          writeFile(outfile, output)
          setFilePermissions(outfile, outFilePerms)
        else:
          let outfile = savepath / fname & ext
          copyFile(condir / f, outfile)
          setFilePermissions(outfile, outFilePerms)
      else:
        stderr.writeLine(fmt"Unknown error occurred: {f} is neither file nor directory")
        quit(ord(DttError.UnknownError))


let doc = """

dtt (Do The Thing)

An extremely straight-forward static site generator that will convert
Markdown files into html and publish them along with any static content
(images, CSS, html files, etc.) to an output dir to be served as static
content from any host.

usage:
  dtt init [options] [DIR]
  dtt build [options]
  dtt clean [options]

Options:
  -f --force  Force init to overwrite existing files (if any)
  -a --all    Force clean to delete every file created by dtt, not just output directory
  -h --help   Show this screen.
  --version   Show version.
"""
when isMainModule:
  let args = docopt(doc, version=fmt"{bin} {version}")

  if args["init"]:
    initCmd($args["DIR"], args["--force"])
  elif args["clean"]:
    cleanCmd(args["--all"], args["--force"])
  elif args["build"]:
    buildCmd()