├── .env.example
├── .gitignore
├── LICENSE
├── Procfile
├── README.md
├── camel.js
├── nodemon.json
├── npm-shrinkwrap.json
├── package.json
├── posts
├── 2014
│ └── 5
│ │ └── 1
│ │ └── sample-post.md
├── 2015
│ └── 2
│ │ ├── 6
│ │ └── sample-link-post.md
│ │ └── 16
│ │ └── some-awesome-website.redirect
├── 404.md
├── about.md
└── index.md
├── public
└── css
│ └── site.css
├── templates
├── defaultTags.html
├── footer.html
├── header.html
├── postHeader.html
└── rssFooter.html
└── typings
├── body-parser
└── body-parser.d.ts
├── express
└── express.d.ts
├── handlebars
└── handlebars.d.ts
├── node
└── node.d.ts
├── q-io
└── Q-io.d.ts
├── sugar
└── sugar.d.ts
└── underscore
└── underscore.d.ts
/.env.example:
--------------------------------------------------------------------------------
1 | [Cameljs App]
2 | PORT=
3 |
4 | [Drafts]
5 | AUTH_USER_NAME=
6 | AUTH_PASSWORD=
7 |
8 | [Twitter Access]
9 | TWITTER_CONSUMER_KEY=
10 | TWITTER_CONSUMER_SECRET=
11 | TWITTER_ACCESS_TOKEN=
12 | TWITTER_TOKEN_SECRET=
13 |
14 | [Twitter Display]
15 | TWITTER_USERNAME=
16 | TWITTER_CLIENT_NEEDLE=
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Dependencies
11 | node_modules
12 |
13 | # env variables
14 | .env
15 |
16 | # Mac artifacts
17 | .DS_Store
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Casey Liss
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node camel.js
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | "Camel" is a blogging platform written in [Node.js][n]. It is designed to be fast, simple, and lean.
2 |
3 | [n]: http://nodejs.org/
4 |
5 | # Design Goals
6 |
7 | More specifically, the design goals were:
8 |
9 | * Easy posting using [Markdown][m]
10 | * Basic metadata, stored in each file
11 | * Basic templating, with a site header/footer and post header stored separately from content
12 | * Extremely quick performance, by caching rendered HTML output
13 | * Support for two RSS feeds:
14 | * The default one, where link posts open on the target website
15 | * The alternate feed, where link posts open on this website
16 | * Optional automatic posts to Twitter
17 |
18 | [m]: http://daringfireball.net/projects/markdown
19 |
20 | # Approach
21 |
22 | Camel is neither a static blogging platform nor a truly dynamic one. It is a little
23 | from column A, and a little from column B. The first time a post is loaded, it is rendered
24 | by converting from Markdown to HTML, and then postprocessed by adding headers & footer, as well
25 | as making metadata replacements. Upon a completed render, the resultant HTML is stored
26 | and used from that point forward.
27 |
28 | # Usage
29 |
30 | ## Installation
31 |
32 | 1. Install [Node][n] & [npm][npm]
33 | 2. Clone the repository
34 | 3. Get all the dependencies using NPM: `npm install`
35 | 4. `node ./camel.js` or using `npm start`
36 |
37 | [npm]: https://www.npmjs.org/
38 |
39 | ## Configuration
40 |
41 | * There's a group of "statics" near the top of the file
42 | * The RSS parameters in the `generateRss` function will need to be modified.
43 | * The headers/footers:
44 | * `header.html` - site header; shown at the top of every page
45 | * `footer.html` - site footer; shown at the bottom of every page
46 | * `defaultTags.html` - default metadata; merged with page metadata (page wins)
47 | * `postHeader.html` - post header; shown at the top of every post not marked with `@@ HideHeader=true`. See below.
48 | * `rssFooter.html` - RSS footer; intended to only show anything on the bottom of
49 | link posts in RSS, but is appended to all RSS entries.
50 | * It's worth noting there are some [Handlebars][hb] templates in use:
51 | * `index.md`
52 | * `@@ DayTemplate` - used to render a day
53 | * `@@ ArticlePartial` – used to render a single article in a day
54 | * `@@ FooterTemplate` - used to render pagination
55 | * `postHeader.html` - placed on every post between the site header and post content
56 | * `rssFooter.html` - placed on the bottom of every RSS item
57 | * If you'd like to have Camel post to Twitter, set four environment variables (see below)
58 | * If you'd like to support endpoints that require [basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication),
59 | set two environment variables (see below).
60 |
61 | [hb]: http://handlebarsjs.com/
62 |
63 | ### Environment Variables
64 |
65 | You can define ENV variables used by Camel in one of the following ways:
66 |
67 | 1. Heroku admin panel
68 | 2. `export` function in bash/zsh shells.
69 | 3. `.env` file - use `.env.example` as a starting point
70 |
71 | ## Files
72 |
73 | To use Camel, the following files are required:
74 |
75 | Root
76 | +-- camel.js
77 | | Application entry point
78 | +-- package.json
79 | | Node package file
80 | +-- templates/
81 | | +-- defaultTags.html
82 | | | Site-level default tags, such as the site title
83 | | +-- header.html
84 | | | Site header (top of every page)
85 | | +-- footer.html
86 | | | Site footer (bottom of every page)
87 | | +-- postHeader.html
88 | | | Post header (top of every post, after the site header. Handlebars template.)
89 | | `-- rssFooter.html
90 | | RSS footer (at the end of every RSS item)
91 | +-- public/
92 | | `-- Any static files, such as images/css/javascript/etc.
93 | `-- posts/
94 | All the pages & posts are here. Pages in the root, posts ordered by day. For example:
95 | +-- index.md
96 | | Root file; note that DayTemplate, ArticlePartial, and FooterTemplate are
97 | | all Handlebars templates
98 | +-- about.md
99 | | Sample about page
100 | +-- 2014/
101 | | Year
102 | | +-- 4/
103 | | | Month
104 | | | +-- 29/
105 | | | | Day
106 | | | | `-- some-blog-post.md
107 | | | `-- 30/
108 | | | +-- some-other-post.md
109 | | | `-- yet-another-post.md
110 | | `-- 5/
111 | | +-- 1/
112 | | | `-- newest-blog-post.md
113 | | `-- 5/
114 | | `-- some-cool-website.redirect
115 | `-- etc.
116 |
117 | For each post, metadata is specified at the top, and can be leveraged in the body. For example:
118 |
119 | @@ Title=Test Post
120 | @@ Date=2014-05 17:50
121 | @@ Description=This is a short description used in Twitter cards and Facebook Open Graph.
122 | @@ Image=http://somehost.com/someimage.png
123 |
124 | This is a *test post* entitled "@@Title@@".
125 |
126 | The title and date are required. Any other metadata, such as `Description` and `Image`, is optional.
127 |
128 | As of version 1.5.3, you can optionally use
129 | [MultiMarkdown-style metadata](http://fletcher.github.io/MultiMarkdown-5/syntax.html#metadata).
130 | If you choose to use that style, the above would be:
131 |
132 | Title: Test Post
133 | Date: 2014-05 17:50
134 | Description: This is a short description used in Twitter cards and Facebook Open Graph.
135 | Image: http://somehost.com/someimage.png
136 |
137 | This is a *test post* entitled "@@Title@@".
138 |
139 | Note, however, that MultiMarkdown's support for multiline metadata is not supported.
140 | Each metadata item must be wholly contained on its own line.
141 |
142 | ### Link Posts
143 | As of version 1.3, link posts are supported. To create a link post, simply add a `Link`
144 | metadata item:
145 |
146 | @@ Title=Sample Link Post
147 | @@ Date=2015-02-06 12:00
148 | @@ Link=http://www.vt.edu/
149 |
150 | This is a sample *link* post.
151 |
152 | The presence of a `Link` metadata item indicates this is a link post. The formatting for
153 | link and non-link post headers is controlled by the `postHeader.html` template.
154 |
155 | In the RSS feed, the link for a link post is the *external* link. Thus, `rssFooter.html`
156 | is used to add a permalink to the Camel site at the bottom of each link post. It is
157 | important to note that this footer is shown on *every* post; it is up to the footer to
158 | decide whether or not to show anything for the post in question. The example included in
159 | this repo behaves as intended.
160 |
161 | ### Redirects
162 |
163 | As of version 1.1, redirects are supported. To do so, a specially formed file is placed
164 | in the `posts/` tree. The file should have two lines; the first should be the status code
165 | of the redirect ([301][301] or [302][302]). The second line should be the target URL.
166 |
167 | Suppose you wanted to redirect `/2014/12/10/source` to `/2014/12/10/destination`. You will
168 | add the file `/posts/2014/12/10/source.redirect`; it will contain the following:
169 |
170 | 302
171 | /2014/12/10/destination
172 |
173 | Redirects to both internal and external URLs are supported. Providing an invalid status
174 | code will result in that status code being used blindly, so tread carefully.
175 |
176 | [301]: http://en.wikipedia.org/wiki/HTTP_301
177 | [302]: http://en.wikipedia.org/wiki/HTTP_302
178 |
179 | ### Automatic tweets
180 |
181 | As of version 1.4, Camel can automatically tweet when a new post is discovered. This
182 | requires a custom app to be set up for your blog; you can set this up [at Twitter][tdev].
183 | To enable, specify four environment variables to correspond to those Twitter issues:
184 |
185 | * `TWITTER_CONSUMER_KEY`
186 | * `TWITTER_CONSUMER_SECRET`
187 | * `TWITTER_ACCESS_TOKEN`
188 | * `TWITTER_TOKEN_SECRET`
189 |
190 | Additionally, a couple of variables up at the top of the file need to be set:
191 |
192 | * `twitterUsername` - the username of the Twitter account that will be tweeted from.
193 | * `twitterClientNeedle` - a portion of the client's name
194 |
195 | Upon startup, and when the caches are cleaned, Camel will look at the most recent tweets
196 | by the account in question by the app with a name that contains `twitterClientNeedle`. It
197 | will look to see the most recent URL tweeted. If the URL does not match the most recent
198 | post's URL, then a tweet is fired off.
199 |
200 | [tdev]: https://apps.twitter.com
201 |
202 | ### Authentication
203 |
204 | As of version 1.5.0, basic authentication is supported. It is selectively used on individual
205 | routes in order to provide a small barrier for entry for administrative tasks, most
206 | specifically, rendering a draft post. Naturally, basic auth is an inherently insecure
207 | protection mechanism; it is provided simply to prevent drive-bys.
208 |
209 | To enable basic authentication, two environment variables are required:
210 |
211 | * `AUTH_USER_NAME`
212 | * `AUTH_PASSWORD`
213 |
214 | By default, the `/render-draft` endpoint requires basic auth to actually render a draft
215 | post.
216 |
217 |
218 | # Quirks
219 |
220 | There are a couple of quirks, which don't bother me, but may bother you.
221 |
222 | ## Adding a Post
223 |
224 | When a new post is created, if you want an instant refresh, you'll want to restart the
225 | app in order to clear the caches. There is a commented out route `/tosscache` that will also
226 | do this job, if you choose to enable it.
227 |
228 | Otherwise, the internal caches will reset every 30 minutes.
229 |
230 | Additionally, there is no mechanism within Camel for transporting a post to the `posts/`
231 | directory. It is assumed that delivery will happen by way of a `git push` or equivalent.
232 | That is, for example, how it would work when run on [Heroku][h].
233 |
234 | *Note that as of 19 November 2014, Heroku now supports integration with Dropbox, which
235 | [makes it much easier to post to Camel while mobile][camelmobile].*
236 |
237 | [h]: http://www.heroku.com/
238 | [camelmobile]: http://www.caseyliss.com/2014/11/19/heroku-adds-dropbox-support
239 |
240 | ## Pagination
241 |
242 | Camel uses a semi-peculiar pagination model which is being referred to as "loose pagination".
243 | Partly due to laziness, and partly because it seems better, pagination isn't strict. Rather
244 | than always cutting off a page after N posts, instead, pagination is handled differently.
245 |
246 | Starting with the most recent day's posts, all the posts in that day are added to a logical
247 | page. Once that page contains N *or more* posts, that page is considered complete. The next
248 | page is then started.
249 |
250 | Therefore, all the posts in a single day will __always__ be on the same page. That, in turns, means
251 | that pages will have *at least* N posts, but possibly more. In fact, a single page could have
252 | quite a few more than N posts if, say, on one lucrative day there are 1.5*N or 2*N posts.
253 |
254 | Pagination is only necessary on the homepage, and page numbers are 1-based. Pages greater than
255 | 1 are loaded by passing the query string parameter p. For example, `hostname/page/3` for page 3.
256 |
257 | # Status
258 |
259 | Camel is functional, and is presently running [www.caseyliss.com][c]. There are lots of
260 | features that probably *could* be added, but none that I'm actively planning.
261 |
262 | [c]: http://www.caseyliss.com/
263 |
264 | ## Branches
265 |
266 | There is a branch, [`postuploads`][pu], that allows for posts to be uploaded via the same
267 | mechanism as making a draft. This isn't useful for [www.caseyliss.com][c], due to the way
268 | that Camel is hosted, but may be useful for others.
269 |
270 | [pu]: https://github.com/cliss/camel/tree/postuploads
271 |
272 | # License
273 |
274 | Camel is MIT-Licensed.
275 |
276 | While by no means neccessary, I'd very much appreciate it if you provided a link back to
277 | either this repository, or [my website][c], on any sites that run Camel.
278 |
279 | # Change Log
280 |
281 | * __1.5.7__ Fix bug wherein RSS caching could get confused if multiple hostnames are
282 | used to access the site.
283 | * __1.5.6__ Fix bug wherein a `Description` that contains a `"` would not be escaped, and
284 | thus would prematurely truncate `twitter:description` and `og:descripton` tags.
285 | * __1.5.5__ Fix bug in `/render-draft` where each line had a double carriage return. This
286 | caused metadata to not be picked up properly, and thus the post not render properly.
287 | In turn, that defeated most of the purpose of draft support in the first place.
288 | * __1.5.4__ Fix bug wherein a MultiMarkdown-style metadata line that contained a URL with a
289 | query string caused a crash.
290 | * __1.5.3__ Adds support for
291 | [MultiMarkdown-style metadata](http://fletcher.github.io/MultiMarkdown-5/syntax.html#metadata).
292 | Previous-style metadata (prefix with `@@`) is still supported.
293 | * __1.5.2__ Prevent display of posts dated in the future.
294 | * __1.5.1__ Add ability to define ENV variables using a `.env` file.
295 | * __1.5.0__ Add `/render-draft` route with basic authentication.
296 | * __1.4.8__ Fix broken auto-tweeter.
297 | * __1.4.7__ Tweak postRegex to allow for posts that have trailing `+` in their name, such
298 | as [this one](http://www.caseyliss.com/2014/10/2/emoji++)
299 | * __1.4.6__ Change deep homepage pages to `/page/N` instead of `/?p=N`. Maintains support for
300 | original, query string based URLs. Upgrade to latest version of packages.
301 | * __1.4.5__ Fix auto-tweeter not considering too-long titles
302 | (issue #[21](https://github.com/cliss/camel/issues/21))
303 | * __1.4.4__ Add support for Facebook Open Graph.
304 | * __1.4.3__ Add support for Twitter cards; thanks to [@tofias](https://twitter.com/tofias)
305 | for the help.
306 | * __1.4.2__ Now provides for `/rss-alternate`, which points link posts to internal links
307 | instead of external ones.
308 | * __1.4.1__ Refactored to satisfy [JSLint](http://jslint.it). Fixed issue where a day that
309 | only had a redirect in it caused duplicate day breaks to show on the homepage.
310 | * __1.4.0__ Added support for auto-tweeting.
311 | * __1.3.1__ Updated RSS feed such that link posts open the external link, and have a
312 | "Permalink" to the site is shown at the bottom of the post.
313 | * __1.3.0__ Added link posts.
314 | * __1.2.1__ Significant cleanup/restructuring. Now less embarrassing! Removal of lots of
315 | similar-sounding functions and more liberal use of data that we've already collected in
316 | `allPostsSortedAndGrouped()`.
317 | * __1.2.0__ Changes from [marked](https://github.com/chjj/marked) to
318 | [markdown-it](https://github.com/markdown-it/markdown-it), adds support for footnotes.
319 | * __1.1.0__ Fix post regex issue, adds support for redirects, adds `/count` route,
320 | prevents year responses for unreasonable years
321 | * __1.0.1__ Adds x-powered-by header, upgrades to packages
322 | * __1.0.0__ Initial release
323 |
--------------------------------------------------------------------------------
/camel.js:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 | ///
5 | ///
6 | ///
7 | ///
8 |
9 | /***************************************************
10 | * INITIALIZATION *
11 | ***************************************************/
12 |
13 | // load ENV variables from .env file
14 | // use .env.example file as a starting point
15 | require('dotenv').load({silent: true});
16 |
17 | var express = require('express');
18 | var bodyParser = require('body-parser');
19 | var compress = require('compression');
20 | var http = require('http');
21 | var fs = require('fs');
22 | var qfs = require('q-io/fs');
23 | var sugar = require('sugar');
24 | var _ = require('underscore');
25 | var markdownit = require('markdown-it')({
26 | html: true,
27 | xhtmlOut: true,
28 | typographer: true
29 | }).use(require('markdown-it-footnote'));
30 | var Rss = require('rss');
31 | var Handlebars = require('handlebars');
32 | var version = require('./package.json').version;
33 | var Twitter = require('twitter');
34 | var basicAuth = require('basic-auth');
35 | var twitterClient = new Twitter({
36 | consumer_key: process.env.TWITTER_CONSUMER_KEY,
37 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET,
38 | access_token_key: process.env.TWITTER_ACCESS_TOKEN,
39 | access_token_secret: process.env.TWITTER_TOKEN_SECRET
40 | });
41 | var draftAuthInfo = {
42 | user: process.env.AUTH_USER_NAME,
43 | pass: process.env.AUTH_PASSWORD
44 | };
45 |
46 | var app = express();
47 | app.use(compress());
48 | app.use(express.static("public"));
49 | app.use(function (request, response, next) {
50 | response.header('X-powered-by', 'Camel (https://github.com/cliss/camel)');
51 | next();
52 | });
53 | var server = http.createServer(app);
54 |
55 | // "Statics"
56 | var postsRoot = './posts/';
57 | var templateRoot = './templates/';
58 | var metadataMarker = '@@';
59 | var maxCacheSize = 50;
60 | var postsPerPage = 10;
61 | var postRegex = /^(.\/)?posts\/\d{4}\/\d{1,2}\/\d{1,2}\/(\w|-|\+)*(.redirect|.md)?$/;
62 | var footnoteAnchorRegex = /[#"]fn\d+/g;
63 | var footnoteIdRegex = /fnref\d+/g;
64 | var utcOffset = 5;
65 | var cacheResetTimeInMillis = 1800000;
66 | var twitterUsername = process.env.TWITTER_USERNAME; // 'caseylisscom';
67 | var twitterClientNeedle = process.env.TWITTER_CLIENT_NEEDLE; //'Camel Spitter';
68 |
69 | var renderedPosts = {};
70 |
71 | /* {
72 | * hostone.website.com: {
73 | * date: ""
74 | * rss: ""
75 | * },
76 | * hosttwo.website.com: {
77 | * date: ""
78 | * rss: ""
79 | * }
80 | * ]
81 | */
82 | var renderedRss = {};
83 | var renderedAlternateRss = {};
84 | var allPostsSortedGrouped = {};
85 | var headerSource;
86 | var footerSource = null;
87 | var postHeaderTemplate = null;
88 | var rssFooterTemplate = null;
89 | var siteMetadata = {};
90 |
91 | /***************************************************
92 | * HELPER METHODS *
93 | ***************************************************/
94 |
95 | // Middleware to require auth for routes
96 | function requireAuth(request, response, next) {
97 | if (Object.values(draftAuthInfo).all(function (i) { typeof i !== 'undefined' && i.length > 0; })) {
98 | var user = basicAuth(request);
99 |
100 | if (!user || user.name !== draftAuthInfo.user || user.pass !== draftAuthInfo.pass) {
101 | response.set('WWW-Authenticate', 'Basic realm=Authorization Required');
102 | return response.status(401).send('You have to say the magic word.');
103 | }
104 | }
105 | next();
106 | };
107 |
108 | function normalizedFileName(file) {
109 | var retVal = file;
110 | if (file.startsWith('posts')) {
111 | retVal = './' + file;
112 | }
113 |
114 | retVal = retVal.replace('.md', '');
115 |
116 | return retVal;
117 | }
118 |
119 | function fetchFromCache(file) {
120 | return renderedPosts[normalizedFileName(file)] || null;
121 | }
122 |
123 | function addRenderedPostToCache(file, postData) {
124 | //console.log('Adding to cache: ' + normalizedFileName(file));
125 | renderedPosts[normalizedFileName(file)] = _.extend({ file: normalizedFileName(file), date: new Date() }, postData);
126 |
127 | if (_.size(renderedPosts) > maxCacheSize) {
128 | var sorted = _.sortBy(renderedPosts, function (post) { return post.date; });
129 | delete renderedPosts[sorted.first().file];
130 | }
131 |
132 | //console.log('Cache has ' + JSON.stringify(_.keys(renderedPosts)));
133 | }
134 |
135 | // Separate the metadata from the body
136 | function getLinesFromData(data) {
137 | // Drafts seem to treat carriage returns as "\r\n"; real
138 | // posts are simply "\n". Remove all the "\r"s so that
139 | // everything looks like we expect.
140 | data = data.replace(/\r/g, '');
141 | var lines = data.lines();
142 | // Extract the metadata
143 | var metadataEnds = _.findIndex(lines, function (line) {
144 | return line.trim().length === 0;
145 | });
146 | metadataEnds = metadataEnds === -1 ? lines.length : metadataEnds;
147 |
148 | return {
149 | metadata: lines.slice(0, metadataEnds),
150 | body: lines.slice(metadataEnds).join('\n')
151 | };
152 | }
153 |
154 | // Gets all the lines in a post and separates the metadata from the body
155 | function getLinesFromPost(file) {
156 | file = file.endsWith('.md') ? file : file + '.md';
157 | var data = fs.readFileSync(file, {encoding: 'UTF8'});
158 |
159 | return getLinesFromData(data);
160 | }
161 |
162 | // Parses the metadata in the file
163 | function parseMetadata(lines) {
164 | var retVal = {};
165 |
166 | lines.each(function (line) {
167 | if (line.has(metadataMarker) && line.has('=')) {
168 | line = line.replace(metadataMarker, '');
169 | line = line.compact();
170 | var firstIndex = line.indexOf('=');
171 | retVal[line.first(firstIndex)] = line.from(firstIndex + 1);
172 | } else if (line.has(':')) {
173 | line = line.compact();
174 | var firstIndex = line.indexOf(':');
175 | retVal[line.first(firstIndex)] = line.from(firstIndex + 2);
176 | }
177 | });
178 |
179 | // Description could have a " in it that needs to be escaped.
180 | if (Object.has(retVal, "Description")) {
181 | retVal["Description"] = retVal["Description"].replace(/"/g, '"')
182 | }
183 |
184 | // NOTE: Some metadata is added in generateHtmlAndMetadataForFile().
185 |
186 | // Merge with site default metadata
187 | Object.merge(retVal, siteMetadata, false, function (key, targetVal, sourceVal) {
188 | // Ensure that the file wins over the defaults.
189 | return targetVal;
190 | });
191 |
192 | return retVal;
193 | }
194 |
195 | // Gets the external link for this file. Relative if request is
196 | // not specified. Absolute if request is specified.
197 | function externalFilenameForFile(file, request) {
198 | var hostname = typeof(request) !== 'undefined' ? request.headers.host : '';
199 |
200 | var retVal = hostname.length ? ('http://' + hostname) : '';
201 | retVal += file.at(0) === '/' && hostname.length > 0 ? '' : '/';
202 | retVal += file.replace('.md', '').replace(postsRoot, '').replace(postsRoot.replace('./', ''), '');
203 | return retVal;
204 | }
205 |
206 | function performMetadataReplacements(replacements, haystack) {
207 | _.keys(replacements).each(function (key) {
208 | // Ensure that it's a global replacement; non-regex treatment is first-only.
209 | haystack = haystack.replace(new RegExp(metadataMarker + key + metadataMarker, 'g'), replacements[key]);
210 | });
211 |
212 | return haystack;
213 | }
214 |
215 | function generateHtmlAndMetadataForLines(lines, file) {
216 | var metadata = parseMetadata(lines.metadata);
217 | if (typeof(file) !== 'undefined') {
218 | metadata.relativeLink = externalFilenameForFile(file);
219 | // If this is a post, assume a body class of 'post'.
220 | if (postRegex.test(file)) {
221 | metadata.BodyClass = 'post';
222 | }
223 | }
224 |
225 | return {
226 | metadata: metadata,
227 | header: performMetadataReplacements(metadata, headerSource),
228 | postHeader: performMetadataReplacements(metadata, postHeaderTemplate(metadata)),
229 | rssFooter: performMetadataReplacements(metadata, rssFooterTemplate(metadata)),
230 | unwrappedBody: performMetadataReplacements(metadata, markdownit.render(lines.body)),
231 | html: function () {
232 | return this.header +
233 | this.postHeader +
234 | this.unwrappedBody +
235 | footerSource;
236 | }
237 | };
238 | }
239 |
240 | // Gets the metadata & rendered HTML for this file
241 | function generateHtmlAndMetadataForFile(file) {
242 | var retVal = fetchFromCache(file);
243 | if (typeof(retVal) !== 'undefined') {
244 | var lines = getLinesFromPost(file);
245 | addRenderedPostToCache(file, generateHtmlAndMetadataForLines(lines, file));
246 | }
247 |
248 | return fetchFromCache(file);
249 | }
250 |
251 | // Gets all the posts, grouped by day and sorted descending.
252 | // Completion handler gets called with an array of objects.
253 | // Array
254 | // +-- Object
255 | // | +-- 'date' => Date for these articles
256 | // | `-- 'articles' => Array
257 | // | +-- (Article Object)
258 | // | +-- ...
259 | // | `-- (Article Object)
260 | // + ...
261 | // |
262 | // `-- Object
263 | // +-- 'date' => Date for these articles
264 | // `-- 'articles' => Array
265 | // +-- (Article Object)
266 | // +-- ...
267 | // `-- (Article Object)
268 | function allPostsSortedAndGrouped(completion) {
269 | if (Object.size(allPostsSortedGrouped) !== 0) {
270 | completion(allPostsSortedGrouped);
271 | } else {
272 | qfs.listTree(postsRoot, function (name, stat) {
273 | return postRegex.test(name);
274 | }).then(function (files) {
275 | // Lump the posts together by day
276 | var groupedFiles = _.groupBy(files, function (file) {
277 | var parts = file.split('/');
278 | return new Date(parts[1], parts[2] - 1, parts[3]);
279 | });
280 |
281 | // Sort the days from newest to oldest
282 | var retVal = [];
283 | var sortedKeys = _.sortBy(_.keys(groupedFiles), function (date) {
284 | return new Date(date);
285 | }).reverse();
286 |
287 | // For each day...
288 | _.each(sortedKeys, function (key) {
289 | if (new Date(key) > new Date()) {
290 | return;
291 | }
292 |
293 | // Get all the filenames...
294 | var articleFiles = groupedFiles[key];
295 | var articles = [];
296 | // ...get all the data for that file ...
297 | _.each(articleFiles, function (file) {
298 | if (!file.endsWith('redirect')) {
299 | articles.push(generateHtmlAndMetadataForFile(file));
300 | }
301 | });
302 |
303 | // ...so we can sort the posts...
304 | articles = _.sortBy(articles, function (article) {
305 | // ...by their post date and TIME.
306 | return Date.create(article.metadata.Date);
307 | }).reverse();
308 | // Array of objects; each object's key is the date, value
309 | // is an array of objects
310 | // In that array of objects, there is a body & metadata.
311 | // Note if this day only had a redirect, it may have no articles.
312 | if (articles.length > 0) {
313 | retVal.push({date: key, articles: articles});
314 | }
315 | });
316 |
317 | allPostsSortedGrouped = retVal;
318 | completion(retVal);
319 | });
320 | }
321 | }
322 |
323 | function tweetLatestPost() {
324 | if (twitterClient !== null && typeof(process.env.TWITTER_CONSUMER_KEY) !== 'undefined') {
325 | twitterClient.get('statuses/user_timeline', {screen_name: twitterUsername}, function (error, tweets) {
326 | if (error) {
327 | console.log(JSON.stringify(error, undefined, 2));
328 | return;
329 | }
330 |
331 | var lastUrl = null, i = 0;
332 | while (lastUrl === null && i < tweets.length) {
333 | if (tweets[i].source.has(twitterClientNeedle) &&
334 | tweets[i].entities &&
335 | tweets[i].entities.urls &&
336 | tweets[i].entities.urls.length > 0) {
337 | lastUrl = tweets[i].entities.urls[0].expanded_url;
338 | } else {
339 | i += 1;
340 | }
341 | }
342 |
343 | allPostsSortedAndGrouped(function (postsByDay) {
344 | var latestPost = postsByDay[0].articles[0];
345 | var link = latestPost.metadata.SiteRoot + latestPost.metadata.relativeLink;
346 |
347 | if (lastUrl !== link) {
348 | console.log('Tweeting new link: ' + link);
349 |
350 | // Figure out how many characters we have to play with.
351 | twitterClient.get('help/configuration', function (error, configuration, response) {
352 | var suffix = " \n\n";
353 | var maxSize = 280 - configuration.short_url_length_https - suffix.length;
354 |
355 | // Shorten the title if need be.
356 | var title = latestPost.metadata.Title;
357 | if (title.length > maxSize) {
358 | title = title.substring(0, maxSize - 3) + '...';
359 | }
360 |
361 | var params = {
362 | status: title + suffix + link
363 | };
364 | twitterClient.post('statuses/update', params, function (error, tweet, response) {
365 | if (error) {
366 | console.log(JSON.stringify(error, undefined, 2));
367 | }
368 | });
369 | });
370 | } else {
371 | console.log('Twitter is up to date.');
372 | }
373 | });
374 | });
375 | }
376 | }
377 |
378 | // Loads header or footer files from disk
379 | function loadHeaderFooter(file, completion) {
380 | fs.exists(templateRoot + file, function(exists) {
381 | if (exists) {
382 | fs.readFile(templateRoot + file, {encoding: 'UTF8'}, function (error, data) {
383 | if (!error) {
384 | completion(data);
385 | }
386 | });
387 | }
388 | });
389 | }
390 |
391 | // Empties the caches.
392 | function emptyCache() {
393 | console.log('Emptying the cache.');
394 | renderedPosts = {};
395 | renderedRss = {};
396 | allPostsSortedGrouped = {};
397 |
398 | tweetLatestPost();
399 | }
400 |
401 | // Initialize application and load template files
402 | function init() {
403 | loadHeaderFooter('defaultTags.html', function (data) {
404 | // Note this comes in as a flat string; split on newlines for parsing metadata.
405 | siteMetadata = parseMetadata(data.split('\n'));
406 |
407 | // This relies on the above, so nest it.
408 | loadHeaderFooter('header.html', function (data) {
409 | headerSource = data;
410 | });
411 | });
412 | loadHeaderFooter('footer.html', function (data) { footerSource = data; });
413 | loadHeaderFooter('rssFooter.html', function (data) {
414 | rssFooterTemplate = Handlebars.compile(data);
415 | });
416 | loadHeaderFooter('postHeader.html', function (data) {
417 | Handlebars.registerHelper('formatPostDate', function (date) {
418 | return new Handlebars.SafeString(new Date(date).format('{Weekday}, {d} {Month} {yyyy}'));
419 | });
420 | Handlebars.registerHelper('formatIsoDate', function (date) {
421 | return new Handlebars.SafeString(typeof(date) !== 'undefined' ? new Date(date).iso() : '');
422 | });
423 | postHeaderTemplate = Handlebars.compile(data);
424 | });
425 |
426 | // Kill the cache every 30 minutes.
427 | setInterval(emptyCache, cacheResetTimeInMillis);
428 |
429 | tweetLatestPost();
430 | }
431 |
432 | // Gets the rendered HTML for this file, with header/footer.
433 | function generateHtmlForFile(file) {
434 | var fileData = generateHtmlAndMetadataForFile(file);
435 | return fileData.html();
436 | }
437 |
438 | // Gets all the posts, paginated.
439 | // Goes through the posts, descending date order, and joins
440 | // days together until there are 10 or more posts. Once 10
441 | // posts are hit, that's considered a page.
442 | // Forcing to exactly 10 posts per page seemed artificial, and,
443 | // frankly, harder.
444 | function allPostsPaginated(completion) {
445 | allPostsSortedAndGrouped(function (postsByDay) {
446 | var pages = [];
447 | var thisPageDays = [];
448 | var count = 0;
449 | postsByDay.each(function (day) {
450 | count += day.articles.length;
451 | thisPageDays.push(day);
452 | // Reset count if need be
453 | if (count >= postsPerPage) {
454 | pages.push({ page: pages.length + 1, days: thisPageDays });
455 | thisPageDays = [];
456 | count = 0;
457 | }
458 | });
459 |
460 | if (thisPageDays.length > 0) {
461 | pages.push({ page: pages.length + 1, days: thisPageDays});
462 | }
463 |
464 | completion(pages);
465 | });
466 | }
467 |
468 | /***************************************************
469 | * ROUTE HELPERS *
470 | ***************************************************/
471 |
472 | function send404(response, file) {
473 | console.log('404: ' + file);
474 | response.status(404).send(generateHtmlForFile('posts/404.md'));
475 | }
476 |
477 | function loadAndSendMarkdownFile(file, response) {
478 | if (file.endsWith('.md')) {
479 | // Send the source file as requested.
480 | console.log('Sending source file: ' + file);
481 | fs.exists(file, function (exists) {
482 | if (exists) {
483 | fs.readFile(file, {encoding: 'UTF8'}, function (error, data) {
484 | if (error) {
485 | response.status(500).send({error: error});
486 | return;
487 | }
488 | response.type('text/x-markdown; charset=UTF-8');
489 | response.status(200).send(data);
490 | return;
491 | });
492 | } else {
493 | response.status(400).send({error: 'Markdown file not found.'});
494 | }
495 | });
496 | } else if (fetchFromCache(file) !== null) {
497 | // Send the cached version.
498 | console.log('Sending cached file: ' + file);
499 | response.status(200).send(fetchFromCache(file).html());
500 | } else {
501 | var found = false;
502 | // Is this a post?
503 | if (fs.existsSync(file + '.md')) {
504 | found = true;
505 | console.log('Sending file: ' + file);
506 | var html = generateHtmlForFile(file);
507 | response.status(200).send(html);
508 | // Or is this a redirect?
509 | } else if (fs.existsSync(file + '.redirect')) {
510 | var data = fs.readFileSync(file + '.redirect', {encoding: 'UTF8'});
511 | if (data.length > 0) {
512 | var parts = data.split('\n');
513 | if (parts.length >= 2) {
514 | found = true;
515 | console.log('Redirecting to: ' + parts[1]);
516 | response.redirect(parseInt(parts[0], 10), parts[1]);
517 | }
518 | }
519 | }
520 |
521 | if (!found) {
522 | send404(response, file);
523 | return;
524 | }
525 | }
526 | }
527 |
528 | // Sends a listing of an entire year's posts.
529 | function sendYearListing(request, response) {
530 | var year = request.params.slug;
531 | var retVal = '
' + year + '
';
532 | var currentMonth = null;
533 | var anyFound = false;
534 |
535 | allPostsSortedAndGrouped(function (postsByDay) {
536 | postsByDay.each(function (day) {
537 | var thisDay = Date.create(day.date);
538 | if (thisDay.is(year)) {
539 | // Date.isBetween() is not inclusive, so back the from date up one
540 | var thisMonth = new Date(Number(year), Number(currentMonth)).addDays(-1);
541 | // ...and advance the to date by two (one to offset above, one to genuinely add).
542 | var nextMonth = Date.create(thisMonth).addMonths(1).addDays(2);
543 |
544 | //console.log(thisMonth.short() + ' <-- ' + thisDay.short() + ' --> ' + nextMonth.short() + '? ' + (thisDay.isBetween(thisMonth, nextMonth) ? 'YES' : 'NO'));
545 | if (currentMonth === null || !thisDay.isBetween(thisMonth, nextMonth)) {
546 | // If we've started a month list, end it, because we're on a new month now.
547 | if (currentMonth >= 0) {
548 | retVal += '';
549 | }
550 |
551 | anyFound = true;
552 | currentMonth = thisDay.getMonth();
553 | retVal += '\n';
554 | }
555 |
556 | day.articles.each(function (article) {
557 | retVal += '- ' + article.metadata.Title + '
';
558 | });
559 | }
560 | });
561 |
562 | if (!anyFound) {
563 | retVal += "No posts found.";
564 | }
565 |
566 | var updatedSource = performMetadataReplacements(siteMetadata, headerSource);
567 | var header = updatedSource.replace(metadataMarker + 'Title' + metadataMarker, 'Posts for ' + year);
568 | response.status(200).send(header + retVal + footerSource);
569 | });
570 |
571 | }
572 |
573 | // Handles a route by trying the cache first.
574 | // file: file to try.
575 | // sender: function to send result to the client. Only parameter is an object that has the key 'body', which is raw HTML
576 | // generator: function to generate the raw HTML. Only parameter is a function that takes a completion handler that takes the raw HTML as its parameter.
577 | // baseRouteHandler() --> generator() to build HTML --> completion() to add to cache and send
578 | function baseRouteHandler(file, sender, generator) {
579 | if (fetchFromCache(file) === null) {
580 | console.log('Not in cache: ' + file);
581 | generator(function (postData) {
582 | addRenderedPostToCache(file, {body: postData});
583 | sender({body: postData});
584 | });
585 | } else {
586 | console.log('In cache: ' + file);
587 | sender(fetchFromCache(file));
588 | }
589 | }
590 |
591 | // Generates a RSS feed.
592 | // The linkGenerator is what determines if the articles will link
593 | // to this site or to the target of a link post; it takes an article.
594 | // The completion function takes an object:
595 | // {
596 | // date: // Date the generation happened
597 | // rss: // Rendered RSS
598 | // }
599 | function generateRss(request, feedUrl, linkGenerator, completion) {
600 | var feed = new Rss({
601 | title: siteMetadata.SiteTitle,
602 | description: 'Posts to ' + siteMetadata.SiteTitle,
603 | feed_url: siteMetadata.SiteRoot + feedUrl,
604 | site_url: siteMetadata.SiteRoot,
605 | image_url: siteMetadata.SiteRoot + '/images/favicon.png',
606 | author: 'Your Name',
607 | copyright: '2013-' + new Date().getFullYear() + ' Your Name',
608 | language: 'en',
609 | pubDate: new Date().toString(),
610 | ttl: '60'
611 | });
612 |
613 | var max = 10;
614 | var i = 0;
615 | allPostsSortedAndGrouped(function (postsByDay) {
616 | postsByDay.forEach(function (day) {
617 | day.articles.forEach(function (article) {
618 | if (i < max) {
619 | i += 1;
620 | feed.item({
621 | title: article.metadata.Title,
622 | // Offset the time because Heroku's servers are GMT, whereas these dates are EST/EDT.
623 | date: new Date(article.metadata.Date).addHours(utcOffset),
624 | url: linkGenerator(article),
625 | guid: externalFilenameForFile(article.file, request),
626 | description: article.unwrappedBody.replace(/