diff --git a/index.js b/index.js index 939c621..dc5cfd9 100644 --- a/index.js +++ b/index.js @@ -1,51 +1,44 @@ const { loadFileStructureFromOptions } = require('./lib/helpers'); +const { validate, retrieveFileOptions } = require('./lib/options'); const SvelteVersionDetector = require('./lib/detector'); -const DEFAULT_ENCODING = 'utf8'; -const DEFAULT_IGNORED_VISIBILITIES = ['protected', 'private']; +/** + * @typedef {import("./typings").SvelteParserOptions} SvelteParserOptions + */ -function validateOptions(options) { - if (!options || (!options.filename && !options.fileContent)) { - throw new Error('One of options.filename or options.filecontent is required'); - } -} - -function normalizeOptions(options) { - options.encoding = options.encoding || DEFAULT_ENCODING; - options.ignoredVisibilities = options.ignoredVisibilities || DEFAULT_IGNORED_VISIBILITIES; -} - -function buildSvelte2Parser(structure, options) { +function buildSvelte2Parser(options) { const Parser = require('./lib/parser'); // Convert structure object to old version source options - const hasScript = structure.scripts && structure.scripts.length > 0; - const hasStyle = structure.styles && structure.styles.length > 0; + const { scripts, styles, template } = options.structure; + + const hasScript = !!scripts && scripts.length > 0; + const hasStyle = !!styles && styles.length > 0; options.source = { - template: structure.template, - script: hasScript ? structure.scripts[0].content : '', - scriptOffset: hasScript ? structure.scripts[0].offset : 0, - style: hasStyle ? structure.styles[0].content : '', - styleOffset: hasStyle ? structure.styles[0].offset : 0, + template: template, + script: hasScript ? scripts[0].content : '', + scriptOffset: hasScript ? scripts[0].offset : 0, + style: hasStyle ? styles[0].content : '', + styleOffset: hasStyle ? styles[0].offset : 0, }; return new Parser(options); } -function buildSvelte3Parser(structure, options) { +function buildSvelte3Parser(options) { const Parser = require('./lib/v3/parser'); - return new Parser(structure, options); + return new Parser(options); } -function buildSvelteParser(structure, options, version) { +function buildSvelteParser(options, version) { if (version === SvelteVersionDetector.SVELTE_VERSION_3) { - return buildSvelte3Parser(structure, options); + return buildSvelte3Parser(options); } if (version === SvelteVersionDetector.SVELTE_VERSION_2) { - return buildSvelte2Parser(structure, options); + return buildSvelte2Parser(options); } if (version) { @@ -116,7 +109,7 @@ function mergeItems(itemType, currentItem, newItem, ignoreLocations) { return currentItem; } -function subscribeOnParserEvents(parser, options, version, resolve, reject) { +function subscribeOnParserEvents(parser, ignoredVisibilities, version, resolve, reject) { const component = { version: version }; @@ -166,7 +159,7 @@ function subscribeOnParserEvents(parser, options, version, resolve, reject) { parser.features.forEach((feature) => { if (component[feature] instanceof Array) { component[feature] = component[feature].filter((item) => { - return !options.ignoredVisibilities.includes(item.visibility); + return !ignoredVisibilities.includes(item.visibility); }); } }); @@ -179,18 +172,34 @@ function subscribeOnParserEvents(parser, options, version, resolve, reject) { }); } +/** + * Main parse function. + * @param {SvelteParserOptions} options + * @example + * const { parse } = require('sveltedoc-parser'); + * // basic usage only requires 'filename' to be set. + * const doc = await parse({ + * filename: 'main.svelte', + * encoding: 'ascii', + * features: ['data', 'computed', 'methods'], + * ignoredVisibilities: ['private'], + * includeSourceLocations: true, + * version: 3 + * }); + */ module.exports.parse = (options) => new Promise((resolve, reject) => { try { - validateOptions(options); - normalizeOptions(options); + validate(options); + + const fileOptions = retrieveFileOptions(options); - const structure = loadFileStructureFromOptions(options); + options.structure = loadFileStructureFromOptions(fileOptions); - const version = options.version || SvelteVersionDetector.detectVersionFromStructure(structure, options.defaultVersion); + const version = options.version || SvelteVersionDetector.detectVersionFromStructure(options.structure, options.defaultVersion); - const parser = buildSvelteParser(structure, options, version); + const parser = buildSvelteParser(options, version); - subscribeOnParserEvents(parser, options, version, resolve, reject); + subscribeOnParserEvents(parser, options.ignoredVisibilities, version, resolve, reject); parser.walk(); } catch (error) { @@ -198,8 +207,11 @@ module.exports.parse = (options) => new Promise((resolve, reject) => { } }); +/** + * @param {SvelteParserOptions} options + */ module.exports.detectVersion = (options) => { - validateOptions(options); + validate(options); return SvelteVersionDetector.detectVersionFromOptions(options); }; diff --git a/lib/helpers.js b/lib/helpers.js index 63546e5..2362d87 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,6 +1,21 @@ const fs = require('fs'); const path = require('path'); +/** + * @typedef {import('../typings').SvelteParserOptions} SvelteParserOptions + * @typedef {'fileContent' | 'filename' | 'encoding'} FileOptionKeys + * @typedef {{content?: string; template?: string, scripts?: HtmlBlock[], styles?: HtmlBlock[] }} FileStructure + * @typedef {Pick & { structure?: FileStructure }} FileOptions + */ + +/** + * @typedef {{ offset: number, outerPosition: { start: number, end: number }, content: string, attributes: string }} HtmlBlock + */ + +/** + * + * @param {FileOptions} options + */ function loadFileStructureFromOptions(options) { if (options.structure) { return options.structure; @@ -28,9 +43,17 @@ function loadFileStructureFromOptions(options) { } } - throw new Error('Can not load source from options, because nothing is not provided'); + // With options validation, this should never happen + throw new Error('Internal Error: Cannot load FileStructure because input is missing.'); } +/** + * + * @param {string} content + * @param {string} blockName + * @param {number} searchStartIndex + * @returns {{ block: HtmlBlock | null }} + */ function extractHtmlBlock(content, blockName, searchStartIndex) { const blockOuterStartIndex = content.indexOf(`<${blockName}`, searchStartIndex); @@ -65,7 +88,14 @@ function extractHtmlBlock(content, blockName, searchStartIndex) { }; } +/** + * + * @param {string} content + * @param {string} blockName + * @returns {{ blocks: HtmlBlock[] }} + */ function extractAllHtmlBlocks(content, blockName) { + /** @type {HtmlBlock[]} */ const blocks = []; let searchResult = extractHtmlBlock(content, blockName); @@ -83,6 +113,11 @@ function extractAllHtmlBlocks(content, blockName) { }; } +/** + * + * @param {string} fileContent + * @returns {FileStructure} + */ function parseFileStructureFromContent(fileContent) { const scriptBlocksSearchResult = extractAllHtmlBlocks(fileContent, 'script'); const styleBlocksSearchResult = extractAllHtmlBlocks(fileContent, 'style'); diff --git a/lib/options.js b/lib/options.js new file mode 100644 index 0000000..dbbe408 --- /dev/null +++ b/lib/options.js @@ -0,0 +1,151 @@ +const { isString, printArray, isVisibilitySupported, VISIBILITIES } = require('./utils'); + +/** + * @typedef {import("../typings").SvelteParserOptions} SvelteParserOptions + */ + +/** @type {BufferEncoding[]} */ +const ENCODINGS = [ + 'ascii', + 'utf8', + 'utf-8', + 'utf16le', + 'ucs2', + 'ucs-2', + 'base64', + 'latin1', + 'binary', + 'hex' +]; + +const ERROR_ENCODING_FORMAT = 'Expected options.encoding to be a string. '; +const ERROR_VISIBILITIES_FORMAT = 'Expected options.ignoredVisibilities to be an array of strings. '; +const INFO_ENCODING_SUPPORTED = `Supported encodings: ${printArray(ENCODINGS)}.`; +const INFO_VISIBILITIES_SUPPORTED = `Supported visibilities: ${printArray(VISIBILITIES)}.`; + +function getUnsupportedEncodingString(enc) { + return `encoding ${printArray([enc])} not supported. ` + + INFO_ENCODING_SUPPORTED; +} + +function getUnsupportedVisibilitiesString(arr) { + return `Visibilities [${printArray(arr)}] in ` + + 'options.ignoredVisibilities are not supported. ' + + INFO_VISIBILITIES_SUPPORTED; +} + +const ErrorMessage = Object.freeze({ + OptionsRequired: 'An options object is required.', + InputRequired: 'One of options.filename or options.fileContent is required.', + EncodingMissing: 'Internal Error: options.encoding is not set.', + EncodingFormat: ERROR_ENCODING_FORMAT + INFO_ENCODING_SUPPORTED, + EncodingNotSupported: (enc) => getUnsupportedEncodingString(enc), + IgnoredVisibilitiesMissing: 'Internal Error: options.ignoredVisibilities is not set.', + IgnoredVisibilitiesFormat: ERROR_VISIBILITIES_FORMAT + INFO_VISIBILITIES_SUPPORTED, + IgnoredVisibilitiesNotSupported: (arr) => getUnsupportedVisibilitiesString(arr), +}); + +/** @type {BufferEncoding} */ +const DEFAULT_ENCODING = 'utf8'; + +/** @type {SymbolVisibility[]} */ +const DEFAULT_IGNORED_VISIBILITIES = ['protected', 'private']; + +/** @returns {SvelteParserOptions} */ +function getDefaultOptions() { + return { + encoding: DEFAULT_ENCODING, + ignoredVisibilities: [...DEFAULT_IGNORED_VISIBILITIES], + }; +} + +function retrieveFileOptions(options) { + return { + structure: options.structure, + fileContent: options.fileContent, + filename: options.filename, + encoding: options.encoding, + }; +} + +/** + * Applies default values to options. + * @param {SvelteParserOptions} options + */ +function normalize(options) { + const defaults = getDefaultOptions(); + + Object.keys(defaults).forEach((optionKey) => { + /** + * If the key was not set by the user, apply default value. + * This is better than checking for falsy values because it catches + * use cases were a user tried to do something not intended with + * an option (e.g. putting a value of 'false' or an empty string) + */ + if (!(optionKey in options)) { + options[optionKey] = defaults[optionKey]; + } + }); +} + +/** + * @param {SvelteParserOptions} options + * @throws an error if any options are invalid + */ +function validate(options) { + if (!options) { + throw new Error(ErrorMessage.OptionsRequired); + } + + normalize(options); + + const hasFilename = + ('filename' in options) && + isString(options.filename) && + options.filename.length > 0; + + // Don't check length for fileContent because it could be an empty file. + const hasFileContent = + ('fileContent' in options) && + isString(options.fileContent); + + if (!hasFilename && !hasFileContent) { + throw new Error(ErrorMessage.InputRequired); + } + + if ('encoding' in options) { + if (!isString(options.encoding)) { + throw new Error(ErrorMessage.EncodingFormat); + } + + if (!ENCODINGS.includes(options.encoding)) { + throw new Error(ErrorMessage.EncodingNotSupported(options.encoding)); + } + } else { + // Sanity check. At this point, 'encoding' should be set. + throw new Error(ErrorMessage.EncodingMissing); + } + + if ('ignoredVisibilities' in options) { + if (!Array.isArray(options.ignoredVisibilities)) { + throw new Error(ErrorMessage.IgnoredVisibilitiesFormat); + } + + if (!options.ignoredVisibilities.every(isVisibilitySupported)) { + const notSupported = options.ignoredVisibilities.filter( + (iv) => !isVisibilitySupported(iv) + ); + + throw new Error(ErrorMessage.IgnoredVisibilitiesNotSupported(notSupported)); + } + } else { + // Sanity check. At this point, 'ignoredVisibilities' should be set. + throw new Error(ErrorMessage.IgnoredVisibilitiesMissing); + } +} + +module.exports = { + ErrorMessage: ErrorMessage, + validate: validate, + retrieveFileOptions: retrieveFileOptions, +}; diff --git a/lib/parser.js b/lib/parser.js index 2dd24a4..6137d8d 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -6,7 +6,7 @@ const path = require('path'); const utils = require('./utils'); const jsdoc = require('./jsdoc'); -const hasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); +const hasOwnProperty = utils.hasOwnProperty; const DEFAULT_OPTIONS = { /** diff --git a/lib/utils.js b/lib/utils.js index ebf2912..59035f3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,10 +1,17 @@ -const RE_VISIBILITY = /(public|protected|private)/; +/** + * @typedef {import('../typings').JSVisibilityScope} JSVisibilityScope + * @type {Readonly} + */ +const VISIBILITIES = Object.freeze(['public', 'protected', 'private']); +const RE_VISIBILITY = new RegExp(`^(${VISIBILITIES.join('|')})$`); const RE_KEYWORDS = /@\**\s*([a-z0-9_-]+)(\s+(-\s+)?([\wÀ-ÿ\s*{}[\]()='"`_^$#&²~|\\£¤€%µ,?;.:/!§<>+¨-]+))?/ig; const DEFAULT_VISIBILITY = 'public'; +const isVisibilitySupported = (v) => RE_VISIBILITY.test(v); + const getVisibility = (keywords, defaultVisibility) => { - const keyword = keywords.find((keyword) => RE_VISIBILITY.test(keyword.name)); + const keyword = keywords.find((keyword) => isVisibilitySupported(keyword.name)); if (keyword) { return keyword.name; @@ -284,7 +291,15 @@ const isTopLevelComment = (comment) => { return comment.keywords.some((keyword) => keyword.name === 'component'); }; +const isString = (x) => typeof x === 'string' || x instanceof String; + +const printArray = (array = []) => array.map(s => `'${s}'`).join(', '); + +const hasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); + +module.exports.VISIBILITIES = VISIBILITIES; module.exports.DEFAULT_VISIBILITY = DEFAULT_VISIBILITY; +module.exports.isVisibilitySupported = isVisibilitySupported; module.exports.getVisibility = getVisibility; module.exports.parseComment = parseComment; module.exports.getCommentFromSourceCode = getCommentFromSourceCode; @@ -300,3 +315,6 @@ module.exports.escapeImportKeyword = escapeImportKeyword; module.exports.inferTypeFromVariableDeclaration = inferTypeFromVariableDeclaration; module.exports.isTopLevelComment = isTopLevelComment; module.exports.buildCamelCase = buildCamelCase; +module.exports.isString = isString; +module.exports.printArray = printArray; +module.exports.hasOwnProperty = hasOwnProperty; diff --git a/lib/v3/parser.js b/lib/v3/parser.js index 16aca41..7f423b7 100644 --- a/lib/v3/parser.js +++ b/lib/v3/parser.js @@ -40,12 +40,12 @@ const RETURN_ALIASES = { }; class Parser extends EventEmitter { - constructor(structure, options) { + constructor(options) { super(); this._options = Object.assign({}, DEFAULT_OPTIONS, options); - this.structure = structure; + this.structure = options.structure; this.features = options.features || SUPPORTED_FEATURES; this.includeSourceLocations = options.includeSourceLocations; this.componentName = null; diff --git a/test/integration/parse/basicV2.svelte b/test/integration/parse/basicV2.svelte new file mode 100644 index 0000000..215ded0 --- /dev/null +++ b/test/integration/parse/basicV2.svelte @@ -0,0 +1,3 @@ + diff --git a/test/integration/parse/basicV3.svelte b/test/integration/parse/basicV3.svelte new file mode 100644 index 0000000..7534c7d --- /dev/null +++ b/test/integration/parse/basicV3.svelte @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/test/integration/parse/parse.spec.js b/test/integration/parse/parse.spec.js new file mode 100644 index 0000000..46e67c2 --- /dev/null +++ b/test/integration/parse/parse.spec.js @@ -0,0 +1,35 @@ +const path = require('path'); +const chai = require('chai'); +const expect = chai.expect; + +const parser = require('./../../../index'); + +describe('parse - Integration', () => { + it('should correctly auto-detect svelte V2 component', (done) => { + parser.parse({ + filename: path.resolve(__dirname, 'basicV2.svelte'), + }).then((doc) => { + expect(doc, 'Document should exist').to.exist; + // v3-parser converts component name to kebab-case + expect(doc.name).to.equal('basic-v2'); + + done(); + }).catch(e => { + done(e); + }); + }); + + it('should correctly auto-detect svelte V3 component', (done) => { + parser.parse({ + filename: path.resolve(__dirname, 'basicV3.svelte'), + }).then((doc) => { + expect(doc, 'Document should be provided').to.exist; + // v3-parser converts component name to PascalCase + expect(doc.name).to.equal('BasicV3'); + + done(); + }).catch(e => { + done(e); + }); + }); +}); diff --git a/test/unit/options/options.spec.js b/test/unit/options/options.spec.js new file mode 100644 index 0000000..76d2f0a --- /dev/null +++ b/test/unit/options/options.spec.js @@ -0,0 +1,98 @@ +const expect = require('chai').expect; + +const { + validate, + retrieveFileOptions, + ErrorMessage +} = require('../../../lib/options'); + +const baseOptions = { filename: 'empty.svelte' }; + +describe('Options Module', () => { + describe('options.validate', () => { + describe('Should throw when', () => { + it('options object is missing', () => { + expect(() => validate()).to.throw(ErrorMessage.OptionsRequired); + }); + + it('input is missing, not a string, or an empty filename', () => { + expect(() => validate({})).to.throw(ErrorMessage.InputRequired); + expect(() => validate({ filename: {} })).to.throw(ErrorMessage.InputRequired); + expect(() => validate({ filename: '' })).to.throw(ErrorMessage.InputRequired); + expect(() => validate({ fileContent: {} })).to.throw(ErrorMessage.InputRequired); + }); + + it('encoding is not a string', () => { + const options = { ...baseOptions, encoding: true }; + + expect(() => validate(options)).to.throw(ErrorMessage.EncodingFormat); + }); + + it('encoding is not supported', () => { + const unsupported = 'l33t-enc'; + const options = { ...baseOptions, encoding: unsupported }; + + expect(() => validate(options)).to.throw( + ErrorMessage.EncodingNotSupported(unsupported) + ); + }); + + it('ignoreVisibilities is not an array', () => { + const unsupported = 'unsupported'; + const mixed = ['private', unsupported]; + const options = { ...baseOptions, ignoredVisibilities: mixed }; + + expect(() => validate(options)).to.throw( + ErrorMessage.IgnoredVisibilitiesNotSupported([unsupported]) + ); + }); + + it('ignoreVisibilities contains at least one unsupported visibility', () => { + const unsupported = 'unsupported'; + const mixed = ['private', unsupported]; + const options = { ...baseOptions, ignoredVisibilities: mixed }; + + expect(() => validate(options)).to.throw( + ErrorMessage.IgnoredVisibilitiesNotSupported([unsupported]) + ); + }); + }); + + describe('Should pass when', () => { + it('just filename is present', () => { + expect(() => validate(baseOptions)).to.not.throw(); + }); + + it('just fileContent is present', () => { + expect(() => validate({ fileContent: 'content' })).to.not.throw(); + }); + + it('fileContent is empty', () => { + expect(() => validate({ fileContent: '' })).to.not.throw(); + }); + + it('ignoreVisibilities is an empty array', () => { + const options = { ...baseOptions, ignoredVisibilities: [] }; + + expect(() => validate(options)).to.not.throw(); + }); + + it('ignoreVisibilities is an array of supported visibilities', () => { + const options = { + ...baseOptions, + ignoredVisibilities: ['protected', 'public'] + }; + + expect(() => validate(options)).to.not.throw(); + }); + }); + }); + + describe('options.retrieveFileOptions', () => { + it('Should return all file-related keys from options', () => { + expect(retrieveFileOptions(baseOptions)).to.have.keys( + 'filename', 'fileContent', 'structure', 'encoding' + ); + }); + }); +}); diff --git a/typings.d.ts b/typings.d.ts index 15ddad6..81861a2 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -85,7 +85,7 @@ export interface ISvelteItem { /** * The description of the item, provided from related comment. */ - description?: string|null; + description?: string | null; /** * The visibility of item. */ @@ -177,7 +177,7 @@ export interface SvelteComputedItem extends ISvelteItem { /** * The list of data or computed properties names, marked as depended to this property. */ - dependencies: string[] + dependencies: string[] } export interface SvelteMethodParamItem { @@ -266,7 +266,7 @@ export interface SvelteEventItem extends ISvelteItem { /** * The name of HTML element if propagated standart JS Dom event or null. */ - parent?: string|null; + parent?: string | null; /** * The list of event modificators. @@ -296,7 +296,7 @@ export interface SvelteRefItem extends ISvelteItem { /** * The name of HTML element or component that binded with this ref name. */ - parent?: string|null; + parent?: string | null; } /** @@ -306,7 +306,7 @@ export interface SvelteComponentDoc { /** * The name of the parsed component. */ - name?: string|null; + name?: string | null; /** * The Svelte compiler version that used for this document. */ @@ -314,7 +314,7 @@ export interface SvelteComponentDoc { /** * The component description. */ - description?: string|null; + description?: string | null; /** * The list of defined model properties. @@ -324,7 +324,7 @@ export interface SvelteComponentDoc { * The list of defined computed properties of component. */ computed?: SvelteComputedItem[]; - + /** * The list of included components. */ @@ -365,4 +365,119 @@ export interface SvelteComponentDoc { * @since {2.1.0} */ dispatchers?: SvelteMethodItem[]; -} \ No newline at end of file +} + +/** + * Features supported by the Svelte 2 parser. + */ +export enum Svelte2Feature { + name = 'name', + data = 'data', + computed = 'computed', + methods = 'methods', + actions = 'actions', + helpers = 'helpers', + components = 'components', + description = 'description', + keywords = 'keywords', + events = 'events', + slots = 'slots', + transitions = 'transitions', + refs = 'refs', + store = 'store', +} + +/** + * Features supported by the Svelte 3 parser. + */ +export enum Svelte3Feature { + name = 'name', + data = 'data', + computed = 'computed', + methods = 'methods', + components = 'components', + description = 'description', + keywords = 'keywords', + events = 'events', + slots = 'slots', + refs = 'refs', +}; + +type Svelte2FeatureKeys = keyof typeof Svelte2Feature; +type Svelte3FeatureKeys = keyof typeof Svelte3Feature; +type SvelteFeatureKeys = Svelte2FeatureKeys | Svelte3FeatureKeys; +type Svelte2ExclusiveFeature = Exclude; +type Svelte3ExclusiveFeature = Exclude; + +/** + * Supported Svelte versions. + */ +export type SvelteVersion = 2 | 3; + +interface BaseParserOptions { + /** + * The filename to parse. Required unless fileContent is passed. + */ + filename?: string; + + /** + * The file content to parse. Required unless filename is passed. + */ + fileContent?: string; + + /** + * @default 'utf8' + */ + encoding?: BufferEncoding; + + /** + * The component features to parse and extract. + * Uses all supported features by default. + * @see Svelte2Feature + * @see Svelte3Feature + */ + features?: F[]; + + /** + * The list of ignored visibilities. Use an empty array to export all + * visibilities. + * @default ['private','protected'] + */ + ignoredVisibilities?: JSVisibilityScope[]; + + /** + * Indicates that source locations should be provided for component symbols. + * @default false + */ + includeSourceLocations?: boolean + + /** + * Optional. Use 2 or 3 to specify which svelte syntax should be used. + * When version is not provided, the parser tries to detect which version + * of the syntax to use. + */ + version?: V; + + /** + * Optional. Specify which version of svelte syntax to fallback to if the + * parser can't identify the version used. + */ + defaultVersion?: V; +} + +/** + * Options to pass to the main parse function. + * + * @example + * const options = { + * filename: 'main.svelte', + * encoding: 'ascii', + * features: ['data', 'computed', 'methods'], + * ignoredVisibilities: ['private'], + * includeSourceLocations: true, + * version: 3 + * }; + */ +export type SvelteParserOptions = + | BaseParserOptions<3, Svelte3FeatureKeys> + | BaseParserOptions<2, Svelte2FeatureKeys>;