Initial commit

This commit is contained in:
Mikko Ahlroth 2014-11-03 00:08:29 +02:00
commit 44c090b5fd
20 changed files with 663 additions and 0 deletions

3
.bowerrc Normal file
View file

@ -0,0 +1,3 @@
{
"directory": "src/bower_modules"
}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.DS_Store
node_modules/
bower_modules/
# Don't track build output
dist/
dev/

17
bower.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "laine",
"version": "0.0.0",
"private": true,
"dependencies": {
"components-bootstrap": "~3.1.1",
"crossroads": "~0.12.0",
"hasher": "~1.2.0",
"requirejs": "~2.1.11",
"requirejs-text": "~2.0.10",
"knockout": "~3.2.0",
"knockout-projections": "~1.1.0-pre",
"lodash": "~2.4.1",
"moment": "~2.8.3",
"marked": "~0.3.2"
}
}

98
gulpfile.js Normal file
View file

@ -0,0 +1,98 @@
// Node modules
var fs = require('fs'), vm = require('vm'), merge = require('deeply'), chalk = require('chalk'), es = require('event-stream');
// Gulp and plugins
var gulp = require('gulp'), rjs = require('gulp-requirejs-bundler'), concat = require('gulp-concat'), clean = require('gulp-clean'),
replace = require('gulp-replace'), uglify = require('gulp-uglify'), htmlreplace = require('gulp-html-replace');
// Gulp minify for smallinizing our CSS
var minify = require('gulp-minify-css');
// Gulp filesize for printing sizes before and after minification
var size = require('gulp-size');
// Config
var requireJsRuntimeConfig = vm.runInNewContext(fs.readFileSync('src/app/require.config.js') + '; require;');
requireJsOptimizerConfig = merge(requireJsRuntimeConfig, {
out: 'scripts.js',
baseUrl: './src',
name: 'app/startup',
paths: {
requireLib: 'bower_modules/requirejs/require',
lodashLib: 'bower_modules/lodash/dist/lodash',
momentLib: 'bower_modules/moment/moment'
},
include: [
'requireLib',
'lodashLib',
'momentLib',
'components/nav-bar/nav-bar',
'components/home-page/home',
'components/db/db',
'components/transfer/transfer',
'components/single-post/single-post',
'components/pagination/pagination'
],
insertRequire: ['app/startup'],
bundles: {
// If you want parts of the site to load on demand, remove them from the 'include' list
// above, and group them into bundles here.
// 'bundle-name': [ 'some/module', 'another/module' ],
// 'another-bundle-name': [ 'yet-another-module' ]
}
});
// Discovers all AMD dependencies, concatenates together all required .js files, minifies dist files
gulp.task('js', function() {
return rjs(requireJsOptimizerConfig)
.pipe(size({title: 'Original JS'}))
.pipe(gulp.dest('./dev/'))
.pipe(uglify({ preserveComments: false }))
.pipe(size({title: 'Minified JS'}))
.pipe(gulp.dest('./dist/'));
});
// Concatenates CSS files, rewrites relative paths to Bootstrap fonts, copies Bootstrap fonts
gulp.task('css', function() {
var bowerCss = gulp.src('src/bower_modules/components-bootstrap/css/bootstrap.min.css')
.pipe(replace(/url\((')?\.\.\/fonts\//g, 'url($1fonts/')),
appCss = gulp.src('src/css/*.css');
return es.concat(bowerCss, appCss)
.pipe(concat('css.css'))
.pipe(size({title: 'Original CSS'}))
.pipe(gulp.dest('./dev/'))
.pipe(minify())
.pipe(size({title: 'Minified CSS'}))
.pipe(gulp.dest('./dist/'));
});
// Copies fonts
gulp.task('fonts', function() {
return gulp.src('./src/bower_modules/components-bootstrap/fonts/*', { base: './src/bower_modules/components-bootstrap/' })
.pipe(gulp.dest('./dev/'))
.pipe(gulp.dest('./dist/'));
});
// Copies index.html, replacing <script> and <link> tags to reference production URLs
gulp.task('html', function() {
return gulp.src('./src/index.html')
.pipe(htmlreplace({
'css': 'css.css',
'js': 'scripts.js'
}))
.pipe(gulp.dest('./dist/'))
.pipe(gulp.dest('./dev/'));
});
// Removes all files from ./dist/
gulp.task('clean', function() {
return gulp.src('./dist/**/*', { read: false })
.pipe(clean());
});
gulp.task('default', ['html', 'js', 'css', 'fonts'], function(callback) {
callback();
console.log('\nPlaced optimized files in ' + chalk.magenta('dist/')
+ ' and dev files in ' + chalk.magenta('dev/\n'));
});

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "laine",
"version": "0.0.0",
"devDependencies": {
"chalk": "~0.4.0",
"deeply": "~0.1.0",
"event-stream": "~3.1.0",
"gulp": "^3.8.9",
"gulp-clean": "~0.2.4",
"gulp-concat": "~2.2.0",
"gulp-html-replace": "~1.0.0",
"gulp-replace": "~0.2.0",
"gulp-requirejs-bundler": "^0.1.1",
"gulp-uglify": "~0.2.1",
"gulp-size": "~1.1.0",
"gulp-minify-css": "~0.3.11"
}
}

18
src/app/require.config.js Normal file
View file

@ -0,0 +1,18 @@
// require.js looks for the following global when initializing
var require = {
baseUrl: ".",
paths: {
"bootstrap": "bower_modules/components-bootstrap/js/bootstrap.min",
"crossroads": "bower_modules/crossroads/dist/crossroads.min",
"hasher": "bower_modules/hasher/dist/js/hasher.min",
"jquery": "bower_modules/jquery/dist/jquery",
"knockout": "bower_modules/knockout/dist/knockout",
"knockout-projections": "bower_modules/knockout-projections/dist/knockout-projections",
"signals": "bower_modules/js-signals/dist/signals.min",
"text": "bower_modules/requirejs-text/text",
"marked": "bower_modules/marked/lib/marked"
},
shim: {
"bootstrap": { deps: ["jquery"] }
}
};

37
src/app/router.js Normal file
View file

@ -0,0 +1,37 @@
define(["knockout", "crossroads", "hasher"], function(ko, crossroads, hasher) {
// This module configures crossroads.js, a routing library. If you prefer, you
// can use any other routing library (or none at all) as Knockout is designed to
// compose cleanly with external libraries.
//
// You *don't* have to follow the pattern established here (each route entry
// specifies a 'page', which is a Knockout component) - there's nothing built into
// Knockout that requires or even knows about this technique. It's just one of
// many possible ways of setting up client-side routes.
return new Router({
routes: [
{ url: '', params: { page: 'home-page' } }
]
});
function Router(config) {
var currentRoute = this.currentRoute = ko.observable({});
ko.utils.arrayForEach(config.routes, function(route) {
crossroads.addRoute(route.url, function(requestParams) {
currentRoute(ko.utils.extend(requestParams, route.params));
});
});
activateCrossroads();
}
function activateCrossroads() {
function parseHash(newHash, oldHash) { crossroads.parse(newHash); }
crossroads.normalizeFn = crossroads.NORM_AS_OBJECT;
hasher.initialized.add(parseHash);
hasher.changed.add(parseHash);
hasher.init();
}
});

30
src/app/startup.js Normal file
View file

@ -0,0 +1,30 @@
define(['jquery', 'knockout', './router', 'marked', 'bootstrap', 'knockout-projections'],
function($, ko, router, marked) {
// Components can be packaged as AMD modules, such as the following:
ko.components.register('nav-bar', { require: 'components/nav-bar/nav-bar' });
ko.components.register('home-page', { require: 'components/home-page/home' });
// ... or for template-only components, you can just point to a .html file directly:
ko.components.register('about-page', {
template: { require: 'text!components/about-page/about.html' }
});
ko.components.register('db', { require: 'components/db/db' });
ko.components.register('transfer', { require: 'components/transfer/transfer' });
ko.components.register('single-post', { require: 'components/single-post/single-post' });
ko.components.register('pagination', { require: 'components/pagination/pagination' });
// [Scaffolded component registrations will be inserted here. To retain this feature, don't remove this comment.]
// Set Markdown parser options
marked.setOptions({
smartypants: true
});
// Start the application
ko.applyBindings({ route: router.currentRoute });
});

223
src/components/db/db.js Normal file
View file

@ -0,0 +1,223 @@
define(['knockout', '../transfer/transfer', 'moment'], function(ko, transfer, moment) {
function DB(params) {
var self = this;
// List of all posts
self.posts = ko.observableArray();
// List of all pages
self.pages = ko.observableArray();
// Below are a few pure computeds for convenience, they should be autobuilt
// when the posts / pages are loaded
// All posts in a dict (slug as key) for faster retrieval of single post
self.postDict = ko.pureComputed(function() {
var ret = {};
_.forEach(self.posts(), function(post) {
ret[post().slug()] = post;
});
return ret;
});
// Dict of pages (slug as key)
self.pageDict = ko.pureComputed(function() {
var ret = {};
_.forEach(self.pages(), function(page) {
ret[page().slug()] = page;
});
return ret;
});
// Dict of tags (tag name as key) and the posts they have
self.tags = ko.pureComputed(function() {
var ret = {};
_.forEach(self.posts(), function(post) {
_.forEach(post().tags(), function(tag) {
if (!(tag in ret)) {
ret[tag] = ko.observableArray(post);
}
else {
ret[tag].push(post);
}
});
});
return ret;
});
// Dict of years (year number as key) and the posts for those years
self.years = ko.pureComputed(function() {
var ret = {};
_.forEach(self.posts(), function(post) {
var key = post().date().format('YYYY');
if (!(key in ret)) {
ret[key] = ko.observableArray(post);
}
else {
ret[key].push(post);
}
});
});
// Dict of months ('YYYY-MM' as key) and the posts for those months
self.months = ko.pureComputed(function() {
var ret = {};
_.forEach(self.posts(), function(post) {
var key = post().date().format('YYYY-MM');
if (!(key in ret)) {
ret[key] = ko.observableArray(post);
}
else {
ret[key].push(post);
}
});
});
/**
* Parse a line of tags separated by commas and return the tags in a list.
*/
self.parseTagLine = function(tagStr) {
var tags = tagStr.split(',');
var retTags = [];
_.forEach(tags, function(tag) {
retTags.push(tag.trim());
});
return retTags;
};
/**
* Parse data of a single post from a regex match, returning and observable
*/
self.parsePostData = function(matched) {
var tags = {};
if (!_.isUndefined(matched[3])) {
tags = self.parseTagLine(matched[3]);
}
var date = moment(matched[1], 'YYYY-MM-DD');
var slug = matched[2];
var post = ko.observable({
slug: ko.observable(slug),
date: ko.observable(date),
tags: ko.observable(tags),
// True if this posts title and content have been fetched from the
// individual post file
synced: ko.observable(false),
// These will be parsed later from the individual post file
title: ko.observable(null),
content: ko.observable(null),
isSplit: ko.observable(null),
shortContent: ko.observable(null)
});
return post;
};
/**
* Parse data of a single page from a regex match, returning and observable
*/
self.parsePageData = function(matched) {
var slug = matched[1];
var page = ko.observable({
slug: slug,
linkText: matched[2],
synced: false,
title: null,
isSplit: false
});
return page;
};
/**
* Parse the index file, putting its contents into the given
* observableArrays
*/
self.parseIndex = function(posts, pages, indexStr) {
var post_data_regex = /^(\d{4}-\d{2}-\d{2}) ([a-z0-9\-]+)(\s([^,]+?,?)*)?$/;
var page_data_regex = /^([a-z0-9\-]+) (.*)$/;
var lines = indexStr.split('\n');
var line_no = 1;
_.forEach(lines, function(line) {
var post_match = post_data_regex.exec(line);
var page_match = page_data_regex.exec(line);
if (post_match !== null) {
posts.push(self.parsePostData(post_match));
}
else if (page_match !== null) {
pages.push(self.parsePageData(page_match));
}
else {
console.log('Ignoring unmatched line ' + line_no + ': "' + line + '"');
}
++line_no;
});
// After loading all posts, sort them descending by date. Pages will be
// left as-is
self.posts.sort(function(left, right) {
return left().date().isSame(right().date())? 0
: (left().date().isBefore(right().date())? 1 : -1);
});
};
/**
* Parse data from a data file, updating the given observable
*/
self.parseData = function(obj, dataStr) {
var lines = dataStr.split('\n');
obj().title(lines[0]);
obj().synced(true);
obj().content(lines.slice(2).join('\n'));
var parts = obj().content().split('<!--SPLIT-->');
obj().shortContent(parts[0]);
obj().isSplit(parts.length > 1);
};
/**
* Parse index data and store it in the system.
*/
self.loadIndex = function() {
transfer.loadIndex().success(function(data) {
self.parseIndex(self.posts, self.pages, data);
});
};
/**
* Load a post with the given slug and update its data in the index.
*/
self.loadPost = function(slug) {
transfer.loadPost(slug).success(function(data) {
var post = self.postDict()[slug];
self.parseData(post, data);
});
};
/**
* Load a page with the given slug and update its dat a in the index.
*/
self.loadPage = function(slug) {
transfer.loadPage(slug).success(function(data) {
var page = self.pageDict()[slug];
self.parseData(page, data);
});
}
}
// This runs when the component is torn down. Put here any logic necessary to clean up,
// for example cancelling setTimeouts or disposing Knockout subscriptions/computeds.
DB.prototype.dispose = function() {};
return new DB();
});

View file

@ -0,0 +1,3 @@
<h2>Page: <!-- ko text: page --><!-- /ko --></h2>
<pagination params="posts: posts, page: page"></pagination>

View file

@ -0,0 +1,13 @@
define(["knockout", "text!./home.html", "../db/db"], function(ko, homeTemplate, DB) {
function HomeViewModel(route) {
var self = this;
self.posts = DB.posts;
self.page = ko.observable(1);
DB.loadIndex();
}
return { viewModel: HomeViewModel, template: homeTemplate };
});

View file

@ -0,0 +1,27 @@
<!--
The navigation UI that is docked to the top of the window. Most of this markup simply
follows Bootstrap conventions. The only Knockout-specific parts are the data-bind
attributes on the <li> elements.
-->
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">Laine</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li data-bind="css: { active: route().page === 'home-page' }">
<a href="#">Home</a>
</li>
</ul>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
define(['knockout', 'text!./nav-bar.html'], function(ko, template) {
function NavBarViewModel(params) {
// This viewmodel doesn't do anything except pass through the 'route' parameter to the view.
// You could remove this viewmodel entirely, and define 'nav-bar' as a template-only component.
// But in most apps, you'll want some viewmodel logic to determine what navigation options appear.
this.route = params.route;
}
return { viewModel: NavBarViewModel, template: template };
});

View file

@ -0,0 +1,21 @@
<div class="post-list" data-bind="foreach: { data: postsForPage(), as: 'post' }">
<single-post params="post: post, short: true"></single-post>
</div>
<ul class="pagination" data-bind="if: pageCount() > 1">
<li data-bind="css: { disabled: page() === 1 }">
<a data-bind="click: goBack">
&laquo;
</a>
</li>
<!-- ko foreach: _.range(1, pageCount() + 1) -->
<li data-bind="css: { active: $data === $parent.page() } ">
<a data-bind="click: $parent.goTo, text: $data"></a>
</li>
<!-- /ko -->
<li data-bind="css: { disabled: page() === pageCount() }">
<a data-bind="click: goForward">
&raquo;
</a>
</li>
</ul>

View file

@ -0,0 +1,45 @@
define(['knockout', 'text!./pagination.html'], function(ko, templateMarkup) {
function Pagination(params) {
var self = this;
self.posts = params.posts;
self.page = params.page;
self.postsPerPage = 3;
self.pageCount = ko.pureComputed(function() {
return Math.ceil(params.posts().length / self.postsPerPage);
});
self.goTo = function(page) {
if (page < 1) {
page = 1;
}
else if (page > self.pageCount()) {
page = self.pageCount();
}
self.page(page);
};
self.goBack = function() {
self.goTo(self.page() - 1);
}
self.goForward = function() {
self.goTo(self.page() + 1);
}
self.postsForPage = ko.pureComputed(function() {
var start = (self.page() - 1) * self.postsPerPage;
return self.posts.slice(start, start + self.postsPerPage);
});
}
// This runs when the component is torn down. Put here any logic necessary to clean up,
// for example cancelling setTimeouts or disposing Knockout subscriptions/computeds.
Pagination.prototype.dispose = function() {};
return { viewModel: Pagination, template: templateMarkup };
});

View file

@ -0,0 +1,9 @@
<div class="single-post">
<h2 data-bind="text: post.title()"></h2>
<p class="post-tags" data-bind="foreach: post.tags()">
<span data-bind="text: $data"></span><!-- ko if: ($index() < post.tags().length - 1) -->,<!-- /ko -->
</p>
<div class="post-content" data-bind="html: content() !== null? marked(content()) : ''"></div>
</div>

View file

@ -0,0 +1,32 @@
define(['knockout', 'text!./single-post.html', '../db/db', 'marked'],
function(ko, templateMarkup, DB, marked) {
function SinglePost(params) {
var self = this;
self.post = params.post;
self.marked = marked;
// Is this post shown as short (in pagination) or not?
self.short = params.short;
self.content = self.post.content;
if (self.short) {
self.content = self.post.shortContent;
}
// If this post is not fully fetched into the index yet, do that now
if (!self.post.synced()) {
DB.loadPost(self.post.slug());
// Show loading indicator before loading is done
self.post.title("Loading post contents…");
}
}
// This runs when the component is torn down. Put here any logic necessary to clean up,
// for example cancelling setTimeouts or disposing Knockout subscriptions/computeds.
SinglePost.prototype.dispose = function() {};
return { viewModel: SinglePost, template: templateMarkup };
});

View file

@ -0,0 +1,22 @@
define(['knockout'], function(ko) {
function Transfer(params) {
this.loadIndex = function() {
return $.get('index');
};
this.loadPost = function(slug) {
return $.get('posts/' + slug + '.md');
};
this.loadPage = function(slug) {
return $.get('pages/' + slug + '.md')
};
}
// This runs when the component is torn down. Put here any logic necessary to clean up,
// for example cancelling setTimeouts or disposing Knockout subscriptions/computeds.
Transfer.prototype.dispose = function() {};
return new Transfer();
});

3
src/css/styles.css Normal file
View file

@ -0,0 +1,3 @@
#page {
margin-top: 80px;
}

24
src/index.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Laine</title>
<!-- build:css -->
<link href="bower_modules/components-bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="css/styles.css" rel="stylesheet">
<!-- endbuild -->
<!-- build:js -->
<script src="app/require.config.js"></script>
<script data-main="app/startup" src="bower_modules/requirejs/require.js"></script>
<!-- endbuild -->
</head>
<body>
<nav-bar params="route: route"></nav-bar>
<div id="page" class="container" data-bind="component: { name: route().page, params: route }">
<div class="loading">
<h1>Loading…</h1>
</div>
</div>
</body>
</html>