├── .gitignore
├── LICENSE.md
├── ipsum.nimrod.cfg
├── ipsumgenera.nimble
├── layouts
├── article.html
├── articles.html
├── atom.xml
├── default.html
├── static.html
└── tag.html
├── readme.md
└── src
├── config.nim
├── ipsum.nim
├── metadata.nim
└── rstrender.nim
/.gitignore:
--------------------------------------------------------------------------------
1 | # Wildcard
2 | *.swp
3 | .DS_Store
4 |
5 | # Absolute paths
6 | /ipsum
7 | /nimcache
8 | /output
9 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2013 Dominik Picheta
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of
5 | this software and associated documentation files (the "Software"), to deal in
6 | the Software without restriction, including without limitation the rights to
7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8 | of the Software, and to permit persons to whom the Software is furnished to do
9 | so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 |
--------------------------------------------------------------------------------
/ipsum.nimrod.cfg:
--------------------------------------------------------------------------------
1 | --path:"$lib/packages/docutils"
2 | --verbosity:"0"
3 |
--------------------------------------------------------------------------------
/ipsumgenera.nimble:
--------------------------------------------------------------------------------
1 | version = "0.1.1"
2 | author = "Dominik Picheta"
3 | description = "A static blog generator."
4 | license = "MIT"
5 | srcDir = "src"
6 | bin = @["ipsum"]
7 |
8 | requires "nim >= 0.19.4"
9 |
--------------------------------------------------------------------------------
/layouts/article.html:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/layouts/tag.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | A tag.html title: ${tag}
4 |
5 |
6 |
7 | ${body}
8 |
9 |
10 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # ipsum genera
2 |
3 | *ipsum genera* is a static blog generator written in Nimrod. It's something
4 | I hacked together in a couple of days so you're mostly on your own for now if
5 | you wish to use it. Of course, I love the name *ipsum genera* so much that I
6 | may just be willing to make this more user friendly.
7 |
8 | ## Quick manual
9 |
10 | To set up *ipsum* you first need to install [nimrod's nimble package
11 | manager](https://github.com/nim-lang/nimble). Once you have that installed
12 | you can run:
13 |
14 | ```
15 | git clone https://github.com/dom96/ipsumgenera
16 | cd ipsumgenera
17 | nimble install
18 | ```
19 |
20 | This will compile *ipsum* and copy it to your ``$nimbleDir/bin/`` directory. Add
21 | this directory to your ``$PATH`` environment variable so you can run *ipsum*
22 | anywhere. In the future if you need to update your *ipsum* version you will
23 | need to refresh that checkout and run ``nimble install`` again to overwrite your
24 | current copy.
25 |
26 | Now, go to the directory of your choice for storing your own website and
27 | create the structure to hold your content:
28 |
29 | ```
30 | cd ~/projects
31 | mkdir -p super_website/articles
32 | mkdir -p super_website/static
33 | mkdir -p super_website/layouts
34 | cd super_website
35 | ```
36 |
37 | Put articles in the ``articles`` folder with metadata similar to jekyll's:
38 | ```
39 | ---
40 | title: "GTK+: I love GTK and here is why."
41 | date: 2013-08-18 23:03
42 | tags: Nimrod, GTK
43 | ---
44 | ```
45 |
46 | Save the article as: ``2013-08-18-gtk-plus-i-love-gtk-and-here-is-why.rst``, or
47 | something else, the filename doesn't really matter, *ipsum* does not care as
48 | long as they have an ``rst`` extension. All other extension files are ignored.
49 |
50 | Put static files in the ``static`` folder. If the file ends in ``.rst`` it has
51 | to have metadata like a normal article, but the generated html will keep the
52 | relative path instead of getting a generated one from the date+title. If the
53 | file doesn't end in ``.rst``, it will simply be copied to the output website
54 | directoy.
55 |
56 | You then need to create some layouts and put them into the layouts folder.
57 | You will need the following layouts:
58 |
59 | * article.html -- Template for an article.
60 | * static.html -- Template for a static file.
61 | * default.html -- Template for the index.html page, this includes the article
62 | list.
63 | * tag.html -- Template for the specific tag page, this will include a list of
64 | articles belonging to a certain tag.
65 |
66 | Layouts are simply html templates, *ipsum* will replace a couple of specific
67 | strings in the html templates when it's generating your blog. The format of
68 | these strings is ${key}, and the supported keys are:
69 |
70 | * ``${body}`` -- In ``default.html`` this is the article list, in
71 | ``article.html`` this will be the actual article text.
72 | * ``${title}`` -- Article title in ``article.html``, otherwise the blog title
73 | from ``ipsum.ini``.
74 | * ``${date}`` or ``${pubDate}`` -- Article publication date in ``article.html``
75 | extracted from the metadata. The date has to be in format **YYYY-MM-DD
76 | hh:mm**.
77 | * ``${modDate}`` -- Article modification date in ``article.html`` extracted
78 | from the metadata, or if not available from the file's last modification
79 | timestamp.
80 | * ``${prefix}`` -- The path to the root in ``article.html`` **only**.
81 | * ``${tag}`` -- The tag name, ``tag.html`` **only**.
82 |
83 | Where ``article.html`` is mentioned, the same applies for ``static.html``. You
84 | will also need to create an ``ipsum.ini`` file, the file should contain the
85 | following information:
86 |
87 | ```ini
88 | title = "Blog title here"
89 | url = "http://this.blog.me"
90 | author = "Your Name"
91 | ```
92 |
93 | The information from this config is also available in your templates, the key
94 | names match the names in the config file. Additional options you can add to the
95 | ``ipsum.ini`` file:
96 |
97 | * ``numRssEntries`` - Integer with the maximum number of generated entries in
98 | the rss feed. If you don't specify a value for this, the value ``10`` will be
99 | used by default.
100 |
101 | Once you're done with the setup, simply execute ``ipsum`` and your blog should
102 | be generated before you can even blink!
103 |
104 | ## Metadata reference
105 |
106 | * ``title`` - Article title.
107 | * ``date`` or ``pubDate`` - Date the article was written.
108 | * ``modDate`` - Specifies a posterior date the article was updated. If you
109 | don't specify this tag, it will be filled in with the file's last
110 | modification timestamp.
111 | * ``tags`` - Tags belonging to the article.
112 | * ``draft`` - If ``true`` article will not be generated.
113 |
114 | ## License
115 |
116 | [MIT license](LICENSE.md).
117 |
--------------------------------------------------------------------------------
/src/config.nim:
--------------------------------------------------------------------------------
1 | import parsecfg, streams, strutils, os
2 |
3 | type
4 | TConfig* = object
5 | title*: string
6 | url*: string
7 | author*: string
8 | numRssEntries*: int
9 |
10 | proc initConfig(): TConfig =
11 | result.title = ""
12 | result.url = ""
13 | result.author = ""
14 | result.numRssEntries = 10
15 |
16 | proc validateConfig(config: TConfig) =
17 | template ra(field: string) =
18 | raise newException(ValueError,
19 | "You need to specify the '$1' field in the config." % field)
20 | if config.title == "":
21 | ra("title")
22 | if config.author == "":
23 | ra("author")
24 | if config.url == "":
25 | ra("url")
26 | if config.numRssEntries < 0:
27 | raise newException(ValueError,
28 | "The numRssEntries value can't be negative.")
29 |
30 | proc parseConfig*(filename: string): TConfig =
31 | if not filename.existsFile:
32 | raise newException(ValueError, "Missing '" & filename & "'")
33 | result = initConfig()
34 | var file = newFileStream(filename, fmRead)
35 | var cfg: CfgParser
36 | open(cfg, file, filename)
37 | while true:
38 | let ev = cfg.next()
39 | case ev.kind
40 | of cfgSectionStart:
41 | raise newException(ValueError, "No sections supported.")
42 | of cfgKeyValuePair, cfgOption:
43 | case ev.key.normalize
44 | of "title":
45 | result.title = ev.value
46 | of "url":
47 | result.url = ev.value
48 | of "author":
49 | result.author = ev.value
50 | of "numrssentries":
51 | result.numRssEntries = ev.value.parseInt
52 | of cfgError:
53 | raise newException(ValueError, ev.msg)
54 | of cfgEof:
55 | break
56 | cfg.close()
57 | file.close()
58 | validateConfig(result)
59 |
--------------------------------------------------------------------------------
/src/ipsum.nim:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2013 Dominik Picheta
2 | # Licensed under MIT license.
3 |
4 | import os, times, strutils, algorithm, strtabs, parseutils, tables, xmltree,
5 | sequtils, unicode, unidecode
6 |
7 | import metadata, rstrender, config
8 |
9 | const
10 | articleDir = "articles"
11 | outputDir = "output"
12 | staticDir = "static"
13 | tagPagePrefix = "../"
14 |
15 |
16 | proc normalizeTitle(title: string): string =
17 | for i in title.unidecode.runes():
18 | case char(i)
19 | of ':', '-', '=', '*', '^', '%', '$', '#', '@', '!', '{', '}', '[', ']',
20 | '<', '>', ',', '.', '?', '|', '~', '\\', '/', '"', '\'':
21 | discard
22 | of '&': result.add "-and-"
23 | of '+': result.add "-plus-"
24 | of ' ': result.add '-'
25 | else:
26 | result.add i.toLower.char()
27 |
28 | proc normalizeTag(tag: string): string =
29 | for i in tag.runes():
30 | case i
31 | of Rune(' '):
32 | result.add '-'
33 | else:
34 | result.add i.toLower.toUTF8()
35 |
36 | proc joinUrl(x: varargs[string]): string =
37 | for i in x:
38 | var cleanI = i
39 | if cleanI[0] == '/': cleanI = cleanI[1 .. ^1]
40 | if cleanI.endsWith("/"): cleanI = cleanI[0 .. ^2]
41 |
42 | result.add "/" & cleanI
43 | result = result[1 .. ^1] # Get rid of the / at the start.
44 |
45 | proc genUrl(article: TArticleMetadata): string =
46 | # articles/2013/03/title.html
47 | joinUrl(articleDir, format(article.pubDate, "yyyy/MM"),
48 | article.title.normalizeTitle() & ".html")
49 |
50 | proc escapePath(s: string): string =
51 | ## Escapes a path to be valid according to RFC 3987.
52 | ##
53 | ## This proc does not perform the full work of parsing and filtering a full
54 | ## IRI, it is only used to filter local filename paths and escape their
55 | ## possible unicode characters for inclusion as rss identifiers. If you pass
56 | ## a full absolute URL, the scheme part will be malformed. A full correct
57 | ## implementation would handle the different parts of the URL correctly.
58 | ##
59 | ## This is a variation of the algorithm found in cgi.URLencode. Unicode
60 | ## characters are parsed correctly because Nimrod strings happen to be
61 | ## encoded in UTF8 and the rfc specifies that the encoded bytes need to
62 | ## translated to %HH format.
63 | result = newStringOfCap(s.len + s.len shr 2) # assume 12% non-alnum-chars
64 | for i in 0..s.len-1:
65 | case s[i]
66 | of 'a'..'z', 'A'..'Z', '0'..'9', '-', '.', '~', '/', '_': add(result, s[i])
67 | else:
68 | add(result, '%')
69 | add(result, toHex(ord(s[i]), 2))
70 |
71 | proc findArticles(): seq[string] =
72 | var dir = getCurrentDir() / articleDir
73 | for f in walkFiles(dir / "*.rst"):
74 | result.add(f)
75 |
76 | proc findStaticFiles(): seq[string] =
77 | ## Returns a list of files in the static subdirectory.
78 | ##
79 | ## Unlike findArticles, the returned paths are not absoulte, they are
80 | ## relative to the static directory. You need to prefix the results with
81 | ## staticDir to reach the file.
82 | const valid = {pcFile, pcLinkToFile, pcDir, pcLinkToDir}
83 | let pruneLen = staticDir.len
84 | for f in walkDirRec(staticDir, valid):
85 | assert f.len > pruneLen + 1
86 | result.add(f[pruneLen + 1 ..< f.len])
87 |
88 | include "../layouts/articles.html" # TODO: Rename to articlelist.html?
89 | include "../layouts/atom.xml"
90 | const defaultArticleLayout = staticRead("../layouts/article.html")
91 | const defaultStaticLayout = staticRead("../layouts/static.html")
92 | const defaultDefaultLayout = staticRead("../layouts/default.html")
93 | const defaultTagLayout = staticRead("../layouts/tag.html")
94 |
95 | proc replaceKeys(s: string, kv: StringTableRef): string =
96 | var i = 0
97 | while i < s.len:
98 | case s[i]
99 | of '\\':
100 | if s[i+1] == '$':
101 | result.add("$")
102 | i.inc 2
103 | else:
104 | result.add(s[i])
105 | i.inc
106 | of '$':
107 | assert s[i+1] == '{'
108 | let key = captureBetween(s, '{', '}', i)
109 | if not hasKey(kv, key):
110 | raise newException(ValueError, "Key not found: " & key)
111 | result.add(kv[key])
112 | i.inc key.len + 3
113 | else:
114 | result.add s[i]
115 | i.inc
116 |
117 | proc createKeys(otherKeys: varargs[tuple[k, v: string]],
118 | cfg: TConfig): StringTableRef =
119 | result = newStringTable({"blog_title": cfg.title, "blog_url": cfg.url,
120 | "blog_author": cfg.author})
121 | for i in otherKeys:
122 | result[i.k] = i.v
123 |
124 | proc needsRefresh*(target: string, src: varargs[string]): bool =
125 | ## Returns true if target is missing or src has newer modification date.
126 | ##
127 | ## Copied from
128 | ## https://github.com/fowlmouth/nake/blob/078a99849a29a890fc22a14173ca4591a14dea86/nake.nim#L150
129 | assert len(src) > 0, "Pass some parameters to check for"
130 | var targetTime: int64
131 | try:
132 | targetTime = toUnix(getLastModificationTime(target))
133 | except OSError:
134 | return true
135 |
136 | for s in src:
137 | let srcTime = toUnix(getLastModificationTime(s))
138 | if srcTime > targetTime:
139 | return true
140 |
141 |
142 | proc generateArticle(filename, dest, style: string, meta: TArticleMetadata,
143 | cfg: TConfig) =
144 | ## Generates an html file from the rst `filename`.
145 | ##
146 | ## Pass as `dest` the relative final path of the input `filename`. `style` is
147 | ## the name of one of the files in th layouts subdirectory.
148 | let
149 | def =
150 | try:
151 | readFile(getCurrentDir() / "layouts" / style)
152 | except IOError:
153 | case style
154 | of "article.html":
155 | echo("Using internal layouts/article.html")
156 | defaultArticleLayout
157 | of "static.html":
158 | echo("Using internal layouts/static.html")
159 | defaultStaticLayout
160 | else:
161 | raise
162 | pubDate = format(meta.pubDate, "dd/MM/yyyy HH:mm")
163 | modDate = format(meta.modDate, "dd/MM/yyyy HH:mm")
164 | # Calculate prefix depending on the depth of `dest`.
165 | var prefix = ""
166 | for i in parentDirs(dest, inclusive = false):
167 | prefix = "../" & prefix
168 | let tags = renderTags(meta.tags, prefix)
169 | let output = replaceKeys(def,
170 | {"title": meta.title, "date": pubDate, "pubDate": pubDate,
171 | "modDate": modDate, "body": renderRst(meta.body, prefix),
172 | "prefix": prefix, "tags": tags}.createKeys(cfg))
173 | let path = getCurrentDir() / outputDir / dest
174 | createDir(path.splitFile.dir)
175 |
176 | writeFile(path, output)
177 |
178 | proc processStatic(cfg: TConfig): seq[TArticleMetadata] =
179 | ## Processes files found in the static subdirectory.
180 | ##
181 | ## Non rst files will be copied as is, rst files will be processed with the
182 | ## static template.
183 | ##
184 | ## The proc will return the list of the metadata for parsed rst files.
185 | let staticFilenames = findStaticFiles()
186 | for i in staticFilenames:
187 | let
188 | src = staticDir / i
189 | ext = i.splitFile.ext.toLower
190 | if ext == ".rst":
191 | let dest = changeFileExt(i, "html")
192 | echo("Processing ", getCurrentDir() / outputDir / dest)
193 | let meta = parseMetadata(src)
194 | if meta.isDraft:
195 | echo(" Article is a draft, omitting from article list.")
196 | continue
197 | result.add(meta)
198 | generateArticle(src, dest, "static.html", meta, cfg)
199 | else:
200 | let dest = getCurrentDir() / outputDir / i
201 | if dest.needsRefresh(src):
202 | echo "Copying ", dest
203 | createDir(dest.splitFile.dir)
204 | copyFileWithPermissions(src, dest)
205 |
206 | proc generateDefault(mds: seq[TArticleMetadata], cfg: TConfig) =
207 | let def =
208 | try:
209 | readFile(getCurrentDir() / "layouts" / "default.html")
210 | except IOError:
211 | echo("Using internal layouts/default.html")
212 | defaultDefaultLayout
213 | let output = replaceKeys(def,
214 | {"body": renderArticles(mds, ""), "prefix": ""}.createKeys(cfg))
215 | writeFile(getCurrentDir() / outputDir / "index.html", output)
216 |
217 | proc sortArticles(articles: var seq[TArticleMetadata]) =
218 | articles.sort do (x, y: TArticleMetadata) -> int:
219 | if timeInfoToTime(x.pubDate) > timeInfoToTime(y.pubDate):
220 | -1
221 | elif timeInfoToTime(x.pubDate) == timeInfoToTime(y.pubDate):
222 | 0
223 | else:
224 | 1
225 |
226 | proc processArticles(cfg: TConfig): seq[TArticleMetadata] =
227 | let articleFilenames = findArticles()
228 | for i in articleFilenames:
229 | echo("Processing ", i)
230 | let meta = parseMetadata(i)
231 | if not meta.isDraft:
232 | result.add(meta)
233 | else:
234 | echo(" Article is a draft, omitting from article list.")
235 | generateArticle(i, genURL(meta), "article.html", meta, cfg)
236 |
237 | # Sort articles from newest to oldest.
238 | sortArticles(result)
239 |
240 | proc generateTagPages(meta: seq[TArticleMetadata], cfg: TConfig) =
241 | var tags = initTable[string, seq[TArticleMetadata]]()
242 | for a in meta:
243 | for t in a.tags:
244 | let nt = t.normalizeTag
245 | if not tags.hasKey(nt):
246 | tags[nt] = @[]
247 | tags.mget(nt).add(a)
248 |
249 | let templ =
250 | try:
251 | readFile(getCurrentDir() / "layouts" / "tag.html")
252 | except IOError:
253 | echo("Using internal layouts/tag.html")
254 | defaultTagLayout
255 | createDir(getCurrentDir() / outputDir / "tags")
256 | for tag, articles in tags:
257 | var sorted = articles
258 | sortArticles(sorted)
259 | let output = replaceKeys(templ,
260 | {"body": renderArticles(sorted, tagPagePrefix), "tag": tag,
261 | "prefix": tagPagePrefix}.createKeys(cfg))
262 | writeFile(getCurrentDir() / outputDir / "tags" /
263 | tag.addFileExt("html"), output)
264 |
265 | proc generateAtomFeed(meta: seq[TArticleMetadata], cfg: TConfig) =
266 | # Prunes the article sequence according to the configuration limit.
267 | assert cfg.numRssEntries >= 0
268 | var meta = meta
269 | if meta.len > cfg.numRssEntries:
270 | meta.delete(cfg.numRssEntries + 1, 0:
8 | for i in node.sons:
9 | if i == nil:
10 | result.add(repeat(' ', indent + 2) & "NIL SON!!!\n"); continue
11 | result.add strRst(i, indent + 2)
12 |
13 | proc renderCodeBlock(n: PRstNode): string =
14 | ## Renders a block with code syntax highlighting.
15 | if n.sons[2] == nil: return
16 | var m = n.sons[2].sons[0]
17 | assert m.kind == rnLeaf
18 | var langstr = strip(getArgument(n))
19 | var lang: SourceLanguage
20 | if langstr == "":
21 | lang = langNim # default language
22 | else:
23 | lang = getSourceLanguage(langstr)
24 |
25 | result.add "
\n"
194 | else:
195 | echo("Unknown node kind in rst: ", node.kind)
196 | doAssert false
197 |
198 | proc renderRst*(text: string, articlePrefix: string, filename = "",
199 | absoluteUrls = ""): string =
200 | ## Returns the rst `text` string as rendered HTML.
201 | ##
202 | ## The `articlePrefix` string will replace strings in the form ${prefix} in
203 | ## the urls found in the rst `text`. You can pass here the absolute root URL
204 | ## to where you will place all the generated HTML files so that you can write
205 | ## links in the form ``${prefix}images/smiley.gif`` and they will resolve
206 | ## correctly from every subdirectory.
207 | ##
208 | ## The `filename` parameter is used for ornamental purposes, if something
209 | ## fails it will be displayed to the user there, but otherwise serves no real
210 | ## purpose.
211 | ##
212 | ## The `absoluteUrls` is only used for RSS HTML generation. Different RSS
213 | ## readers have different behaviours when resolving relative URLs, so if you
214 | ## write a relative link to another article, most likely the RSS reader will
215 | ## build an incorrect absolute URL. To avoid this, pass the absolute URL for
216 | ## the directory where the generated HTML will be placed, and it will be
217 | ## prepended automatically to all relative links. If you are generating
218 | ## standalone HTML, pass the empty string to leave relative link unmodified.
219 | var hasToc = false
220 | var ast = rstParse(text, filename, 0, 0, hasToc,
221 | {roSupportRawDirective, roSupportMarkdown})
222 | #echo strRst(ast)
223 | result = renderRst(ast, articlePrefix, absoluteUrls)
224 |
225 | when isMainModule:
226 | import os, metadata
227 | var i = 0
228 | var filename = getCurrentDir().parentDir() / "articles" / "2013-03-13-gtk-plus-a-method-to-guarantee-scrolling.rst"
229 | discard parseMetadata(filename, i)
230 | echo renderRst(readFile(filename)[i .. ^1], filename)
231 |
--------------------------------------------------------------------------------