src/LinkTemplate.class.mjs

/**
 * @file Link generation templates.
 * @author Bart Busschots <opensource@bartificer.ie>
 * @license MIT
 */

/**
 * This module provides as class for representing the templates used to generate links from link data objects.
 * @module link-template
 * @requires module:urijs
 */

/**
 * A class representing the templates used to render link data objects as actual links.
 * @see {@link module:link-data.LinkData} for the data objects that are rendered with these templates.
 */
export class LinkTemplate{
    /**
     * @param {string} templateString - A Moustache template string.
     * @param {templateFieldFilterTuple[]} [filters=[]] - an optional set of filter functions to apply to some or all template fields.
     * @param {templateFieldExtractorFunction} [fieldExtractor] - an optional function to extract additional fields from a web page DOM object, making the extracted fields available for use in the template under the `extraFields` key.
     * @example <caption>Example of defining a template with filters</caption>
     * let template = new LinkTemplate(
     *     '<a href="{{{url}}}">{{{text}}}</a>',
     *     [
     *         ['url', linkify.util.stripUTMParameters],
     *         ['text', linkify.util.regulariseWhitespace]
     *     ]
     * );
     */
    constructor(templateString, filters, fieldExtractor){
        // TO DO - add validation
        
        /**
         * The Moustache template string.
         *
         * @private
         * @type {templateString}
         */
        this._templateString = '';
        this.templateString = templateString;
        
        /**
         * The filter functions to be applied to the various fields as a plain
         * object of arrays of {@filterFunction} callbacks indexed by:
         * * `all` - filters to be applied to all fields.
         * * `url` - filters to be applied to just the URL.
         * * `text` - filters to be applied just the link text.
         * * `description` - filters to be applied just the link description.
         *
         * @private
         * @type {Object.<"all"|"url"|"text"|"description", templateFieldFilterFunction>}
         */
        this._filters = {
            all: [],
            url: [],
            text: [],
            description: []
        };
        if(Array.isArray(filters)){
            for(let f of filters){
                if(Array.isArray(f)){
                    this.addFilter(...f);
                }
            }
        }

        /**
         * An optional function to extract additional fields from a web page DOM object, making the extracted fields available for use in the template under the `extraFields` key.
         * @private
         * @type {templateFieldExtractorFunction}
         */
        this._fieldExtractor = fieldExtractor;
    }
    
    /**
     * The Mustache template string. Will be coerced to a string with `String(templateString)`.
     * @type {string}
     */
    get templateString(){
        return this._templateString;
    }
    set templateString(templateString){
        this._templateString = String(templateString);
    }

    /**
     * Whether or not the template applies any field filters.
     * @type {boolean}
     */
    get hasFilters(){
        for(const fieldName of Object.keys(this._filters)){
            if(this._filters[fieldName].length) return true;
        }
        return false;
    }

    /**
     * The number of field filters the template applies.
     * @readonly
     * @type {number}
     */
    get numFilters(){
        let ans = 0;
        for(const filterList of Object.values(this._filters)){
            ans += filterList.length;
        }
        return ans;
    }

    /**
     * All field filters, indexed by the fileld they apply to.
     * @readonly
     * @type {Object<string, templateFieldFilterFunction[]>}
     */
    get filters(){
        const ans = {};
        for(const fieldName of Object.keys(this._filters).sort()){
            ans[fieldName] = [...this._filters[fieldName]];
        }
        return ans;
    }

    /**
     * All field filters the template applies as tuples, the first value being the field the filter applies to, the second the filter itself. 
     * @readonly
     * @type {templateFieldFilterTuple[]}
     */
    get filterTuples(){
        const ans = [];
        for(const fieldName of Object.keys(this._filters).sort()){
            for(const filter of this._filters[fieldName]){
                ans.push([fieldName, filter]);
            }
        }
        return ans;
    }
    
    /**
     * Add a filter to be applied to one or all fields.
     *
     * If an invalid args are passed, the function does not save the filter or
     * throw an error, but it does log a warning.
     *
     * @param {"all"|"url"|"text"|"description"} fieldName
     * @param {templateFieldFilterFunction} filterFn - the filter function.
     * @returns {module:link-template.LinkTemplate} Returns a reference to self to facilitate function chaining.
     */
    addFilter(fieldName, filterFn){
        // make sure that args are at least plausibly valid
        if(typeof fieldName !== 'string' || typeof filterFn !== 'function'){
            console.warn('silently ignoring request to add filter due to invalid args');
            return this;
        }
        
        // make sure the field name is valid
        if(!this._filters[fieldName]){
            console.warn(`silently ignoring request to add filter for unknown field (${fieldName})`);
            return this;
        }
        
        // add the filter
        this._filters[fieldName].push(filterFn);
        
        // return a reference to self
        return this;
    }
    
    /**
     * Get the filter functions that should be applied to any given field.
     * 
     * @param {"all"|"url"|"text"|"description"} fieldName
     * @returns {templateFieldFilterFunction[]} returns an array of callbacks, which may be
     * empty. An empty array is also returned if an invalid field name is passed.
     */
    filtersFor(fieldName){
        fieldName = String(fieldName);
        let ans = [];
        
        if(this._filters[fieldName]){
            if(fieldName !== 'all'){
                for(let f of this._filters.all){
                    ans.push(f);
                }
            }
            for(let f of this._filters[fieldName]){
                ans.push(f);
            }
        }
        return ans;
    }

    /**
     * The optional extra fields extractor function for this template. If present, this function will be called with the DOM data for a web page when the page data object is being extracted. These fields will be passed through to the link data object, and be avaialable for use within the template as `extraFields.fieldName`.
     * 
     * For this process to work, the field extractor function **must** return an object containing key-value pairs, where the keys are the field names to be used in the template, and the values are strings.
     * @type {?templateFieldExtractorFunction}
     * @throws {TypeError} if the value set is not a function or null.
     */
    get fieldExtractor(){
        return this._fieldExtractor;
    }
    set fieldExtractor(fieldExtractor){
        if(typeof fieldExtractor === 'function' || fieldExtractor === null){
            this._fieldExtractor = fieldExtractor;
        } else {
            throw new TypeError('fieldExtractor must be a function or null');
        }
    }

    /**
     * Whether or not this template supports extra fields.
     * @readonly
     * @type {boolean}
     */
    get hasExtraFields(){
        return typeof this._fieldExtractor === 'function';
    }
};