bin/cli.mjs

#!/usr/bin/env node

/**
 * @file The code for the command-line interface. This file does not contribute to the module's public API.
 * @author Bart Busschots <opensource@bartificer.ie>
 * @license MIT
 */

/**
 * A commandline inteface (CLI) published as `linkify`.
 * 
 * The CLI can be customised with a module that exports a configured `Linkifier` object as it's default export.
 * @private
 * @module linkify-cli
 * @requires module:clipboardy
 * @requires module:commander
 * @requires module:kleur
 * @requires module:urijs
 * @requires module:utilities
 * @requires module:linkifier-class.Linkifier
 */

//
// === Imports ===
//

// --- Static Imports ---

// import Commander.js (CLI framework)
import { Command } from 'commander';

// import the needed text formatting from kleur
import kleur from 'kleur';
const { bold, italic, blue, green, grey, red } = kleur;
import URI from 'urijs';

// import the clipboard support
import clipboard from 'clipboardy';

// import the required linkifier classes — resolves because the name here matches the package name in ../package.json
import { Linkifier, VERSION } from '@bartificer/linkify'
//import { Linkifier } from '../src/Linkifier.class.mjs' // for debugging
const utilities = Linkifier.utilities; // for conveniece

// --- Conditional Dynamic Imports ---

/**
 * Placeholder into which a config can be loaded.
 * @type {configurationObject}
 */
let CONFIG = null;

//
// === CLI Utility functions ===
//

/**
 * Merge CLI options into config options.
 * 
 * Note that the config options are read from the global variable.
 * @param {Object} cliOpts - the options passed to the CLI.
 * @returns {Object} the merged and rationalised options, with the CLI options taking precedence over the config options, and clipboard blocks over clipboard requests.
 */
function mergeOptions(cliOpts){
    // start with any options from a loaded config module (could be an empty object)
    const opts = {...CONFIG.options};
    utilities.debug(`Options loaded from config:\n${JSON.stringify(opts, null, 2)}`);

    // merge in the cli options
    utilities.debug(`CLI Options: (take precedence)\n${JSON.stringify(cliOpts, null, 2)}`);
    for(const [name, value] of Object.entries(cliOpts)){
        opts[name] = value;
    }

    // Rationalise Clipboard options — within actions, only check the explicit to and from options!
    // Apply two-level precdence logic:
    // 1. do not override precedence of CLI flags over config options
    // 2. clipboard blocks have a higher precedence than individual clipboard requests (--no-clipboard > --to-clipboard etc.)
    if(opts.hasOwnProperty('clipboard')){ // an explicit value was set
        if(opts.clipboard){ // a truthy value was set
            // a truthy value was set, propagate each interaction unless explicitly blocked
            if(opts.hasOwnProperty('fromClipboard') && !opts.fromClipboard){
                utilities.debug('options --clipboard & --no-from-clipboard both set, --no-from-clipboard not overriden');
            } else {
                opts.fromClipboard = true;
                utilities.debug('option --clipboard set, and no --no-from-clipboard, therefore set --from-clipboard');
            }
            if(opts.hasOwnProperty('toClipboard') && !opts.toClipboard){
                utilities.debug('options --clipboard & --no-to-clipboard both set, --no-to-clipboard not overriden');
            } else {
                opts.toClipboard = true;
                utilities.debug('option --clipboard set, and no --no-to-clipboard, therefore set --to-clipboard');
            }
        } else {
            // a falsy value was set — block all clipboard interaction
            opts.fromClipboard = false;
            opts.toClipboard = false;
            utilities.debug('option --no-clipboard set, therefore set --no-to-clipboard & --no-from-clipboard');
        }
    }

    // debug the final merged options
    utilities.debug(`Merged options:\n${JSON.stringify(opts, null, 2)}`);

    // return the merged options
    return opts;
}

/**
 * Resolve the URL passed to a cli command via an argument, the clipboard, or piped in.
 * 
 * The following algorithm is followed:
 * 1. Read from the clipboard if an appropriate option was passed
 * 2. read from the argument, if passed
 * 3. try read from the input pipe
 * 4. throw an error
 * @param {Object} opts - merged options
 * @param {string} urlArg - the URL argumemt received by the command
 * @returns {string} the URL
 */
async function resolveURL(opts, urlArg){
    // default to an empty URL
    let url = '';

    // apply the above precedence rules
    if(opts.clipboard || opts.fromClipboard){
        url = await clipboard.read();
        if(opts.echoClipboard){
            utilities.info(`URL read from clipboard:\n${grey(url)}`);
        }
    } else if(urlArg) {
        url = urlArg;
    } else if(!process.stdin.isTTY) { // only read from pipes, not from keyboards!
        // a helper function to read from STDIN — generated by Lumo AI, verified with NodeJS API docs
        const pipeReadHelper = function(){
            return new Promise((resolve, reject) => {
                let data = '';
                process.stdin.setEncoding('utf8');
                process.stdin.on('data', (chunk) => (data += chunk));
                process.stdin.on('end', () => resolve(data));
                process.stdin.on('error', reject);
            });
        }; 
        url = await pipeReadHelper();
    } else {
        utilities.fatal('no URL passed');
    }

    // validate the URL for cleaner error messages (rather than letting the module get cranky)
    if(!CONFIG.linkifier.util.isURL(url)){
        utilities.fatal(`invalid URL:\n${grey(url)}`);
    }

    // return the URL
    return url;
}

/**
 * Resolve the optional explicit template passed to a cli command.
 * @param {Object} opts - merged options.
 * @returns {?module:link-template.LinkTemplate}
 */
function resolveExplicitTemplate(opts){
    // assume no explicit template
    let tpl = null;

    // accept an optional template from the config or CLI
    if(opts.template){
        // make sure the template is registered
        if(CONFIG.linkifier.hasTemplate(opts.template)){
            tpl = opts.template;
        } else {
            utilities.fatal(`undefined template: ${grey(opts.template)}'\n${bold().grey('Available Templates:')} ${grey(JSON.stringify(CONFIG.linkifier.templateNames))}`);
        }
    } else {
        utilities.debug('no explicit template passed, the default will be used (potentially overriden on specific URL domains)');
    }

    // return the template
    return tpl;
}

//
// === Build the CLI ===
//

/**
 * The commander object representing the CLI itself.
 * @type {module:commander.Command}
 */
const cli = new Command()
    .name('linkify')
    .version(VERSION)
    .description('Convert URLs to rich links in any format based on the page contents.')
    .option('-C, --config <path>', `path to config file (default: ~/${Linkifier.defaults.configFilename})`)
    .option('-d, --debug', 'enable debug mode')
    .hook('preAction', (cmd) => { if(cmd.opts().debug) utilities.enableDebugMessages() });

/**
 * A re-usable hook for loading the config into the `CONFIG` variable.
 * @function
 * @param {module:commander.Command} cmd - the command loading the config.
 */
const loadConfigHook = async (cmd) => {
    // capture the passed config path, if any
    const configPath = cli.opts().config || '';

    // try import a config
    try {
        CONFIG = await Linkifier.importConfig(configPath);
    } catch (err) {
        utilities.fatal(err.message);
    }
};

/**
 * The commander object representing the sub-command for showing the defaults.
 * @type {module:commander.Command}
 */
const showDefaults = cli.command('show-defaults')
    .alias('defaults')
    .summary('show configuration defaults');
showDefaults.action(async () => {
    // the default templates
    console.log(bold().green('TEMPLATES'));
    console.log(bold().green('---------'));
    for(const [name, tpl] of Object.entries(Linkifier.defaults.linkTemplates)){
        console.log(`\n${bold().green(name)}`);

        // the template string
        console.log(`Mustache string:\n${grey(tpl.templateString)}`);

        // the filters
        if(tpl.hasFilters){
            console.log(`Filters: ${grey(tpl.numFilters)}`);
            for(const [fieldName, filter] of tpl.filterTuples){
                console.log(`${blue(fieldName)}: ${grey(filter.toString())}`);
            }
        } else {
            console.log(`Filters: ${italic().grey('none')}`);
        }

        // the extra field extractor
        if(tpl.hasExtraFields){
            console.log(`Extra Field Extractor: ${grey(tpl.fieldExtractor.toString())}`);
        } else {
            console.log(`Extra Fields: ${italic().grey('not supported')}`);
        }
    }

    // the default data transformer
    console.log(bold().green('\nDATA TRANSFORMER'));
    console.log(bold().green('----------------'));
    console.log(grey(Linkifier.defaults.dataTransformer.toString()));

    // the default small words list
    console.log(bold().green('\nSMALL WORDS'));
    console.log(bold().green('-----------'));
    console.log(italic().grey('Used for the title-case conversion when reversing URL slugs'));
    console.log(italic().grey(`The default short word list from the title-case NPM module with ${green('additions')}`));
    let smallWords = new Set([...Linkifier.defaults.importedSmallWords, ...Linkifier.defaults.extraSmallWords]);
    let smallWordsList = "";
    const extraSmallWords = new Set(Linkifier.defaults.extraSmallWords);
    [...smallWords].sort().forEach((smallWord, i, list) => {
        if(i > 0){
            smallWordsList += i === list.length -1 ? ' & ' : ', ';
        }
        smallWordsList += `'${extraSmallWords.has(smallWord) ? green(smallWord) : smallWord}'`;
    });
    console.log(smallWordsList);

    // the default specially capitalised words list
    console.log(bold().green('\nSPECIALLY CAPITALISED WORDS'));
    console.log(bold().green('---------------------------'));
    console.log(italic().grey('Used for the title-case conversion when reversing URL slugs'));
    let specialWordsList = "";
    Linkifier.defaults.speciallyCapitalisedWords.sort().forEach((word, i, list) => {
        if(i > 0){
            specialWordsList += i === list.length -1 ? ' & ' : ', ';
        }
        specialWordsList += `'${word}'`;
    });
    console.log(specialWordsList);
});

/**
 * The commander object representing the sub-command for showing the loaded config.
 * @type {module:commander.Command}
 */
const showConfig = cli.command('show-config')
    .alias('config')
    .summary('show active configuration');
showConfig.hook('preAction', loadConfigHook); // load the config for this command
showConfig.action(async () => {
    // the loaded templates
    console.log(bold().green('AVAILABLE TEMPLATES'));
    console.log(bold().green('-----------------'));
    for(const name of CONFIG.linkifier.templateNames.sort()){
        const tpl = CONFIG.linkifier.getTemplate(name);
        console.log(`\n${bold().green(name)}`);

        // the template string
        console.log(`Mustache string:\n${grey(tpl.templateString)}`);

        // the filters
        if(tpl.hasFilters){
            console.log(`Filters: ${grey(tpl.numFilters)}`);
            for(const [fieldName, filter] of tpl.filterTuples){
                console.log(`${blue(fieldName)}: ${grey(filter.toString())}`);
            }
        } else {
            console.log(`Filters: ${italic().grey('none')}`);
        }

        // the extra field extractor
        if(tpl.hasExtraFields){
            console.log(`Extra Field Extractor: ${grey(tpl.fieldExtractor.toString())}`);
        } else {
            console.log(`Extra Fields: ${italic().grey('not supported')}`);
        }
    }

    // the template mappings
    console.log(bold().green('\nDOMAIN → DEFAULT THEME MAPPINGS'));
    console.log(bold().green('-------------------------------'));
    const tplMappings = CONFIG.linkifier.domainToDefaultTemplateNameMappings;
    for(const domain of Object.keys(tplMappings).sort()){
        console.log(`${domain == '.' ? bold().blue('DEFAULT') : blue(domain)} → ${tplMappings[domain]}`);
    }

    // the data transformer mappings
    console.log(bold().green('\nDOMAIN → DATATRANSFORMER MAPPINGS'));
    console.log(bold().green('---------------------------------'));
    const transformerMappings = CONFIG.linkifier.domainToTransformerMappings;
    for(const domain of Object.keys(transformerMappings).sort()){
        console.log(`${domain == '.' ? bold().blue('DEFAULT') : blue(domain)} → ${transformerMappings[domain].toString()}`);
    }

    // the current small words list
    console.log(bold().green('\nSMALL WORDS'));
    console.log(bold().green('-----------'));
    console.log(italic().grey('Used for the title-case conversion when reversing URL slugs'));
    let smallWordsList = "";
    [...CONFIG.linkifier.smallWords].sort().forEach((smallWord, i, list) => {
        if(i > 0){
            smallWordsList += i === list.length -1 ? ' & ' : ', ';
        }
        smallWordsList += `'${smallWord}'`;
    });
    console.log(smallWordsList);

    // the current specially capitalised words list
    console.log(bold().green('\nSPECIALLY CAPITALISED WORDS'));
    console.log(bold().green('---------------------------'));
    console.log(italic().grey('Used for the title-case conversion when reversing URL slugs'));
    let specialWordsList = "";
    [...CONFIG.linkifier.speciallyCapitalisedWords].sort().forEach((word, i, list) => {
        if(i > 0){
            specialWordsList += i === list.length -1 ? ' & ' : ', ';
        }
        specialWordsList += `'${word}'`;
    });
    console.log(specialWordsList);
});

/**
 * The commander object representing the page data previewing sub-command.
 * @type {module:commander.Command}
 */
const pageDataPreview = cli.command('preview-page-data')
    .alias('page-data')
    .summary('preview the page data extracted from a URL')
    .option('-c, --clipboard', 'read the URL from the clipboard')
    .option('--no-clipboard', 'block all clipboard interaction')
    .option('--from-clipboard', 'read the URL from the clipboard, added for consistency with generate action')
    .option('--no-from-clipboard', 'block all reading from the clipboard, added for consistency with generate action')
    .option('-e, --echo-clipboard', 'echo information about clipboard interactions to STDOUT')
    .option('-t --template <name>', 'the name of a template that defines a extra field extractor to see extra fields')
    .argument('<url>', 'the URL to preview the page data from, can also be read from the clipboard with the appropriate flags, or piped to to the command');
pageDataPreview.hook('preAction', loadConfigHook); // load the config for this command
pageDataPreview.action(async (u, o) => {
    //
    // --- gather the needed information ---
    //

    // merge and resolve options
    const opts = mergeOptions(o); 

    // resolve the URL
    const url = await resolveURL(opts, u);

    //
    // --- resolve the template ---
    //

    // resolve the explicit template (if any)
    let template = resolveExplicitTemplate(opts); // could be null

    // if not explicit template was found, get the default for the URL's domain
    if(!template){
        const uri = new URI(url);
        template = CONFIG.linkifier.getTemplateForDomain(uri.hostname());
    }

    //
    // --- fetch and output the page data ---
    //

    // try fetch the page data
    const pageData = await CONFIG.linkifier.fetchPageData(url, template.fieldExtractor);

    // render the page data
    console.log(JSON.stringify(pageData.asPlainObject(), null, 2));
});

/**
 * The commander object representing the link generation sub-command.
 * @type {module:commander.Command}
 */
const generate = cli.command('generate-link')
    .alias('generate')
    .summary('generate a link from a URL')
    .option('--from-clipboard', 'read the URL from the clipboard')
    .option('--no-from-clipboard', 'block all reading from the clipboard')
    .option('--to-clipboard', 'write the link to the clipboard')
    .option('--no-to-clipboard', 'block writting to the clipboard')
    .option('-c, --clipboard', 'read the URL from the clipboard and write the link to the clipboard')
    .option('--no-clipboard', 'block all clipboard interaction')
    .option('-e, --echo-clipboard', 'echo information about clipboard interactions to STDOUT')
    .option('-t --template <name>', 'the name of the template to use to render the link')
    .argument('[url]', 'the URL to generate a link for, can also be read from the clipboard with the appropriate flags, or piped to to the command');
generate.hook('preAction', loadConfigHook); // load the config for this command
generate.action(async (u, o) => {
    //
    // --- gather the needed information ---
    //

    // merge and resolve options
    const opts = mergeOptions(o); 

    // resolve the URL
    const url = await resolveURL(opts, u);

    // resolve the explicit template (if any)
    const tpl = resolveExplicitTemplate(opts); // could be null

    //
    // --- generate and output the link ---
    //

    // generate the link
    const link = await CONFIG.linkifier.generateLink(url, tpl);

    // output the link
    if(opts.clipboard || opts.toClipboard){
        await clipboard.write(link);
        if(opts.echoClipboard){
            utilities.info(`link written to clipboard:\n${grey(link)}`);
        }
    } else {
        console.log(link);
    }
});

// execute the CLI
try {
    cli.parse(process.argv);
} catch (err) {
    utilities.fatal(err.message);
}