Sure, web apps are all the rage these days, with their shiny and polished interfaces and megabytes of JS, but what if you just want to write a good ‘ol terminal program using some of the really nice libraries developed for the web? (Ahem, Vue)
I’ll wait for the laughter to quiet down…
… Okay, so, it actually is quite possible, thanks to Yun Lai who has written blessed-vue, a Vue runtime for blessed. Blessed is a node-based curses/ncurses-style library providing GUI-style terminal interface capabilities for your apps.
We won’t go into all the details on blessed and how it works here, but it would be a good idea to take a look at their (very detailed) README for more information on that. What we’re here to do is write a terminal app with Vue, so let’s get started.
Getting Started
We’ll need a few dependencies for the app we’re writing, a few for the build process, and a webpack configuration file (just for vue-loader).
Here’s our app dependency list…
{ ... "dependencies": { "axios": "^0.16.1", "blessed-vue": "^1.0.0", "opn": "^4.0.2", "feedme": "^1.0.0", "vue": "^2.2.1" }, ... }
… and our dev dependency list.
{ ... "devDependencies": { "vue-loader": "^11.1.4", "vue-template-compiler": "^2.2.1", "webpack": "^2.2.0", "webpack-node-externals": "^1.5.4" } ... }
Which can be installed with NPM or Yarn:
# Yarn $ yarn add vue-loader vue-template-compiler webpack webpack-node-externals -D $ yarn add vue blessed-vue axios opn feedme # NPM $ npm install vue-loader vue-template-compiler webpack webpack-node-externals --save-dev $ npm install vue blessed-vue axios opn feedme --save
And the webpack config file.
webpack.config.js
const path = require('path') const webpack = require('webpack') const nodeExternals = require('webpack-node-externals') module.exports = { entry: './src/main.js', output: { path: path.resolve(__dirname, './dist'), filename: 'build.js', libraryTarget: 'commonjs' }, target: 'node', externals: [nodeExternals()], module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', } ] } }
Finally, just for convenience, I’ll throw the npm build scripts here, though they’re not really complicated. npm run dev will recompile the program every time a file changes, while npm run build will only build it once.
{ ... "scripts": { "dev": "webpack --progress --hide-modules --watch", "build": "webpack --progress --hide-modules", } ... }
Now, we can actually write the thing.
Creating a Terminal Program
The goal here is to create a somewhat nice-looking terminal program to pull the list of most recent articles from Alligator.io’s RSS feed and display it in the terminal, allowing you to select an article title and have it open in your browser.
Let’s start out with the main bootstrap file. There are a few important differences from a normal Vue program, namely the import of blessed-vue instead of vue and the use of a fake root element since there’s no DOM in the terminal.
src/main.js
// Make sure to import from 'blessed-vue' instead of 'vue'! import Vue from 'blessed-vue'; import App from './App.vue'; // Create a new fake root element. (Since blessed has no real DOM) const el = Vue.dom.createElement(); Vue.dom.append(el); new Vue({ render: h => h(App) }) // Mount to the fake element. .$mount(el);
Now, we need the app component. You could, of course, use multiple components for this, but since this is such a simple app, I’m eschewing that in favor of simplicity. See the comments for an explanation of what on earth is going on here. ;)
<template> <!-- Every blessed app needs a root "screen" element. smartCSR is scroll-region painting, :keys enables... keyboard support. autoPadding tells the renderer to take padding and borders into account when positioning things. --> <screen ref='screen' :smartCSR="true" :keys="true" :autoPadding="true"> <!-- You'll probably notice that width and height need to be specified explicitly here. This is because blessed positioning is more like CSS absolute positioning than anything else. Also, blessed has no CSS support. Styles compile down into JS objects. You'll also notice there's no style inheritance. So background and foreground colors need to be specified for every element. :( --> <box style='bg: #3FA767; fg: #F9EC31;' width="100%" height="9%"> <text style='bg: #3FA767; fg: #F9EC31; bold: true;' top="center" left="center" content="AlligatorIO - Feed Readerish Thing" /> </box> <box style='bg: black; fg: #3FA767' width="100%" height="92%" top="9%"> <!-- Render loading text if the feed hasn't loaded yet. --> <text v-if="isLoading" style="bg: black; fg: #3FA767; bold: true;" top="center" left="center" content="Loading..." /> <!-- A list that displays a scrollable and selectable list of text. In this case, the feed titles. :keys="true" and :mouse="true" enable keyboard and mouse navigation in the list. --> <list v-else height="100%" width="100%" :border="{}" :style="listStyle" :keys="true" :mouse="true" :items="feedTitles" @select="handleListSelect" /> </box> </screen> </template> <script> // The feed parser. import FeedMe from 'feedme'; // A request library. You could use node's HTTP instead, but I'm lazy. import axios from 'axios'; // A tiny module to open links in the default browser. import opn from 'opn'; export default { data: () => ({ feed: null, isLoading: true, // Note we use JS styles for the list because the object is so large it would // be a pain to do in the template. listStyle: { bg: 'black', fg: '#3FA767', border: { bg: 'black', fg: '#3FA767', }, selected: { bg: '#444', fg: '#F9EC31' } } }), computed: { // Produces a list of article titles from the feed object. feedTitles() { if(this.feed && this.feed.items && this.feed.items.length) { return this.feed.items.map(item => item.title); } } }, methods: { // Opens the selected list item in a browser. handleListSelect(event) { const feedIndex = this.feedTitles.indexOf(event.content); const feedItem = this.feed.items[feedIndex]; opn(feedItem.link); } }, mounted() { // Close the program when CTRL+C is pressed. this.$refs.screen.key(['C-c'], () => { process.exit(0); }); // Load the feed. axios.get('https://alligator.io/feed.xml', { responseType: 'stream' }) .then(response => { const parser = new FeedMe(true); response.data.pipe(parser); parser.on('end', () => { this.isLoading = false; this.feed = parser.done(); }); }); } } </script>
Running it!
Once all the code is in place, run npm run dev
to start webpack. Then, use node dist/build.js
to start the program. Easy-peasy!
And that’s all! You now (hopefully) have a fully functional Alligator.io feed list reader thingy running in your terminal, written with Vue.js!