Commit 3de6ba0e authored by wu-lee's avatar wu-lee

Merge branch 'ghostwriter-theme'

* ghostwriter-theme: (48 commits)
  theme/assets/sass/style.scss - center featured images
  theme/package.json - use compressed sass style
  theme/partials/default.hbs - link title to wiki, not home
  theme/ - tweak post/page index look
  theme/assets/images/favicon.ico - add
  theme/partials/default.hbs - title link -> base, not home
  lib/helpers.js - fix url helper's absolute -> relative conversion
  metalsmith.js - only trim .md, .html from titles
  theme/assets/sass/style.scss - styles for (registration) forms
  theme/partials/navigation.hbs - use title as default label
  metalsmith.js / theme - add a sitemap index
  metalsmith.js - add a long title (includes path)
  theme/ - omit pagination
  theme/partials/default.hbs - show blog index on posts
  theme/ - customise the header somewhat for logo/navigation
  theme/package.json - add sass, so we can re-compile the sass
  theme/partials/post-content.hbs - add post creation date
  metalsmith.js - sort posts by creation date
  theme/partials/loop.hbs - show git first-authored date
  theme/* - remove RSS links
  ...
parents 770eadb5 81c8e730
* + * {
margin-top: 1.5em;
}
body {
background: #17191F;
color: #f7f7f7;
font-family: -apple-system, BlinkMacSystemFont, 'avenir next', avenir, 'helvetica neue', helvetica, ubuntu, roboto, noto, 'segoe ui', arial, sans-serif;
margin-top: 1rem;
/*margin: 0 4rem;*/
/*display: flex;*/
/*flex-direction: column;*/
/*align-items: center;*/
}
#container {
background: #1F232B;
display: flex;
flex-direction: column;
/* align-items: center;*/
width: 50%;
margin: auto;
border-radius: .4rem;
padding: 1em;
}
a {
text-decoration: none;
color: #2b90d9;
}
a:hover, a:active {
text-decoration: underline;
}
img {
width: 20%;
margin: 1.5rem 0;
}
h1 {
font-size: 1.8rem;
text-align: center;
margin: 0
}
form {
}
label {
font-size: 1.1rem;
display: block;
margin-bottom: 0.6rem;
}
input[type='text'],
input[type='email'],
input[type='date'] {
-webkit-appearance: none;
-moz-appearance: none;
border-style: solid;
border-width: 1px;
border-color: rgba( 0, 0, 0, .2 );
padding: .5rem 0;
margin-bottom: 1rem;
display: block;
width: 100%;
max-width: 100%;
min-width: 100%;
}
textarea {
-webkit-appearance: none;
-moz-appearance: none;
border-style: solid;
border-width: 1px;
border-color: rgba( 0, 0, 0, .2 );
padding: .5rem 0;
margin-bottom: 1rem;
display: block;
width: 100%;
min-width: 100%
max-width: 100%;
}
.checklist li {
list-style-type: none;
margin: initial;
}
#submit {
/*display: inline;*/
/*float: left;*/
background: #56A7E1;
font-weight: bold;
padding: 1rem;
margin-top: 2rem;
margin-bottom: 2rem;
text-align: center;
font-size: 1rem;
border-style: none;
border-width: 0;
color: white;
border-radius: .25rem;
/*width: 100%;*/
}
#submit:hover {
cursor: pointer;
}
@media (max-width: 1000px) {
#container {
width: 90%
}
img {
width: 30%;
}
h1 {
font-size: 1.5rem;
}
label {
font-size: 1rem;
}
form {
width: 75%;
}
}
/* Really, the following styles are just slapdash scaffolding */
#top {
display: table;
width: 100%;
}
#top > * {
display: table-cell;
vertical-align: middle;
}
#description {
font-size: xx-large;
text-align: center;
}
#menu ul, #menu li {
display: inline-block;
}
#menu li {
background: #1F232B;
border-radius: .4rem;
padding: 0.5em;
margin: 1em 0.1em;
}
#left-sidebar {
}
module.exports = {
theme: {
timezone: 'Europe/London',
title: 'Example Title',
description: 'Example description',
logo: 'images/example_logo.svg',
urls: {
// The site home URL - logo links to this
home: 'https://example.com',
// The top level url of the wiki
base: 'https://example.com/wiki',
// An url to prepend author usernames to
authorbase: 'https://git.coop/',
},
},
port: 9999,
email: {
server: {
......
const Handlebars = require('handlebars');
[
function wikiLink(path) {
// This requires wikiurl to be defined in global metadata
path = path.replace(/[.]md$/, '');
path = path.replace(/^\//, '');
const wikiurl = this.wikiurl.replace(/\/+$/,'/');
return new Handlebars.SafeString(wikiurl+path);
},
function authorLink(author) {
// This assumes a GitLab instance
const baseurl = new URL(this.wikiurl).origin;
const url = encodeURIComponent('https://git.coop/'+author);
const text = Handlebars.Utils.escapeExpression(author);
return new Handlebars.SafeString(
`<a href="${url}">${text}</a>`
);
},
].map(f => Handlebars.registerHelper(f.name, f));
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta name="description" content="{{ description }}">
<link rel="stylesheet" href="/style.css" media="all">
<title>{{ title }}</title>
</head>
<body>
<div id="top">
<img src="/img/social_coop_logo.svg" alt="social.coop logo: three white trees in a green circle">
<span id="menu">{{> menu }}</span>
</div>
<div>
<span id="left-sidebar">
{{> posts }}
</span>
<span id="container">
<div id="description">{{ title }}</div>
<div id="content">
{{{ contents }}}
</div>
</span>
</div>
<div id="bottom">
<span>
Last modified: <a href="{{{wikiLink path }}}">{{ git.authored }}</a>
</span>
<span>
By: <a ref="{{{authorLink git.author }}}">{{ git.author }}</a>
</span>
</div>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
</head>
<body>
<header>
<p>
<a href="/">Home</a>
</p>
</header>
<h1>{{ title }}</h1>
<time>{{ date }}</time>
{{{ contents }}}
<footer>
<p>Generated with {{ generator }} &mdash; <a href="{{ url }}">{{ url }}</a></p>
</footer>
</body>
</html>
/**
Minimal/custom git functionality.
Basically, just pull, and gather working directory metadata.
*/
'use strict';
const shell = require('shelljs');
shell.config.silent = true;
shell.config.fatal = true;
module.exports = (opts = {}) => {
if (!opts.src)
opts.src = './src';
/** Helper function for accumulateFiles. Indicates when *not* to
* update the working value a when compare to a commit b
*/
const compare = {
first: (a,b) => a < b,
last: (a,b) => a > b,
};
// Reduce accumulator for git-log
//
// @param meta - the accumulator object, expected to be:
// { list: <list>, map: <map> }
// where <list> is an array in which to
// build an ordered list of commit IDs,
// and <map> is a simple object in which
// to map commit IDs to to author and time
// information in this form:
// { authored: <date>, author: <string>
// committed: <date>, committer: <string>}
// @param line - a line from git diff-tree to parse
//
// @returns an updated accumulator object
function accumulateCommits(meta, line) {
// fields are:
// commit ID,
......@@ -17,28 +46,64 @@ module.exports = (opts = {}) => {
var authored = fields[1] && new Date(fields[1]*1000);
var committed = fields[3] && new Date(fields[3]*1000);
meta[fields[0]] = {
meta.map[fields[0]] = {
authored: authored,
author: fields[2],
committed: committed,
committer: fields[4],
committer: fields[4],
};
meta.list.push(fields[0]);
return meta;
}
// Reduce accumulator for git-diff-tree
// Reduce accumulator for git-diff-tree.
//
// @param meta - the accumulator object:
// { commits: <commitlog>, files: <filemap> }
// where <commitlog> is the output from
// accumulateCommits, and <filemap> is an object
// in which to map file paths to information
// about the first and last commits
// touching it (omitting any commits prior to
// a delete):
// { first: <commit>, last: <commit> }
// Where <commit> is an entry in the <commitlog>,
// @param line - a line from git diff-tree to parse
//
// @returns the updated accumulator object with the complete file
// map
function accumulateFiles(meta, line) {
const match = line.match(/([A-Z])\s+(.*)/);
if (match) {
// Parse a file modification
if (match[1] === 'D') {
delete meta.files[match[2]];
delete meta.files[match[2]]; // forget this entry
}
else {
meta.files[match[2]] =
meta.commits[meta.commitid];
const fileinfo = meta.files[match[2]] ||
(meta.files[match[2]] = {first: {}, last: {}});
const commitinfo = meta.commits[meta.commitid];
// A common block of code which compares and updates a
// fileinfo user/date value when appropriate.
const update = (who, what, when) => {
if (fileinfo[when][what] &&
compare[when](fileinfo[when][what],
commitinfo[what]))
return; // nothing to do
// update the records
fileinfo[when][what] = commitinfo[what];
fileinfo[when][who] = commitinfo[who];
};
update('author', 'authored', 'first');
update('author', 'authored', 'last');
update('committer', 'committed', 'first');
update('committer', 'committed', 'last');
}
}
else {
......@@ -70,7 +135,59 @@ module.exports = (opts = {}) => {
};
}
/** gets git metadata */
/** Gets git metadata for all the files in the repo.
*
* Example return format:
*
* { '.gitignore':
* { first:
* { authored: 2019-01-28T21:46:14.000Z,
* author: 'bob',
* committed: 2019-01-28T21:46:14.000Z,
* committer: 'alice' },
* last:
* { authored: 2019-02-10T10:56:29.000Z,
* author: 'alice',
* committed: 2019-02-10T23:32:17.000Z,
* committer: 'bob' },
* span: { authored: 1084215000, committed: 1129563000 } },
* 'package.json':
* { first:
* { authored: 2019-01-28T21:49:24.000Z,
* author: 'alice',
* committed: 2019-01-28T23:31:58.000Z,
* committer: 'alice' },
* last:
* { authored: 2019-02-20T10:45:48.000Z,
* author: 'bob',
* committed: 2019-02-25T17:45:38.000Z,
* committer: 'alice' },
* span: { authored: 1947384000, committed: 2398420000 } },
* 'serve.js':
* { first:
* { authored: 2019-01-28T23:33:30.000Z,
* author: 'alice',
* committed: 2019-01-28T23:37:09.000Z,
* committer: 'bob' },
* last:
* { authored: 2019-02-10T10:54:55.000Z,
* author: 'alice',
* committed: 2019-02-10T23:32:17.000Z,
* committer: 'bob' },
* span: { authored: 1077685000, committed: 1122908000 } },
* // ...
* }
*
* Implementation adapted from:
*
* https://stackoverflow.com/questions/19166483/git-how-to-list-all-files-under-version-control-along-with-their-author-date
*
* Note: first and last dates are more strictly the min and max
* found since the last delete. authorship taken from equivalent
* commits.
*
* Note: does not currently detect renames.
*/
function fileInfoSync() {
const execOpts = {cwd: opts.src, silent: true};
......@@ -87,13 +204,11 @@ module.exports = (opts = {}) => {
// Parse the results into a map of commit IDs to timestamps
const commits = result1.stdout
.split(/\n/)
.reduce(accumulateCommits, {});
const commitids = Object.keys(commits);
.reduce(accumulateCommits, {map:{}, list: []});
// Get the files touched by each commit
const result2 = shell
.ShellString(commitids.join("\n"), {silent: true})
.ShellString(commits.list.join("\n")+"\n", {silent: true})
.exec('git diff-tree -r --root --name-status --stdin',
execOpts);
......@@ -106,8 +221,16 @@ module.exports = (opts = {}) => {
const acc = result2
.stdout
.split(/\n/)
.reduce(accumulateFiles, {commits, files:{}});
.reduce(accumulateFiles, {commits:commits.map, files:{}});
for(const file in acc.files) {
const info = acc.files[file];
info.span = {
authored: info.last.authored - info.first.authored,
committed: info.last.committed - info.first.committed,
};
}
return acc.files;
}
......
const Handlebars = require('handlebars');
const moment = require('moment-timezone');
function a(text, path, baseurl) {
const url = new URL(path, baseurl);
text = Handlebars.Utils.escapeExpression(text);
return `<a href="${url}">${text}</a>`;
}
function getopts(args) {
if (args.length > 0) {
args.length -= 1;
const opts = args[args.length];
delete args[args.length];
return Array.prototype.concat.apply([opts], args);
}
return [{}];
}
module.exports = {
wikiLink: function() {
const [opts, path] = getopts(arguments);
// This requires wikiurl to be defined in global metadata
path = path.replace(/[.]md$/, '');
path = path.replace(/^\//, '');
const wikiurl = opts.data.root.wikiurl.replace(/\/+$/,'/');
return new Handlebars.SafeString(wikiurl+path);
},
authorLink: function() {
const [opts, author] = getopts(arguments);
const link = a(author, author, opts.data.root.config.urls.authorbase);
return new Handlebars.SafeString(link);
},
// Ghost emulations
asset: function(path) {
return '/'+path;
},
content: function() {
const [opts] = getopts(arguments);
return opts.data.root.contents;
},
'is-in': function() {
var [options, types] = getopts(arguments);
const collections = options.data.root.collection;
if (collections) {
switch(typeof types) {
case 'string':
types = types.split(',').map(s => s.trim());
break;
case 'array':
break;
case 'undefined':
throw new Error("#is helper must have a parameter");
default:
throw new Error("#is helper parameter must be comma-delimited string or array");
}
// search collections and types to find a matching type
const ismatch = types.reduce(
(result, type) => result || collections.indexOf(type) >= 0,
false
);
if (ismatch) {
return options.fn(this);
}
}
return options.inverse(this);
},
foreach: function(items, options) {
// we need options
if (!options) {
throw new Error('Must pass iterator to #foreach');
}
function filterItemsByVisibility(items, options) {
return items; // stub, reimplement later
}
if (options.data && options.ids) {
contextPath = Handlebars.Utils.appendContextPath(options.data.contextPath, options.ids[0]) + '.';
}
if (typeof(items) === 'function') {
items = items.call(this);
}
// Exclude items which should not be visible in the theme
items = filterItemsByVisibility(items, options);
// Initial values set based on parameters sent through. If
// nothing sent, set to defaults
var fn = options.fn,
inverse = options.inverse,
columns = options.hash.columns,
length = items.length,
limit = Math.floor(options.hash.limit) || length,
from = Math.floor(options.hash.from) || 1,
to = Math.floor(options.hash.to) || length,
output = '',
data,
contextPath;
// If a limit option was sent through (aka not equal to
// default (length)) and from plus limit is less than the
// length, set to to the from + limit
if ((limit < length) && ((from + limit) <= length)) {
to = (from - 1) + limit;
}
if (options.data) {
data = Handlebars.createFrame(options.data);
}
function execIteration(field, index, last) {
if (data) {
data.key = field;
data.index = index;
data.number = index + 1;
data.first = index === from - 1; // From uses 1-indexed, but array uses 0-indexed
data.last = !!last;
data.even = index % 2 === 1;
data.odd = !data.even;
data.rowStart = index % columns === 0;
data.rowEnd = index % columns === (columns - 1);
}
output = output + fn(items[field], {
data: data,
blockParams: Handlebars.Utils.blockParams([items[field], field], [contextPath + field, null])
});
}
function iterateCollection(context) {
// Context is all posts on the blog
var current = 1;
// For each post, if it is a post number that fits within
// the from and to, send the key to execIteration to be
// added to the page
context.forEach((item, key) => {
if (current < from) {
current += 1;
return;
}
if (current <= to) {
execIteration(key, current - 1, current === to);
}
current += 1;
});
}
if (items && typeof items === 'object') {
iterateCollection(items);
}
return output;
},
post: function() {
const [opts] = getopts(arguments);
if (opts.fn) {
const str = opts.fn(this, opts.data);
//console.log("inner", this, arguments, `(${str})`);
return str;
}
else {
return opts.post;
}
},
img_url: function() {
const [options, path] = getopts(arguments);
const baseurl = options.data.root.config.urls.base;
var outputUrl = new URL(path, baseurl).href;
if (!options.hash.absolute)
outputUrl = outputUrl.substring(baseurl.length);
outputUrl = encodeURI(outputUrl);
return new Handlebars.SafeString(outputUrl);
},
encode: function(string, options) {
var uri = string || options;
return new Handlebars.SafeString(encodeURIComponent(uri));
},
// meta_title
url: function() {
const [options, path] = getopts(arguments);
const baseurl = options.data.root.config.urls.base;
var outputUrl = new URL(path || this.path, baseurl);
// FIXME: is absolute /blah or https://foo/blah?
if (!options.hash.absolute)
outputUrl = outputUrl.href;
else
outputUrl = outputUrl.href.substring(outputUrl.origin.length);
outputUrl = encodeURI(outputUrl);
return new Handlebars.SafeString(outputUrl);
},
date: function() {
var [options, date] = getopts(arguments);
var timezone, format, timeago, timeNow, dateMoment;
const root = options.data.root;
if (!date) {
timezone = root.config.timezone;
// set to published_at by default, if it's available
// otherwise, this will print the current date
if (root.git) {
date = moment(root.git.authored).tz(timezone).format();
}
}
// ensure that context is undefined, not null, as that can cause errors
date = date === null ? undefined : date;
format = options.hash.format || 'MMM DD, YYYY';
timeago = options.hash.timeago;
timezone = this.timezone;
timeNow = moment().tz(timezone);
// i18n: Making dates, including month names, translatable to any language.
// Documentation: http://momentjs.com/docs/#/i18n/
// Locales: https://github.com/moment/moment/tree/develop/locale
dateMoment = moment(date);
//dateMoment.locale(i18n.locale()); // Commented to avoid i18n dep
if (timeago) {
date = timezone ? dateMoment.tz(timezone).from(timeNow) : dateMoment.fromNow();
} else {
date = timezone ? dateMoment.tz(timezone).format(format) : dateMoment.format(format);
}
return new Handlebars.SafeString(date);
},
};
......@@ -7,9 +7,10 @@ const markdown = require('metalsmith-markdown');
const permalinks = require('metalsmith-permalinks');
const metalsmithDebug = require('metalsmith-debug');
const discoverPartials = require('metalsmith-discover-partials');
const discoverHelpers = require('metalsmith-discover-helpers');
const singular = require('pluralize').singular;
const winston = require('winston');
const fs = require('fs');
const helpers = require('./lib/helpers.js');
const git = require('./lib/git.js')();
const debug = require('debug')('metalsmith.js');
const config = require('./lib/config.js');
......@@ -28,14 +29,14 @@ function forFiles(onFile) {