├── .gitignore ├── posts ├── 2009 │ ├── 01 │ │ └── 31 │ │ │ └── test-post.textile │ └── 02 │ │ └── 01 │ │ └── another-test-post.textile └── about.textile ├── src ├── lib │ ├── jtextile.jar │ └── gnu-regexp-1.1.4.jar ├── Config.scala ├── Index.scala ├── About.scala ├── Blog.scala ├── AtomFeed.scala ├── Sitemap.scala ├── Archive.scala ├── FileHelpers.scala └── Post.scala ├── Makefile ├── README.textile ├── static ├── 50x.html ├── 404.html └── screen.css ├── LICENSE └── templates └── template.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | src/classes/* 3 | www/ -------------------------------------------------------------------------------- /posts/about.textile: -------------------------------------------------------------------------------- 1 | h1. About 2 | 3 | Here's where information about your blog would go. -------------------------------------------------------------------------------- /src/lib/jtextile.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/al3x/simple-scala-blog/master/src/lib/jtextile.jar -------------------------------------------------------------------------------- /src/lib/gnu-regexp-1.1.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/al3x/simple-scala-blog/master/src/lib/gnu-regexp-1.1.4.jar -------------------------------------------------------------------------------- /src/Config.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | object Config { 4 | val pwd = "/path/to/your/blog" 5 | 6 | val postDir = pwd + "/posts" 7 | val wwwDir = pwd + "/www" 8 | val staticDir = pwd + "/static" 9 | 10 | val template = pwd + "/templates/template.html" 11 | 12 | val aboutPost = postDir + "/about.textile" 13 | 14 | val aboutPath = wwwDir + "/about.html" 15 | val archivePath = wwwDir + "/archive.html" 16 | val indexPath = wwwDir + "/index.html" 17 | val atomPath = wwwDir + "/index.atom" 18 | val sitemapPath = wwwDir + "/sitemap.xml" 19 | } 20 | -------------------------------------------------------------------------------- /src/Index.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.File 4 | import scala.collection.{immutable, mutable} 5 | 6 | class Index(posts: Seq[Post]) extends FileHelpers { 7 | def indexBody = { 8 | var out = "" 9 | for (post <- posts) { 10 | out += post.htmlBody 11 | } 12 | out 13 | } 14 | 15 | lazy val index = templatizeFile(new File(Config.template), 16 | immutable.Map("XTITLE" -> "Home", 17 | "XBODY" -> indexBody)) 18 | 19 | def write = writeFile(new File(Config.indexPath), index) 20 | } 21 | -------------------------------------------------------------------------------- /src/About.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.File 4 | import scala.collection.{immutable, mutable} 5 | import net.sf.jtextile.JTextile 6 | 7 | class About(filePath: String) extends FileHelpers { 8 | val file = new File(filePath) 9 | val aboutHTML = "
" + JTextile.textile(readFile(file)).trim + "
" 10 | 11 | lazy val about = templatizeFile(new File(Config.template), 12 | immutable.Map("XTITLE" -> "About", 13 | "XBODY" -> aboutHTML)) 14 | 15 | def write = writeFile(new File(Config.aboutPath), about) 16 | } 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CLASSES = src/classes 2 | CLASSPATH = src/lib/jtextile.jar:src/lib/gnu-regexp-1.1.4.jar 3 | 4 | .PHONY: all clean cleanwww post sync 5 | 6 | all: clean run 7 | 8 | run: src/classes/net/al3x/blog/Blog.class 9 | scala -classpath $(CLASSES):$(CLASSPATH) net.al3x.blog.Blog 10 | 11 | src/classes/%.class: 12 | fsc -d src/classes -classpath $(CLASSPATH) `find src/ -name \*.scala -print` 13 | 14 | clean: 15 | rm -rf src/classes/* 16 | 17 | cleanwww: 18 | rm -rf www/* 19 | 20 | post: 21 | scala -classpath $(CLASSES):$(CLASSPATH) net.al3x.blog.Blog -n 22 | 23 | rebuild: 24 | scala -classpath $(CLASSES):$(CLASSPATH) net.al3x.blog.Blog -f 25 | 26 | sync: 27 | rsync -avz -e ssh /path/to/your/blog/www/ you@your.host:/var/www/blog 28 | -------------------------------------------------------------------------------- /posts/2009/01/31/test-post.textile: -------------------------------------------------------------------------------- 1 | h1. Test Post 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque lobortis vehicula magna. Praesent auctor hendrerit mauris. Etiam consectetur. Nunc ac augue non justo commodo eleifend. Duis quis enim. Aenean vehicula mattis tortor. Aliquam mattis. Maecenas et ante. Donec augue. Etiam vitae leo at justo feugiat aliquam. Morbi fringilla dignissim ligula. Integer ipsum lectus, sagittis a, tristique pretium, elementum vitae, ligula. Quisque ac massa. Curabitur vitae libero. Suspendisse at quam eget nisl convallis condimentum. 4 | 5 | Duis nunc. Morbi eu neque. Fusce tempor risus. Sed luctus. Curabitur non mauris. Pellentesque lorem. Etiam pretium dui et lectus. Suspendisse at est non turpis rhoncus imperdiet. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Duis sit amet pede. In tempus. Phasellus vulputate risus non dui. -------------------------------------------------------------------------------- /posts/2009/02/01/another-test-post.textile: -------------------------------------------------------------------------------- 1 | h1. Another Test Post 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque lobortis vehicula magna. Praesent auctor hendrerit mauris. Etiam consectetur. Nunc ac augue non justo commodo eleifend. Duis quis enim. Aenean vehicula mattis tortor. Aliquam mattis. Maecenas et ante. Donec augue. Etiam vitae leo at justo feugiat aliquam. Morbi fringilla dignissim ligula. Integer ipsum lectus, sagittis a, tristique pretium, elementum vitae, ligula. Quisque ac massa. Curabitur vitae libero. Suspendisse at quam eget nisl convallis condimentum. 4 | 5 | Duis nunc. Morbi eu neque. Fusce tempor risus. Sed luctus. Curabitur non mauris. Pellentesque lorem. Etiam pretium dui et lectus. Suspendisse at est non turpis rhoncus imperdiet. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Duis sit amet pede. In tempus. Phasellus vulputate risus non dui. -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. Simple Scala Blog 2 | 3 | A lightweight blogging system, designed to transmute a directory of Textile files into an equivalent directory of HTML files with a minimum of fuss. 4 | 5 | h2. Rationalization 6 | 7 | Writing a custom blogging system is a wholly unnecessary act. This system was written so the author could muck about with Scala. Though there's some basic attempt at providing a configuration mechanism, templates and logic are currently too intertwined. Improving this is an exercise for the reader/forker. 8 | 9 | You're encouraged to borrow chunks of code you fancy. The good parts are mostly the use of XML literals. You might find the FileHelpers trait handy, as it provides easy-to-use methods for stuff that should really be in java.io.File or similar. 10 | 11 | h2. Requirements 12 | 13 | * Scala (tested with 2.7.2 final) 14 | * GNU Make 15 | * JTextile (included, but is kind of wonky) 16 | * GNU RegExp for Java (included) 17 | 18 | h2. License 19 | 20 | Licensed under the "Apache Public License, Version 2":http://www.apache.org/licenses/LICENSE-2.0.html. 21 | -------------------------------------------------------------------------------- /src/Blog.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.File 4 | import scala.collection.{immutable, mutable} 5 | import scala.collection.jcl 6 | import scala.xml.XML 7 | 8 | object Blog extends FileHelpers { 9 | def main(args: Array[String]) { 10 | val posts = findPosts(new File(Config.postDir)) 11 | val lastTenPosts = posts.reverse.slice(0, 10) 12 | 13 | if (args.isDefinedAt(0)) { 14 | args(0) match { 15 | case "-f" => posts.foreach(post => { post.write; print(".") }); println("Done.") 16 | case "-n" => Post.newPost 17 | case _ => println("Unknown argument."); System.exit(-1) 18 | } 19 | } else { 20 | lastTenPosts.foreach(post => { post.write; print(".") }); println("Done.") 21 | } 22 | 23 | // copy static files 24 | copyAllFiles(Config.staticDir, Config.wwwDir) 25 | 26 | // generate dynamic files 27 | new About(Config.aboutPost).write 28 | new Archive(posts).write 29 | new Sitemap(posts).write 30 | new AtomFeed(lastTenPosts).write 31 | new Index(lastTenPosts).write 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /static/50x.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Missing Post? 8 | 9 | 10 |

11 | About. 12 | Archive. 13 | Home. 14 |

15 |
16 | Something is terribly wrong. 17 |

18 | 23 |
24 | 25 | -------------------------------------------------------------------------------- /static/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Missing Post? 8 | 9 | 10 |

11 | About. 12 | Archive. 13 | Home. 14 |

15 |
16 | The post you're looking for isn't here. 17 |

18 | 23 |
24 | 25 | -------------------------------------------------------------------------------- /src/AtomFeed.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import scala.xml.XML 4 | 5 | class AtomFeed(posts: Seq[Post]) { 6 | lazy val feed = 7 | 8 | John Doe 9 | A fancy subtitle. 10 | 11 | 12 | {posts(0).updatedDate} 13 | 14 | John Doe 15 | http://example.com/about.html 16 | 17 | http://example.com/ 18 | {for (post <- posts) yield 19 | 20 | {post.title} 21 | 22 | {post.atomId} 23 | {post.updatedDate} 24 | {post.bodyMinusTitle} 25 | 26 | John Doe 27 | http://example.com/about.html 28 | 29 | 30 | } 31 | 32 | 33 | def write = XML.saveFull(Config.atomPath, feed, "UTF-8", true, null) 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Alex Payne 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Sitemap.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import scala.xml.XML 4 | 5 | class Sitemap(posts: Seq[Post]) { 6 | lazy val lastPostDate = posts(posts.size - 1).siteMapDate 7 | 8 | lazy val sitemap = 9 | 10 | 11 | http://example.com/ 12 | {lastPostDate} 13 | daily 14 | 1.0 15 | 16 | 17 | http://example.com/about.html 18 | {lastPostDate} 19 | monthly 20 | 0.8 21 | 22 | 23 | http://example.com/archive.html 24 | {lastPostDate} 25 | daily 26 | 0.7 27 | 28 | {for (post <- posts) yield 29 | 30 | {post.url} 31 | {post.siteMapDate} 32 | monthly 33 | 0.5 34 | 35 | } 36 | 37 | 38 | def write = XML.saveFull(Config.sitemapPath, sitemap, "UTF-8", true, null) 39 | } 40 | -------------------------------------------------------------------------------- /src/Archive.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.File 4 | import scala.collection.{immutable, mutable} 5 | import scala.collection.jcl 6 | 7 | class Archive(posts: Seq[Post]) extends FileHelpers { 8 | def archiveByYearMap: jcl.TreeMap[String, mutable.ListBuffer[Post]] = { 9 | var yearMap = new jcl.TreeMap[String, mutable.ListBuffer[Post]] 10 | 11 | for (post <- posts.reverse) { 12 | var yearList = { 13 | if (yearMap.contains(post.year)) { 14 | yearMap(post.year) 15 | } else { 16 | new mutable.ListBuffer[Post]() 17 | } 18 | } 19 | 20 | yearList += post 21 | yearMap += (post.year -> yearList) 22 | } 23 | 24 | yearMap 25 | } 26 | 27 | lazy val yearsDiv = 28 |
29 |

Archive

30 | {for (key <- archiveByYearMap.keys.toList.reverse) yield 31 |

{key}

32 | 37 | } 38 |
39 | 40 | def write = { 41 | val templatizedBody = templatizeFile(new File(Config.template), 42 | immutable.Map("XTITLE" -> "Archive", 43 | "XBODY" -> yearsDiv.toString)) 44 | writeFile(new File(Config.archivePath), templatizedBody) 45 | } 46 | } -------------------------------------------------------------------------------- /static/screen.css: -------------------------------------------------------------------------------- 1 | /* elements */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body { 8 | background-color: #edf1f3; 9 | color: #222; 10 | font-family: 'Georgia', 'Times New Roman', serif; 11 | line-height: 18px; 12 | margin: 0 auto 18px 25%; 13 | } 14 | 15 | a:link { 16 | color: #336699; 17 | text-decoration: none; 18 | } 19 | a:visited { 20 | color: #660000; 21 | text-decoration: none; 22 | } 23 | a:hover { 24 | color: #336699; 25 | text-decoration: underline; 26 | } 27 | a img { border-width: 0; } 28 | 29 | blockquote { 30 | border-left: 1px solid #222; 31 | line-height: 18px; 32 | font-style: italic; 33 | margin: 22px; 34 | padding-left: 8px; 35 | } 36 | 37 | h1 { 38 | font-size: 24px; 39 | line-height: 36px; 40 | margin-bottom: 18px; 41 | } 42 | 43 | h2 { 44 | font-size: 18px; 45 | line-height: 18px; 46 | margin-bottom: 18px; 47 | } 48 | 49 | h3 { 50 | font-size: 12px; 51 | line-height: 18px; 52 | } 53 | 54 | li { margin-bottom: 4px; } 55 | 56 | p { line-height: 22px; } 57 | 58 | p, ul, ol { margin-bottom: 18px; } 59 | 60 | ul, ol { 61 | margin-left: 2em; 62 | padding-bottom: 9px; 63 | } 64 | 65 | ul { 66 | list-style-type: square; 67 | } 68 | 69 | 70 | /* classes */ 71 | .post, .yearlist { 72 | border-bottom: 1px solid #222; 73 | margin-bottom: 36px; 74 | } 75 | 76 | .post .signoff { text-align: right; } 77 | 78 | /* ids */ 79 | #footer { 80 | font-size: 18px; 81 | padding-top: 18px; 82 | text-align: center; 83 | } 84 | 85 | #title, #title a, #title a:visited { 86 | color: #333; 87 | font-size: 34px; 88 | margin: 18px 0 42px 0; 89 | } 90 | 91 | #wrapper { 92 | max-width: 40em; 93 | min-width: 30em; 94 | width: 32em; 95 | } 96 | -------------------------------------------------------------------------------- /templates/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Alex Payne | XTITLE 13 | 14 | 15 |

16 | Alex Payne 17 | writes online 18 | here. 19 |

20 |
21 | XBODY 22 | 28 |
29 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /src/FileHelpers.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.{File, FileReader, FileWriter} 4 | import java.lang.Runtime 5 | import scala.collection.{immutable, mutable} 6 | import scala.io.Source 7 | 8 | trait FileHelpers { 9 | def findPosts(postDir: File): mutable.ListBuffer[Post] = { 10 | var foundPosts = new mutable.ListBuffer[Post]() 11 | 12 | def recursiveFind(dir: File) { 13 | for (file <- dir.listFiles) { 14 | if (file.isFile && file.getName.endsWith(".textile") && (file.getName != "about.textile")) { 15 | foundPosts += new Post(file) 16 | } else if (file.isDirectory) { 17 | recursiveFind(file) 18 | } 19 | } 20 | } 21 | 22 | recursiveFind(postDir) 23 | foundPosts 24 | } 25 | 26 | def readFile(file: File): String = { 27 | val src = Source.fromFile(file) 28 | src.getLines.mkString 29 | } 30 | 31 | def symlinkFileToFile(cdDir: File, file: File) = { 32 | if (!file.exists) { 33 | val symCmd = Array("/bin/sh", "-c", "cd " + cdDir + "; ln -s " + file.getPath) 34 | Runtime.getRuntime.exec(symCmd) 35 | } 36 | } 37 | 38 | def templatizeFile(file: File, varMap: immutable.Map[String, String]): String = { 39 | var out = readFile(file) 40 | for (key <- varMap.keys) { 41 | out = out.replace(key, varMap(key)) 42 | } 43 | out 44 | } 45 | 46 | def writeFile(file: File, contents: String) = { 47 | def writeIt = { 48 | file.createNewFile 49 | val writer = new FileWriter(file) 50 | writer.write(contents) 51 | writer.flush 52 | writer.close 53 | } 54 | 55 | if (file.exists) { 56 | file.delete 57 | } 58 | 59 | writeIt 60 | } 61 | 62 | def copyFileToPath(file: File, path: String) = { 63 | val newFileName = Array(path, file.getName).mkString("/") 64 | val newFile = new File(newFileName) 65 | 66 | val in = new FileReader(file) 67 | val out = new FileWriter(newFile) 68 | var char = 0 69 | 70 | while (char != -1) { 71 | char = in.read 72 | out.write(char) 73 | } 74 | 75 | in.close 76 | out.close 77 | } 78 | 79 | def copyAllFiles(from: String, to: String) = { 80 | val dir = new File(from) 81 | 82 | if (!dir.isDirectory) { 83 | throw new RuntimeException(dir + " is not a directory, cannot copy from it.") 84 | } 85 | 86 | for (file <- dir.listFiles) { 87 | if (file.isFile) { 88 | copyFileToPath(file, to) 89 | } 90 | } 91 | } 92 | 93 | def editFile(file: File) = { 94 | Runtime.getRuntime.exec("/usr/local/bin/mate -w " + file.getPath) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Post.scala: -------------------------------------------------------------------------------- 1 | package net.al3x.blog 2 | 3 | import java.io.File 4 | import java.text.SimpleDateFormat 5 | import java.util.{Calendar, Date} 6 | import net.sf.jtextile.JTextile 7 | import scala.collection.{immutable, mutable} 8 | import scala.io.Source 9 | import scala.xml.XML 10 | 11 | class Post(file: File) extends FileHelpers { 12 | val parts = file.getPath.split("posts/")(1).split("/") 13 | 14 | val year = parts(0) 15 | val month = parts(1) 16 | val day = parts(2) 17 | 18 | val filename = parts(3).split(".textile")(0) 19 | val relativeUrl = Array(year, month, day, filename).mkString("/") + ".html" 20 | val url = "/" + relativeUrl 21 | 22 | lazy val siteMapDate = Array(year, month, day).mkString("-") 23 | lazy val atomId = "tag:example.com," + siteMapDate + ":" + relativeUrl 24 | 25 | lazy val title = Source.fromFile(file).getLine(1).split("h1. ")(1) 26 | 27 | lazy val body = JTextile.textile(readFile(file)).trim 28 | lazy val htmlBody = 29 | "
" + body + signoffDate + "
" 30 | 31 | lazy val bodyMinusTitle = { 32 | val bodyLines = body.split("\n") 33 | bodyLines.slice(1, bodyLines.size).mkString.trim 34 | } 35 | 36 | lazy val templatizedBody = templatizeFile(new File(Config.template), 37 | immutable.Map("XTITLE" -> title, "XBODY" -> htmlBody)) 38 | 39 | lazy val updatedDate = { 40 | val rfc3339 = new SimpleDateFormat("yyyy-MM-dd'T'h:m:ss'-05:00'") 41 | val calendar = Calendar.getInstance 42 | calendar.set(year.toInt, month.toInt - 1, day.toInt) 43 | rfc3339.format(calendar.getTime) 44 | } 45 | 46 | lazy val signoffDate = { 47 | val simpleDate = new SimpleDateFormat("MMM d, yyyy") 48 | val calendar = Calendar.getInstance 49 | calendar.set(year.toInt, month.toInt - 1, day.toInt) 50 | val dateStr = simpleDate.format(calendar.getTime) 51 | 52 |

53 | —{dateStr} 54 |

.toString 55 | } 56 | 57 | lazy val archiveDate = { 58 | val simpleDate = new SimpleDateFormat("MMM d") 59 | val calendar = Calendar.getInstance 60 | calendar.set(year.toInt, month.toInt - 1, day.toInt) 61 | simpleDate.format(calendar.getTime) 62 | } 63 | 64 | def createDir = { 65 | val outDir = new File(Array(Config.wwwDir, year, month, day).mkString("/")) 66 | if (!outDir.exists) { 67 | outDir.mkdirs 68 | } 69 | } 70 | 71 | def createFile = { 72 | val outFile = new File(Array(Config.wwwDir, year, month, day, filename).mkString("/") + ".html") 73 | writeFile(outFile, templatizedBody) 74 | } 75 | 76 | def createSymlink = { 77 | val cdDir = new File(Array(Config.wwwDir, year, month).mkString("/")) 78 | val symlinkFile = new File(Array(day, filename).mkString("/") + ".html") 79 | symlinkFileToFile(cdDir, symlinkFile) 80 | } 81 | 82 | def write = { 83 | createDir 84 | createFile 85 | createSymlink 86 | } 87 | } 88 | 89 | object Post extends FileHelpers { 90 | def newPost = { 91 | print("File name: ") 92 | var title = readLine().trim 93 | 94 | val df = new SimpleDateFormat("yyyy/MM/dd") 95 | val calendar = Calendar.getInstance 96 | val todayPath = Config.postDir + "/" + df.format(calendar.getTime) 97 | val postFile = new File(todayPath + "/" + title + ".textile") 98 | 99 | new File(todayPath).mkdirs 100 | postFile.createNewFile 101 | println("Created new blank post: " + postFile.getPath) 102 | editFile(postFile) 103 | } 104 | } 105 | --------------------------------------------------------------------------------