1 |
|
2 |
|
3 |
|
4 |
|
5 | fs = require 'fs'
|
6 | path = require 'path'
|
7 | crypto = require 'crypto'
|
8 | http = require 'http'
|
9 | url = require 'url'
|
10 | {spawn, exec} = require 'child_process'
|
11 | async = require 'async'
|
12 | _ = require 'underscore'
|
13 | marked = require 'marked'
|
14 | handlebars = require 'handlebars'
|
15 | mkdirp = require 'mkdirp'
|
16 | rss = require 'rss'
|
17 |
|
18 | require 'date-utils'
|
19 |
|
20 | Hey = module.exports = class
|
21 | constructor: () ->
|
22 | @cwd = process.cwd() + "/"
|
23 | @template = null
|
24 | @cacheFile = "#{@cwd}hey-cache.json"
|
25 | @configFile = "#{@cwd}hey-config.json"
|
26 | @templateFile = "#{@cwd}template.html"
|
27 | @pagesDir = "#{@cwd}pages/"
|
28 | @siteDir = "#{@cwd}site/"
|
29 | @publicDir = "#{@cwd}public/"
|
30 | @rssFile = "#{@cwd}site/rss.xml"
|
31 |
|
32 | init: ->
|
33 | if fs.existsSync(@configFile) and fs.existsSync(@postPath())
|
34 | console.log 'A blog is already setup here'
|
35 | return false
|
36 |
|
37 | mkdirp @siteDir
|
38 | mkdirp @pagesDir
|
39 | mkdirp @publicDir
|
40 | mkdirp @postPath()
|
41 |
|
42 | defaults = @defaults()
|
43 | fs.writeFileSync @cacheFile, '', 'utf8'
|
44 | fs.writeFileSync @templateFile, defaults.tpl, 'utf8'
|
45 | fs.writeFileSync @configFile, defaults.config, 'utf8'
|
46 | fs.writeFileSync @postPath('first-post.md'), defaults.post, 'utf8'
|
47 | yes
|
48 |
|
49 | server: ->
|
50 | do @loadConfig
|
51 | server = http.createServer (req, res) =>
|
52 | uri = url.parse(req.url).pathname
|
53 | filename = path.join "#{@cwd}site", uri
|
54 | fs.exists filename, (exists) ->
|
55 | if exists is false
|
56 | res.writeHead 404, 'Content-Type': 'text/plain'
|
57 | res.write '404 Not Found\n'
|
58 | res.end()
|
59 | return false
|
60 |
|
61 | filename += '/index.html' if fs.statSync(filename).isDirectory()
|
62 |
|
63 | fs.readFile filename, 'binary', (error, file) ->
|
64 | if error?
|
65 | res.writeHead 500, 'Content-Type': 'text/plain'
|
66 | res.write error + "\n"
|
67 | res.end()
|
68 | return false
|
69 |
|
70 | res.writeHead 200
|
71 | res.write file, 'binary'
|
72 | res.end()
|
73 |
|
74 | server.listen 3000
|
75 | console.log "Server running at http://localhost:3000"
|
76 | console.log "CTRL+C to stop it"
|
77 |
|
78 | publish: ->
|
79 | do @loadConfig
|
80 | @rsync @siteDir, @config.server
|
81 |
|
82 | rsync: (from, to, callback) ->
|
83 | port = "ssh -p #{@config.port or 22}"
|
84 | child = spawn "rsync", ['-vurz', '--delete', '-e', port, from, to]
|
85 | child.stdout.on 'data', (out) -> console.log out.toString()
|
86 | child.stderr.on 'data', (err) -> console.error err.toString()
|
87 | child.on 'exit', callback if callback
|
88 |
|
89 | loadConfig: ->
|
90 | @config = readJSON @configFile
|
91 | yes
|
92 |
|
93 | loadCache: ->
|
94 | @cache = readJSON(@cacheFile) or []
|
95 | yes
|
96 |
|
97 | loadTemplate: ->
|
98 | return true if @template?
|
99 | @template = handlebars.compile fs.readFileSync(@templateFile).toString()
|
100 | yes
|
101 |
|
102 | postPath: (filename) ->
|
103 | "#{@cwd}posts/#{filename or ''}"
|
104 |
|
105 | postFiles: ->
|
106 | readDir @postPath()
|
107 |
|
108 | pageFiles: ->
|
109 | readDir @pagesDir
|
110 |
|
111 | setType: (post) ->
|
112 | return post unless post.type
|
113 | post["is#{ucWord post.type}"] = true
|
114 | post
|
115 |
|
116 | postInfo: (filename, isPage) ->
|
117 | file = if isPage is true then "#{@pagesDir}#{filename}" else @postPath filename
|
118 | content = fs.readFileSync(file).toString()
|
119 | hash = md5 content
|
120 | content = content.split '\n\n'
|
121 | top = content.shift().split '\n'
|
122 | post =
|
123 | name: filename
|
124 | title: top[0]
|
125 | slug: path.basename filename, '.md'
|
126 | hash: hash
|
127 | body: @markup content.join '\n\n'
|
128 | tags: []
|
129 |
|
130 | for setting in top[2..]
|
131 | parts = setting.split ': '
|
132 | key = parts[0].toLowerCase()
|
133 | post[key] = if key is 'tags'
|
134 | parts[1].split(',').map((s) -> s.trim())
|
135 | else
|
136 | parts[1]
|
137 |
|
138 | if post.published
|
139 | date = new Date post.published
|
140 | post.prettyDate = date.toFormat @config.prettyDateFormat
|
141 | post.ymd = date.toFormat @config.ymdFormat
|
142 | post.permalink = @permalink post.published, post.slug
|
143 | post.archiveDir = post.published[0..6]
|
144 |
|
145 | if isPage is true
|
146 | post.slug += '/'
|
147 | post.type = 'page'
|
148 |
|
149 | @setType post
|
150 |
|
151 | update: (callback) ->
|
152 | do @loadConfig
|
153 | do @loadCache
|
154 | posts = @postFiles()
|
155 |
|
156 |
|
157 | @cache = @cache.filter (item) -> item.name in posts
|
158 | cacheFiles = _.pluck @cache, 'name'
|
159 |
|
160 | for post, i in @cache
|
161 | current = @postInfo post.name
|
162 | @cache[i] = current if post.hash isnt current.hash
|
163 |
|
164 | @cache.push @postInfo(post) for post in posts when post not in cacheFiles
|
165 | @cache = _.sortBy @cache, (post) ->(if post.published then new Date(post.published) else 0) * -1
|
166 |
|
167 | fs.writeFileSync @cacheFile, JSON.stringify @cache
|
168 |
|
169 | callback?()
|
170 |
|
171 | yes
|
172 |
|
173 | postDir: (pubDate, slug) ->
|
174 | date = new Date pubDate
|
175 | "#{@cwd}site/#{date.toFormat 'YYYY/MM/DD'}/#{slug}"
|
176 |
|
177 | permalink: (pubDate, slug) ->
|
178 | date = new Date pubDate
|
179 | "/#{date.toFormat 'YYYY/MM/DD'}/#{slug}/"
|
180 |
|
181 | build: (callback) ->
|
182 | @update =>
|
183 | exec "rsync -vur --delete public/ site", (err, stdout, stderr) =>
|
184 | throw err if err
|
185 |
|
186 | writePostFile = (post, next) =>
|
187 | return next null unless _.has post, 'published'
|
188 | dir = @postDir post.published, post.slug
|
189 | mkdirp.sync dir unless fs.existsSync dir
|
190 | fs.writeFile "#{dir}/index.html", @render([post]), 'utf8'
|
191 | do next
|
192 |
|
193 | async.each @cache, writePostFile, (error) =>
|
194 | async.parallel [@buildArchive, @buildTags, @buildIndex, @buildPages], (error) ->
|
195 | callback?()
|
196 |
|
197 | buildIndex: (callback) =>
|
198 | index = @config.postsOnHomePage - 1
|
199 | posts = @cache.filter((p) -> 'published' in _.keys p)[0..index]
|
200 | fs.writeFileSync "#{@cwd}site/index.html", @render(posts), 'utf8'
|
201 | @buildRss posts
|
202 | callback?(null)
|
203 |
|
204 | buildRss: (posts, callback) =>
|
205 | feed = new rss
|
206 | title: @config.siteTitle
|
207 | description: @config.description
|
208 | feed_url: "#{@config.site}/rss.xml"
|
209 | site_url: @config.site
|
210 | author: @config.author
|
211 |
|
212 | for post in posts
|
213 | feed.item
|
214 | title: post.title
|
215 | description: post.body
|
216 | url: "#{@config.site}#{post.permalink}"
|
217 | date: post.published
|
218 |
|
219 | fs.writeFileSync @rssFile, feed.xml(), 'utf8'
|
220 |
|
221 | callback?(null)
|
222 |
|
223 | buildPages: (callback) =>
|
224 | for page in @pageFiles()
|
225 | data = @postInfo page, yes
|
226 | pageDir = "#{@siteDir}#{data.slug}"
|
227 | mkdirp.sync pageDir
|
228 | fs.writeFileSync "#{pageDir}index.html", @render([data]), 'utf8'
|
229 |
|
230 | callback?(null)
|
231 |
|
232 | buildTags: (callback) =>
|
233 | @tags = {}
|
234 | for post in @cache when post.tags.length > 0
|
235 | for tag in post.tags when _.has post, 'published'
|
236 | @tags[tag] = [] unless _.has @tags, tag
|
237 | @tags[tag].push post
|
238 |
|
239 | for tag, posts of @tags
|
240 | tagDir = "#{@siteDir}tags/#{tag}/"
|
241 | mkdirp.sync tagDir unless fs.existsSync tagDir
|
242 | fs.writeFileSync "#{tagDir}index.html", @render(posts), 'utf8'
|
243 |
|
244 | callback?(null)
|
245 |
|
246 | buildArchive: (callback) =>
|
247 | @archive = {}
|
248 | for post in @cache when 'published' in _.keys post
|
249 | @archive[post.archiveDir] = [] unless _.has @archive, post.archiveDir
|
250 | @archive[post.archiveDir].push post
|
251 |
|
252 | for archiveDir, posts of @archive
|
253 | archiveDir = "#{@siteDir}archives/#{archiveDir.replace('-', '/')}/"
|
254 | mkdirp.sync archiveDir unless fs.existsSync archiveDir
|
255 | fs.writeFileSync "#{archiveDir}index.html", @render(posts), 'utf8'
|
256 |
|
257 | callback?(null)
|
258 |
|
259 | markup: (content) ->
|
260 | content = marked(content).trim()
|
261 | content.replace /\n/g, ""
|
262 |
|
263 | render: (posts) ->
|
264 | throw "Posts must be an array" unless _.isArray posts
|
265 | do @loadTemplate
|
266 | options = _.omit @config, 'server'
|
267 | options.siteTitle = @pageTitle if posts.length is 1 then posts[0].title else ''
|
268 | html = @template _.extend options, posts: posts
|
269 | html.replace /\n|\r|\t/g, ''
|
270 |
|
271 | pageTitle: (postTitle) ->
|
272 | if postTitle then "#{postTitle} | #{@config.siteTitle}" else @config.siteTitle
|
273 |
|
274 | defaults: ->
|
275 | config = [
|
276 | '{'
|
277 | ' "siteTitle": "Hey, Coffee! Jack!",'
|
278 | ' "author": "Si Rob",'
|
279 | ' "description": "My awesome blog, JACK!",'
|
280 | ' "site": "http://yoursite.com",'
|
281 | ' "postsOnHomePage": 20,'
|
282 | ' "server": "user@yoursite.com:/path/to/your/blog",'
|
283 | ' "port": 22,'
|
284 | ' "prettyDateFormat": "DDDD, DD MMMM YYYY",'
|
285 | ' "ymdFormat": "YYYY-MM-DD"'
|
286 | '}'
|
287 | ].join '\n'
|
288 |
|
289 | post = [
|
290 | 'First Post'
|
291 | '=========='
|
292 | 'Published: 2012-03-27 12:00:00'
|
293 | 'Type: text'
|
294 | ''
|
295 | 'This is a test post.'
|
296 | ''
|
297 | 'This is a second paragraph.'
|
298 | ].join '\n'
|
299 |
|
300 | tpl = [
|
301 | '<!DOCTYPE html>'
|
302 | '<html>'
|
303 | ' <head>'
|
304 | ' <title>{{siteTitle}}</title>'
|
305 | ' </head>'
|
306 | ' <body>'
|
307 | ' {{#each posts}}'
|
308 | ' <div>'
|
309 | ' <h2><a href="{{permalink}}">{{title}}</a></h2>'
|
310 | ' {{{body}}}'
|
311 | ' </div>'
|
312 | ' {{/each}}'
|
313 | ' </body>'
|
314 | '</html>'
|
315 | ].join '\n'
|
316 |
|
317 | { config, post, tpl }
|
318 |
|
319 |
|
320 | readJSON = (file) ->
|
321 | throw "JSON file doesn't exist: #{file}" unless fs.existsSync file
|
322 | fileContents = fs.readFileSync(file).toString()
|
323 | if fileContents then JSON.parse(fileContents) else []
|
324 |
|
325 | readDir = (dir) ->
|
326 | throw "Directory doesn't exist: #{dir}" unless fs.existsSync dir
|
327 | files = fs.readdirSync(dir).filter (f) -> f.charAt(0) isnt '.'
|
328 | files or []
|
329 |
|
330 | md5 = (string) ->
|
331 | crypto.createHash('md5').update(string).digest('hex')
|
332 |
|
333 | ucWord = (string) ->
|
334 | string.charAt(0).toUpperCase() + string.slice 1
|
335 |
|
336 |
|