const _ = require('lodash'); const fs = require('fs'); const path = require('path'); const stringify = require('./stringify'); const Types = require('./types'); const DEFAULT_OPTIONS = { language: 'en', resources: { en: JSON.parse(fs.readFileSync(path.join(__dirname, '../res/en.json'), 'utf8')) } }; // order matters for these! const FUNCTION_DETAILS = ['new', 'this']; const FUNCTION_DETAILS_VARIABLES = ['functionNew', 'functionThis']; const MODIFIERS = ['optional', 'nullable', 'repeatable']; const TEMPLATE_VARIABLES = [ 'application', 'codeTagClose', 'codeTagOpen', 'element', 'field', 'functionNew', 'functionParams', 'functionReturns', 'functionThis', 'keyApplication', 'name', 'nullable', 'optional', 'param', 'prefix', 'repeatable', 'suffix', 'type' ]; const FORMATS = { EXTENDED: 'extended', SIMPLE: 'simple' }; function makeTagOpen(codeTag, codeClass) { let tagOpen = ''; const tags = codeTag ? codeTag.split(' ') : []; tags.forEach(tag => { const tagClass = codeClass ? ` class="${codeClass}"` : ''; tagOpen += `<${tag}${tagClass}>`; }); return tagOpen; } function makeTagClose(codeTag) { let tagClose = ''; const tags = codeTag ? codeTag.split(' ') : []; tags.reverse(); tags.forEach(tag => { tagClose += ``; }); return tagClose; } function reduceMultiple(context, keyName, contextName, translate, previous, current, index, items) { let key; switch (index) { case 0: key = '.first.many'; break; case (items.length - 1): key = '.last.many'; break; default: key = '.middle.many'; } key = keyName + key; context[contextName] = items[index]; return previous + translate(key, context); } function modifierKind(useLongFormat) { return useLongFormat ? FORMATS.EXTENDED : FORMATS.SIMPLE; } function buildModifierStrings(describer, modifiers, type, useLongFormat) { const result = {}; modifiers.forEach(modifier => { const key = modifierKind(useLongFormat); const modifierStrings = describer[modifier](type[modifier]); result[modifier] = modifierStrings[key]; }); return result; } function addModifiers(describer, context, result, type, useLongFormat) { const keyPrefix = `modifiers.${modifierKind(useLongFormat)}`; const modifiers = buildModifierStrings(describer, MODIFIERS, type, useLongFormat); MODIFIERS.forEach(modifier => { const modifierText = modifiers[modifier] || ''; result.modifiers[modifier] = modifierText; if (!useLongFormat) { context[modifier] = modifierText; } }); context.prefix = describer._translate(`${keyPrefix}.prefix`, context); context.suffix = describer._translate(`${keyPrefix}.suffix`, context); } function addFunctionModifiers(describer, context, {modifiers}, type, useLongFormat) { const functionDetails = buildModifierStrings(describer, FUNCTION_DETAILS, type, useLongFormat); FUNCTION_DETAILS.forEach((functionDetail, i) => { const functionExtraInfo = functionDetails[functionDetail] || ''; const functionDetailsVariable = FUNCTION_DETAILS_VARIABLES[i]; modifiers[functionDetailsVariable] = functionExtraInfo; if (!useLongFormat) { context[functionDetailsVariable] += functionExtraInfo; } }); } // Replace 2+ whitespace characters with a single whitespace character. function collapseSpaces(string) { return string.replace(/(\s)+/g, '$1'); } function getApplicationKey({expression}, applications) { if (applications.length === 1) { if (/[Aa]rray/.test(expression.name)) { return 'array'; } else { return 'other'; } } else if (/[Ss]tring/.test(applications[0].name)) { // object with string keys return 'object'; } else { // object with non-string keys return 'objectNonString'; } } class Result { constructor() { this.description = ''; this.modifiers = { functionNew: '', functionThis: '', optional: '', nullable: '', repeatable: '' }; this.returns = ''; } } class Context { constructor(props) { props = props || {}; TEMPLATE_VARIABLES.forEach(variable => { this[variable] = props[variable] || ''; }); } } class Describer { constructor(opts) { let options; this._useLongFormat = true; options = this._options = _.defaults(opts || {}, DEFAULT_OPTIONS); this._stringifyOptions = _.defaults(options, { _ignoreModifiers: true }); // use a dictionary, not a Context object, so we can more easily merge this into Context objects this._i18nContext = { codeTagClose: makeTagClose(options.codeTag), codeTagOpen: makeTagOpen(options.codeTag, options.codeClass) }; // templates start out as strings; we lazily replace them with template functions this._templates = options.resources[options.language]; if (!this._templates) { throw new Error(`I18N resources are not available for the language ${options.language}`); } } _stringify(type, typeString, useLongFormat) { const context = new Context({ type: typeString || stringify(type, this._stringifyOptions) }); const result = new Result(); addModifiers(this, context, result, type, useLongFormat); result.description = this._translate('type', context).trim(); return result; } _translate(key, context) { let result; let templateFunction = _.get(this._templates, key); context = context || new Context(); if (templateFunction === undefined) { throw new Error(`The template ${key} does not exist for the ` + `language ${this._options.language}`); } // compile and cache the template function if necessary if (typeof templateFunction === 'string') { // force the templates to use the `context` object templateFunction = templateFunction.replace(/<%= /g, '<%= context.'); templateFunction = _.template(templateFunction, {variable: 'context'}); _.set(this._templates, key, templateFunction); } result = (templateFunction(_.extend(context, this._i18nContext)) || '') // strip leading spaces .replace(/^\s+/, ''); result = collapseSpaces(result); return result; } _modifierHelper(key, modifierPrefix = '', context) { return { extended: key ? this._translate(`${modifierPrefix}.${FORMATS.EXTENDED}.${key}`, context) : '', simple: key ? this._translate(`${modifierPrefix}.${FORMATS.SIMPLE}.${key}`, context) : '' }; } _translateModifier(key, context) { return this._modifierHelper(key, 'modifiers', context); } _translateFunctionModifier(key, context) { return this._modifierHelper(key, 'function', context); } application(type, useLongFormat) { const applications = type.applications.slice(0); const context = new Context(); const key = `application.${getApplicationKey(type, applications)}`; const result = new Result(); addModifiers(this, context, result, type, useLongFormat); context.type = this.type(type.expression).description; context.application = this.type(applications.pop()).description; context.keyApplication = applications.length ? this.type(applications.pop()).description : ''; result.description = this._translate(key, context).trim(); return result; } elements(type, useLongFormat) { const context = new Context(); const items = type.elements.slice(0); const result = new Result(); addModifiers(this, context, result, type, useLongFormat); result.description = this._combineMultiple(items, context, 'union', 'element'); return result; } new(funcNew) { const context = new Context({'functionNew': this.type(funcNew).description}); const key = funcNew ? 'new' : ''; return this._translateFunctionModifier(key, context); } nullable(nullable) { let key; switch (nullable) { case true: key = 'nullable'; break; case false: key = 'nonNullable'; break; default: key = ''; } return this._translateModifier(key); } optional(optional) { const key = (optional === true) ? 'optional' : ''; return this._translateModifier(key); } repeatable(repeatable) { const key = (repeatable === true) ? 'repeatable' : ''; return this._translateModifier(key); } _combineMultiple(items, context, keyName, contextName) { const result = new Result(); const self = this; let strings; strings = typeof items[0] === 'string' ? items.slice(0) : items.map(item => self.type(item).description); switch (strings.length) { case 0: // falls through case 1: context[contextName] = strings[0] || ''; result.description = this._translate(`${keyName}.first.one`, context); break; case 2: strings.forEach((item, idx) => { const key = `${keyName + (idx === 0 ? '.first' : '.last' )}.two`; context[contextName] = item; result.description += self._translate(key, context); }); break; default: result.description = strings.reduce(reduceMultiple.bind(null, context, keyName, contextName, this._translate.bind(this)), ''); } return result.description.trim(); } /* eslint-enable no-unused-vars */ params(params, functionContext) { const context = new Context(); const result = new Result(); const self = this; let strings; // TODO: this hardcodes the order and placement of functionNew and functionThis; need to move // this to the template (and also track whether to put a comma after the last modifier) functionContext = functionContext || {}; params = params || []; strings = params.map(param => self.type(param).description); if (functionContext.functionThis) { strings.unshift(functionContext.functionThis); } if (functionContext.functionNew) { strings.unshift(functionContext.functionNew); } result.description = this._combineMultiple(strings, context, 'params', 'param'); return result; } this(funcThis) { const context = new Context({'functionThis': this.type(funcThis).description}); const key = funcThis ? 'this' : ''; return this._translateFunctionModifier(key, context); } type(type, useLongFormat) { let result = new Result(); if (useLongFormat === undefined) { useLongFormat = this._useLongFormat; } // ensure we don't use the long format for inner types this._useLongFormat = false; if (!type) { return result; } switch (type.type) { case Types.AllLiteral: result = this._stringify(type, this._translate('all'), useLongFormat); break; case Types.FunctionType: result = this._signature(type, useLongFormat); break; case Types.NameExpression: result = this._stringify(type, null, useLongFormat); break; case Types.NullLiteral: result = this._stringify(type, this._translate('null'), useLongFormat); break; case Types.RecordType: result = this._record(type, useLongFormat); break; case Types.TypeApplication: result = this.application(type, useLongFormat); break; case Types.TypeUnion: result = this.elements(type, useLongFormat); break; case Types.UndefinedLiteral: result = this._stringify(type, this._translate('undefined'), useLongFormat); break; case Types.UnknownLiteral: result = this._stringify(type, this._translate('unknown'), useLongFormat); break; default: throw new Error(`Unknown type: ${JSON.stringify(type)}`); } return result; } _record(type, useLongFormat) { const context = new Context(); let items; const result = new Result(); items = this._recordFields(type.fields); addModifiers(this, context, result, type, useLongFormat); result.description = this._combineMultiple(items, context, 'record', 'field'); return result; } _recordFields(fields) { const context = new Context(); let result = []; const self = this; if (!fields.length) { return result; } result = fields.map(field => { const key = `field.${field.value ? 'typed' : 'untyped'}`; context.name = self.type(field.key).description; if (field.value) { context.type = self.type(field.value).description; } return self._translate(key, context); }); return result; } _getHrefForString(nameString) { let href = ''; const links = this._options.links; if (!links) { return href; } // accept a map or an object if (links instanceof Map) { href = links.get(nameString); } else if ({}.hasOwnProperty.call(links, nameString)) { href = links[nameString]; } return href; } _addLinks(nameString) { const href = this._getHrefForString(nameString); let link = nameString; let linkClass = this._options.linkClass || ''; if (href) { if (linkClass) { linkClass = ` class="${linkClass}"`; } link = `${nameString}`; } return link; } result(type, useLongFormat) { const context = new Context(); const key = `function.${modifierKind(useLongFormat)}.returns`; const result = new Result(); context.type = this.type(type).description; addModifiers(this, context, result, type, useLongFormat); result.description = this._translate(key, context); return result; } _signature(type, useLongFormat) { const context = new Context(); const kind = modifierKind(useLongFormat); const result = new Result(); let returns; addModifiers(this, context, result, type, useLongFormat); addFunctionModifiers(this, context, result, type, useLongFormat); context.functionParams = this.params(type.params || [], context).description; if (type.result) { returns = this.result(type.result, useLongFormat); if (useLongFormat) { result.returns = returns.description; } else { context.functionReturns = returns.description; } } result.description += this._translate(`function.${kind}.signature`, context).trim(); return result; } } module.exports = (type, options) => { const simple = new Describer(options).type(type, false); const extended = new Describer(options).type(type); [simple, extended].forEach(result => { result.description = collapseSpaces(result.description.trim()); }); return { simple: simple.description, extended }; };