Commit ffb067b9 authored by Dylan R. E. Moonfire's avatar Dylan R. E. Moonfire
Browse files

Initial commit of the command-line interface.

- Handles EPUB and a simple clean format.
- Creates a pipeline to process contents files.
- Generates TOC entries based on the theme.
parent 632aa7cc
# EditorConfig is awesome: http://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
indent_brace_style = K&R
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 80
tab_width = 4
trim_trailing_whitespace = true
curly_bracket_next_line = true
[*.{js,ts}]
quote_type = double
[*.json]
indent_size = 2
tab_width = 2
*~
.tscache
/.ntvs_analysis.dat
/.ntvs_analysis.dat.tmp
.baseDir.ts
npm-debug.log
*.tgz
src/*.js
src/*.js.map
src/*.d.ts
lib/
es6/
amd/
umd/
dist/
commonjs/
spec/*.js
spec/*.js.map
spec/*.d.ts
node_modules/
typings/
*~
*.tgz
TODO.markdown
.editorconfig
.jsbeautifyrc
src/
lib/spec/
typings/
tsd.json
#!/bin/bash
rdlkf() { [ -L "$1" ] && (local lk="$(readlink "$1")"; local d="$(dirname "$1")"; cd "$d"; local l="$(rdlkf "$lk")"; ([[ "$l" = /* ]] && echo "$l" || echo "$d/$l")) || echo "$1"; }
DIR="$(dirname "$(rdlkf "$0")")"
exec /usr/bin/env node --harmony "$DIR/../lib/cli.js" "$@"
{
"name": "mfgames-writing-format",
"version": "0.0.0",
"description": "A command-line framework for formatting books into a variety of formats.",
"repository": {
"type": "git",
"url": "git+https://gitlab.com/mfgames-writing-js/mfgames-writing-format-js.git"
},
"keywords": [
"ebook",
"pdf",
"latex",
"markdown",
"html",
"epub"
],
"author": {
"email": "d.moonfire@mfgames.com",
"name": "Dylan R. E. Moonfire",
"url": "https://mfgames.com/"
},
"license": "MIT",
"bugs": {
"url": "https://gitlab.com/mfgames-writing-js/mfgames-writing-format-js/issues"
},
"homepage": "https://gitlab.com/mfgames-writing-js/mfgames-writing-format-js#README",
"main": "lib/index.js",
"bin": {
"mfgames-writing-format": "./bin/mfgames-writing-format"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@types/bluebird": "^2.0.22-alpha",
"@types/fs-finder": "^1.8.21-alpha",
"@types/js-yaml": "^3.5.21-alpha",
"@types/marked": "0.0.26-alpha",
"@types/node": "^4.0.22-alpha",
"@types/uuid": "^3.3.26-alpha",
"@types/yargs": "0.0.21-alpha",
"mfgames-writing-contracts": "*",
"typescript": "^2.1.0-dev.20160720"
},
"dependencies": {
"bluebird": "^3.4.1",
"fs-finder": "^1.8.1",
"incremental": "^1.0.1",
"js-yaml": "^3.6.1",
"liquid-node": "^2.6.1",
"marked": "^0.3.5",
"mfgames-ncx": "0.0.0",
"mfgames-opf": "0.0.0",
"mfgames-writing-contracts": "0.0.0",
"source-map-support": "^0.4.2",
"uuid": "^2.0.2",
"yaml-front-matter": "^3.4.0",
"yargs": "^4.7.1",
"zpad": "^0.5.0"
}
}
/// <reference path="../node_modules/@types/node/index.d.ts"/>
import * as path from "path";
import * as fs from "fs";
import { PublicationData, EditionData, ContentData, Formatter, Theme, PublicationArgs, EditionArgs } from "mfgames-writing-contracts";
import * as Promise from "bluebird";
import { loadModule } from "./plugins";
import { loadConfig } from "./configs";
import { loadContents, appendContents, renderContents } from "./content";
let Finder = require("fs-finder");
let mkdirp = require("mkdirp-promise");
let uuid = require("uuid");
let liquid = require("liquid-node");
export function runBuild(argv: any, packageJson: any, logger: any) {
// The entire build system is based on promises. We start with the publication
// file loading and everything flows from there.
let args = new PublicationArgs(logger, packageJson, argv);
loadPublication(args)
.then(chooseEditions)
.then(data => { args.logger.info("Finished processing output"); });
}
/**
* Creates a promise that finds an appropriate `publication.ext` files (JSON or
* YAML) and loads it into memory. If one cannot be found, then this rejects the
* promise, otherwise it returns the resulting file.
*/
function loadPublication(args: PublicationArgs): Promise<PublicationArgs> {
let promise = new Promise<PublicationArgs>((resolve, reject) => {
// Find the publication file in the current working directory.
let currentDir = path.resolve(process.cwd(), ".");
args.logger.debug("currentDir", currentDir);
// Attempt to find the file at the current directory. We look for common
// file extensions such as "yaml", "yml", and "json".
let files = Finder.in(currentDir).lookUp().findFiles("publication.<(ya?ml|json)>");
args.logger.debug("publicationFiles", files);
if (files.length == 0) {
args.logger.error("Cannot find publication.{yaml,yml,json} from current directory.");
return reject("Could not find publication configuration.");
}
// Load the configuration file.
let config = loadConfig<PublicationData>(args.logger, files[0]);
args.rootDirectory = path.dirname(files[0]);
args.publication = config;
// Ensure every content has a process data which is used to pass values.
prepareContents(config.contents);
// Load the `package.json` file if possible.
let packageFilename = path.join(args.rootDirectory, "package.json");
if (fs.existsSync(packageFilename)) {
// Load the package file.
let packageBuffer = fs.readFileSync(packageFilename);
let packageMetadata = JSON.parse(packageBuffer.toString());
args.package = packageMetadata;
}
// Finish up processing this file.
resolve(args);
});
// Return the resulting promise chain.
return promise;
}
function chooseEditions(args: PublicationArgs): Promise<PublicationArgs> {
// Build up a list of each edition promise which handles processing of a
// single edition.
let promises = new Array<Promise<EditionArgs>>();
for (let editionName in args.publication.editions) {
// Pull out the edition and extend and merge it as needed.
let edition = args.publication.editions[editionName];
edition = extendObjects(edition, args.publication);
edition = mergeObjects(edition, args.publication.metadata);
edition = mergeObjects(edition, args.package);
edition = mergeObjects(edition, getDefaults());
// Create a promise to handle this one.
let editionArgs = new EditionArgs(args, editionName, edition);
promises.push(buildEdition(editionArgs));
}
// If we don't have any, we are going blow up.
if (!promises.length) {
args.logger.error("Cannot find at least one edition to build");
return Promise.reject("Cannot find edition");
}
// If we have at least one edition, we return an "all" promise.
return Promise.all(promises).then(() => args);
}
function buildEdition(args: EditionArgs): Promise<EditionArgs> {
// We need to resolve the output directory and filename.
let promise: Promise<EditionArgs> = Promise.resolve(args);
promise = promise.then(a => {
// Resolve the output directory.
let promise: Promise<any> = new Promise((resolve, reject) => {
let engine = new liquid.Engine();
resolve(engine.parse(args.edition.outputDirectory));
});
promise = promise.then(t => {
let parameters = {
edition: args.edition
};
return t.render(parameters);
});
promise = promise.then(directory => {
args.edition.outputDirectory = directory;
return args;
});
// Resolve the filename.
promise = promise.then(a => {
let engine = new liquid.Engine();
return engine.parse(args.edition.outputFilename);
});
promise = promise.then(t => {
let parameters = {
edition: args.edition
};
return t.render(parameters);
});
promise = promise.then(filename => {
args.edition.outputFilename = filename;
return args;
});
// Finish up with the proper output.
return promise.then(a => args);
});
// Format the edition.
promise = promise.then(a => {
// Pull out the edition data.
args.logger.debug("edition", args.name, args.edition);
// Load the formatter and theme.
args.format = <Formatter>loadModule(args, args.edition.format)();
args.theme = <Theme>loadModule(args, args.edition.theme)();
// Make sure the output directory exists. This creates a promise but
// we don't care about the output, only that it was created.
let promise = mkdirp(args.edition.outputDirectory);
// Build up a chain of promises to format the book. We let the format
// and theme initialize themselves (via `start`), process the content,
// then finalize the theme and the format.
//
// We have to pass the "a" so we can reference the class method.
promise = promise
.then(v => args)
.then(a => args.format.start(a))
.then(a => args.theme.start(a))
.then(a => appendContents(a))
.then(a => loadContents(a))
.then(a => renderContents(a))
.then(a => args.theme.finish(a))
.then(a => args.format.finish(a));
return promise;
});
return promise;
}
function prepareContents(contents: ContentData[], parent: ContentData = undefined) {
if (contents) {
for (let content of contents) {
content.parent = parent;
content.process = {};
prepareContents(content.contents, content);
}
}
}
function pause() {
let milliseconds = 1000;
var start = new Date().getTime();
for (var i = 0; i < 1e7; i++) {
if ((new Date().getTime() - start) > milliseconds) {
break;
}
}
}
function extendObjects(edition: EditionData, publication: PublicationData): EditionData {
// If we aren't extending anything, then we don't have to do anything.
if (!edition.extends)
return edition;
// We are extending it, so pull it in.
let mergeFrom = publication.editions[edition.extends];
return <EditionData>this.extend(edition, mergeFrom);
}
function mergeObjects(object1: any, object2: any): any {
var result = {};
if (object2) {
for (var name in object2) { result[name] = object2[name]; }
}
if (object1) {
for (var name in object1) { result[name] = object1[name]; }
}
return result;
}
function getDefaults() {
return {
lang: "en",
uid: uuid.v4()
};
}
import * as yargs from "yargs";
import { runBuild } from "./build";
let tracer = require("tracer");
// Set up TypeScript source map support.
require('source-map-support').install();
// Set up the build command.
var build_help = "Builds one or more editions of the book.";
function build_yargs(yargs) {
yargs
.help("help")
.demand(0)
.argv;
}
// Combine everything together to create a composite arguments which is used
// to parse the input and create the usage if required.
var argv = yargs
.usage("mfgames-writing-format command")
.help("help")
.showHelpOnFail(true, "Specify --help for available options")
.demand(1)
.command("build", build_help, build_yargs)
.argv;
// Set up logging based on the parameters.
let logger = tracer.colorConsole({
format: "{{timestamp}} {{message}}",
dateformat : "HH:MM:ss"
});
let json = require("../package.json");
logger.info(`Starting ${json.name}@${json.version}`);
// Grab the first elements in the argv, that will be the virtual command we are
// running. Once we have that, pass it into the appropriate function.
var command = argv._.splice(0, 1)[0];
logger.debug(`Running command: ${command}`);
switch (command) {
case "build":
runBuild(argv, json, logger);
break;
}
import * as path from "path";
import * as fs from "fs";
import * as yaml from "js-yaml";
export function loadConfig<T>(logger, filename: string): T
{
// We have the file, so attempt to load it into memory. This will either be
// a YAML or a JSON file.
let extension = path.extname(filename);
logger.debug("publicationFile", filename + ", ext", extension);
switch (extension) {
case ".yaml":
case ".yml":
return <T>loadYaml(filename);
case ".json":
return <T>loadJson(filename);
}
// If we fell through to this point, we don't know how to parse that file
// type.
logger.error(`Cannot parse ${extension} file types`);
return null;
}
function loadYaml(filename: string): any {
let text = loadText(filename);
let data = yaml.safeLoad(text);
return data;
}
function loadJson(filename: string): any {
return JSON.parse(loadText(filename));
}
function loadText(filename: string): string {
return fs.readFileSync(filename).toString();
}
/// <reference path="../node_modules/@types/node/index.d.ts"/>
import * as path from "path";
import * as fs from "fs";
import * as Promise from "bluebird";
import * as marked from "marked";
import { EditionArgs, ContentArgs, ContentData } from "mfgames-writing-contracts";
let incremental = require("incremental");
let yamlFrontMatter = require("yaml-front-matter");
let zpad = require("zpad");
let readFileAsync = Promise.promisify(fs.readFile);
/**
* Goes through the contents list of the publication and expands out any source
* patterns from the results, populating them into `args.contents`.
*/
export function appendContents(args: EditionArgs): Promise<EditionArgs> {
return appendContentsRecursion(args, args.publication.contents);
}
/**
* Recursively goes through the given contents and expands out each one into a
* flattened structure in the `args.contents`.
*/
function appendContentsRecursion(args: EditionArgs, contents: ContentData[]): Promise<EditionArgs> {
// If we don't have contents, then just return a simple resolve.
let promise = Promise.resolve(args);
if (contents) {
// Otherwise, go through the contents and build up some promises.
for (let content of contents) {
promise = promise.then(a => appendContent(a, content));
promise = promise.then(a => appendContentsRecursion(a, content.contents));
}
// Ensure we have an ID for everything we build up.
promise = promise.then(a => {
let index = 0;
for (let content of a.contents) {
if (!content.id) {
content.id = `c-${zpad(index++, 4)}-${content.element}`;
}
}
return a;
});
}
// Return the resulting promise.
return promise;
}
/**
* Appends the contents of a single item to `args.contents`.
*/
function appendContent(args: EditionArgs, content: ContentData): Promise<EditionArgs> {
return new Promise<EditionArgs>((resolve, reject) => {
// Figure out the directory we'll be processing for this.
let directory = content.directory
? path.join(args.rootDirectory, content.directory)
: args.rootDirectory;
// Figure out if we have a parent.
let parentArgs = content.parent && "ContentArgs" in content.parent.process
? content.parent.process["ContentArgs"]
: undefined;
// If we don't have a source, then we intend to generate the results.
let patternMatch = content.source
? content.source.match(/^\/(.+)\/$/)
: undefined;
if (patternMatch) {
// If this starts and stops with a "/", it is a regex. If we have that, then
// we scan the directory to get the files. Otherwise, we just use the source
// as a straight filename.
// Go through all the files in the directory.
let sourceRegex = new RegExp(patternMatch[1]);
let promise = Promise.resolve(args);
for (let file of fs.readdirSync(directory)) {
if (sourceRegex.test(file)) {
// Create a copy of the content with the pattern populated.
let filename = file;
let patternContent: ContentData = {
id: content.id,
directory: directory,
element: content.element,
number: content["number"],
source: filename,
liquid: content.liquid,
parent: content.parent,
process: {}
};
// Add the promise to handle this into the pipeline.
let contentArgs = new ContentArgs(args, patternContent);
contentArgs.parent = parentArgs;
args.contents.push(contentArgs);
// If we have a number, then increment it.
if (content.number) {
content.number = incremental(content.number);
}
}
}
}
else {
let contentArgs = new ContentArgs(args, content);
contentArgs.parent = parentArgs;
args.contents.push(contentArgs);
content.process["ContentArgs"] = contentArgs;
}
// Finish up the promise.
resolve(args);
});
}
/**
* Loads information about the contents including parsing the files into memory
* and loading metadata into the contents.
*/
export function loadContents(args: EditionArgs): Promise<EditionArgs> {
// Loading can be done in any order. While normally we don't want to load
// everything into memory, typically these books are small enough to fit
// in the space so we are going to just do it.
let promises: Promise<ContentArgs>[] = [];
for (let content of args.contents) {
promises.push(loadContent(content));
}
// Combine everything together at the end.
return Promise.all(promises).then(p => args);
}
/**
* Creates a promise that constructs a loading pipeline for the various content
* elements used for the edition.
*/
function loadContent(args: ContentArgs): Promise<ContentArgs> {
// Start with a basic promise, this makes it easier to build the chain.
let promise = Promise.resolve(args);
// If we don't have a source, then we have nothing to load.
if (!args.contentData.source) {
return promise
.then(loadContentGeneration);
}
// Figure out the directory and filename for this content.
let ext = args.extension;
// If this is an image, then just replace it with an HTML to include it.
if (ext === ".jpg" || ext === ".png") {