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