Journo
A quick-and-dirty (literate) blogging engine
Install / Use
/learn @jashkenas/JournoREADME
Journo
Journo = module.exports = {}
Journo is a blogging program, with a few basic goals. To wit:
-
Write in Markdown.
-
Publish to flat files.
-
Publish via Rsync.
-
Maintain a manifest file (what's published and what isn't, pub dates).
-
Retina ready.
-
Syntax highlight code.
-
Publish a feed.
-
Quickly bootstrap a new blog.
-
Preview via a local server.
-
Work without JavaScript, but default to a fluid JavaScript-enabled UI.
You can install and use the journo command via npm: sudo npm install -g journo
... now, let's go through those features one at a time:
Getting Started
-
Create a folder for your blog, and
cdinto it. -
Type
journo initto bootstrap a new empty blog. -
Edit the
config.json,layout.html, andposts/index.mdfiles to suit. -
Type
journoto start the preview server, and have at it.
Write in Markdown
We'll use the excellent marked module to compile Markdown into HTML, and Underscore for many of its goodies later on. Up top, create a namespace for shared values needed by more than one function.
marked = require 'marked'
_ = require 'underscore'
shared = {}
To render a post, we take its raw source, treat it as both an Underscore
template (for HTML generation) and as Markdown (for formatting), and insert it
into the layout as content.
Journo.render = (post, source) ->
catchErrors ->
do loadLayout
source or= fs.readFileSync postPath post
variables = renderVariables post
markdown = _.template(source.toString()) variables
title = detectTitle markdown
content = marked.parser marked.lexer markdown
shared.layout _.extend variables, {title, content}
A Journo site has a layout file, stored in layout.html, which is used
to wrap every page.
loadLayout = (force) ->
return layout if not force and layout = shared.layout
shared.layout = _.template(fs.readFileSync('layout.html').toString())
Publish to Flat Files
A blog is a folder on your hard drive. Within the blog, you have a posts
folder for blog posts, a public folder for static content, a layout.html
file for the layout which wraps every page, and a journo.json file for
configuration. During a build, a static version of the site is rendered
into the site folder, by rsyncing over all static files, rendering and
writing every post, and creating an RSS feed.
fs = require 'fs'
path = require 'path'
{spawn, exec} = require 'child_process'
Journo.build = ->
do loadManifest
fs.mkdirSync('site') unless fs.existsSync('site')
exec "rsync -vur --delete public/ site", (err, stdout, stderr) ->
throw err if err
for post in folderContents('posts')
html = Journo.render post
file = htmlPath post
fs.mkdirSync path.dirname(file) unless fs.existsSync path.dirname(file)
fs.writeFileSync file, html
fs.writeFileSync "site/feed.rss", Journo.feed()
The config.json configuration file is where you keep the configuration
details of your blog, and how to connect to the server you'd like to publish
it on. The valid settings are: title, description, author (for RSS), url , publish (the user@host:path location to rsync to), and publishPort
(if your server doesn't listen to SSH on the usual one).
An example config.json will be bootstrapped for you when you initialize a blog,
so you don't need to remember any of that.
loadConfig = ->
return if shared.config
try
shared.config = JSON.parse fs.readFileSync 'config.json'
catch err
fatal "Unable to read config.json"
shared.siteUrl = shared.config.url.replace(/\/$/, '')
Publish via rsync
Publishing is nice and rudimentary. We build out an entirely static version of the site and rsync it up to the server.
Journo.publish = ->
do Journo.build
rsync 'site/images/', path.join(shared.config.publish, 'images/'), ->
rsync 'site/', shared.config.publish
A helper function for rsyncing, with logging, and the ability to wait for the rsync to continue before proceeding. This is useful for ensuring that our any new photos have finished uploading (very slowly) before the update to the feed is syndicated out.
rsync = (from, to, callback) ->
port = "ssh -p #{shared.config.publishPort or 22}"
child = spawn "rsync", ['-vurz', '--delete', '-e', port, from, to]
child.stdout.on 'data', (out) -> console.log out.toString()
child.stderr.on 'data', (err) -> console.error err.toString()
child.on 'exit', callback if callback
Maintain a Manifest File
The "manifest" is where Journo keeps track of metadata -- the title, description, publications date and last modified time of each post. Everything you need to render out an RSS feed ... and everything you need to know if a post has been updated or removed.
manifestPath = 'journo-manifest.json'
loadManifest = ->
do loadConfig
shared.manifest = if fs.existsSync manifestPath
JSON.parse fs.readFileSync manifestPath
else
{}
do updateManifest
fs.writeFileSync manifestPath, JSON.stringify shared.manifest
We update the manifest by looping through every post and every entry in the
existing manifest, looking for differences in mtime, and recording those
along with the title and description of each post.
updateManifest = ->
manifest = shared.manifest
posts = folderContents 'posts'
delete manifest[post] for post of manifest when post not in posts
for post in posts
stat = fs.statSync postPath post
entry = manifest[post]
if not entry or entry.mtime isnt stat.mtime
entry or= {pubtime: stat.ctime}
entry.mtime = stat.mtime
content = fs.readFileSync(postPath post).toString()
entry.title = detectTitle content
entry.description = detectDescription content, post
manifest[post] = entry
yes
Retina Ready
In the future, it may make sense for Journo to have some sort of built-in facility for automatically downsizing photos from retina to regular sizes ... But for now, this bit is up to you.
Syntax Highlight Code
We syntax-highlight blocks of code with the nifty highlight package that includes heuristics for auto-language detection, so you don't have to specify what you're coding in.
{Highlight} = require 'highlight'
marked.setOptions
highlight: (code, lang) ->
Highlight code
Publish a Feed
We'll use the rss module to build a simple feed of recent posts. Start with
the basic author, blog title, description and url configured in the
config.json. Then, each post's title is the first header present in the
post, the description is the first paragraph, and the date is the date you
first created the post file.
Journo.feed = ->
RSS = require 'rss'
do loadConfig
config = shared.config
feed = new RSS
title: config.title
description: config.description
feed_url: "#{shared.siteUrl}/rss.xml"
site_url: shared.siteUrl
author: config.author
for post in sortedPosts()[0...20]
entry = shared.manifest[post]
feed.item
title: entry.title
description: entry.description
url: postUrl post
date: entry.pubtime
feed.xml()
Quickly Bootstrap a New Blog
We init a new blog into the current directory by copying over the contents
of a basic bootstrap folder.
Journo.init = ->
here = fs.realpathSync '.'
if fs.existsSync 'posts'
fatal "A blog already exists in #{here}"
bootstrap = path.join(__dirname, 'bootstrap')
exec "rsync -vur --delete #{bootstrap} .", (err, stdout, stderr) ->
throw err if err
console.log "Initialized new blog in #{here}"
Preview via a Local Server
Instead of constantly rebuilding a purely static version of the site, Journo
provides a preview server (which you can start by just typing journo from
within your blog).
Journo.preview = ->
http = require 'http'
mime = require 'mime'
url = require 'url'
util = require 'util'
do loadManifest
server = http.createServer (req, res) ->
rawPath = url.parse(req.url).pathname.replace(/(^\/|\/$)/g, '') or 'index'
If the request is for a preview of the RSS feed...
if rawPath is 'feed.rss'
res.writeHead 200, 'Content-Type': mime.lookup('.rss')
res.end Journo.feed()
If the request is for a static file that exists in our public directory...
else
publicPath = "public/" + rawPath
fs.exists publicPath, (exists) ->
if exists
res.writeHead 200, 'Content-Type': mime.lookup(publicPath)
fs.createReadStream(publicPath).pipe res
If the request is for the slug of a valid post, we reload the layout, and render it...
else
post = "posts/#{rawPath}.md"
fs.exists post, (exists) ->
if exists
loadLayout true
fs.readFile post, (err, content) ->
res.writeHead 200, 'Content-Type': 'text/html'
res.end Journo.render post, content
Anything else is a 404. (Does anyone know a cross-platform equivalent of the
OSX open command?)
else
res.writeHead 404
res.end '404 Not Found'
server.listen 1234
console.log "Journo is previewing at http://localhost:1234"
exec "open http://localhost:1234"
Work Without JavaScript, But Default to a Fluid JavaScript-Enabled UI
The best way to handle this bit seems to be entirely on the client-side. For example, when rendering a JavaScript slideshow of photogra
Related Skills
node-connect
332.3kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
81.7kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
332.3kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
81.7kCommit, push, and open a PR
