diff --git a/lib/utils.js b/lib/utils.js
index c6e251a..e32abdf 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -69,7 +69,8 @@ const parseComment = (text, defaultVisibility = DEFAULT_VISIBILITY) => {
/**
* @param {Node} node
* @param {SourceCode} sourceCode
- * @param {*} options
+ * @param {{ defaultVisibility: string, useFirst: boolean, useLeading: boolean, useTrailing: boolean }} options
+ * @return {import('../typings').IScopedCommentItem}
*/
const getCommentFromSourceCode = (node, sourceCode, {
defaultVisibility = DEFAULT_VISIBILITY,
@@ -481,6 +482,9 @@ const inferTypeFromVariableDeclaration = (variable) => {
}
};
+/**
+ * @param {import('../typings').IScopedCommentItem} comment
+ */
const isTopLevelComment = (comment) => {
return comment.keywords.some((keyword) => keyword.name === 'component');
};
diff --git a/lib/v3/events.js b/lib/v3/events.js
new file mode 100644
index 0000000..bc1cca1
--- /dev/null
+++ b/lib/v3/events.js
@@ -0,0 +1,53 @@
+const CommonEvent = Object.freeze({
+ /**
+ * Emit the @see {SvelteDataItem} object.
+ */
+ DATA: 'data',
+ /**
+ * Emit the @see {SvelteEventItem} object.
+ */
+ EVENT: 'event',
+ /**
+ * Emit the global comment @see {IScopedCommentItem} object.
+ */
+ GLOBAL_COMMENT: 'global-comment',
+});
+
+const TemplateEvent = Object.freeze({
+ ...CommonEvent,
+ NAME: 'name',
+ REF: 'ref',
+ SLOT: 'slot',
+ EXPRESSION: 'expression',
+});
+
+const ScriptEvent = Object.freeze({
+ ...CommonEvent,
+ METHOD: 'method',
+ COMPUTED: 'computed',
+ IMPORTED_COMPONENT: 'imported-component',
+});
+
+const ParserEvent = Object.freeze({
+ NAME: 'name',
+ DESCRIPTION: 'description',
+ KEYWORDS: 'keywords',
+
+ DATA: 'data',
+ EVENT: 'event',
+ REF: 'ref',
+ SLOT: 'slot',
+ METHOD: 'method',
+ COMPUTED: 'computed',
+ IMPORTED_COMPONENT: 'component',
+
+ FAILURE: 'failure',
+ END: 'end',
+});
+
+module.exports = {
+ CommonEvent,
+ TemplateEvent,
+ ScriptEvent,
+ ParserEvent
+};
diff --git a/lib/v3/parser.js b/lib/v3/parser.js
index 962aa37..b881dae 100644
--- a/lib/v3/parser.js
+++ b/lib/v3/parser.js
@@ -1,20 +1,21 @@
const EventEmitter = require('events');
const path = require('path');
-const espree = require('espree');
-const eslint = require('eslint');
-const HtmlParser = require('htmlparser2-svelte').Parser;
-
const utils = require('./../utils');
-const jsdoc = require('./../jsdoc');
const {
normalize: normalizeOptions,
validateFeatures,
- getAstDefaultOptions
} = require('../options');
-const hasOwnProperty = utils.hasOwnProperty;
+const TemplateParser = require('./template');
+const { TemplateEvent, ScriptEvent, ParserEvent } = require('./events');
+
+const ScriptParser = require('./script');
+/**
+ * @typedef {import('../../typings').Svelte3Feature} Svelte3Feature
+ * @type {Svelte3Feature[]}
+*/
const SUPPORTED_FEATURES = [
'name',
'data',
@@ -27,21 +28,11 @@ const SUPPORTED_FEATURES = [
'slots',
'refs'
];
-const SCOPE_DEFAULT = 'default';
-const SCOPE_STATIC = 'static';
-const SCOPE_MARKUP = 'markup';
-
-const PARAM_ALIASES = {
- arg: true,
- argument: true,
- param: true
-};
-const RETURN_ALIASES = {
- return: true,
- returns: true,
-};
class Parser extends EventEmitter {
+ /**
+ * @param {import('../options').SvelteParserOptions} options
+ */
constructor(options) {
super();
@@ -49,17 +40,16 @@ class Parser extends EventEmitter {
// External options
this.filename = options.filename;
- this.structure = options.structure;
this.features = options.features;
this.includeSourceLocations = options.includeSourceLocations;
+ /** @type {import("../helpers").FileStructure} */
+ this.structure = options.structure;
// Internal properties
this.componentName = null;
- this.eventsEmitted = {};
- this.identifiers = {};
- this.imports = {};
- this.dispatcherConstructorNames = [];
- this.dispatcherNames = [];
+
+ this.scriptParser = null;
+ this.templateParser = null;
}
walk() {
@@ -67,7 +57,7 @@ class Parser extends EventEmitter {
try {
this.__walk();
} catch (error) {
- this.emit('failure', error);
+ this.emit(ParserEvent.FAILURE, error);
}
});
@@ -79,17 +69,15 @@ class Parser extends EventEmitter {
this.parseComponentName();
}
- if (this.structure.scripts) {
- this.structure.scripts.forEach(scriptBlock => {
- this.parseScriptBlock(scriptBlock);
- });
+ if (this.structure.scripts && this.structure.scripts.length) {
+ this.parseScriptBlocks(this.structure.scripts);
}
if (this.structure.template) {
- this.parseTemplate();
+ this.parseTemplate(this.structure.template);
}
- this.emit('end');
+ this.emit(ParserEvent.END);
}
static getDefaultOptions() {
@@ -119,845 +107,128 @@ class Parser extends EventEmitter {
}
if (this.componentName) {
- this.emit('name', utils.buildCamelCase(this.componentName));
+ this.emit(ParserEvent.NAME, utils.buildCamelCase(this.componentName));
}
}
- updateType(item) {
- const typeKeyword = item.keywords.find(kw => kw.name === 'type');
-
- if (typeKeyword) {
- const parsedType = jsdoc.parseTypeKeyword(typeKeyword.description);
+ /**
+ * @param {import('../../typings').IScopedCommentItem} comment
+ */
+ emitGlobalComment(comment) {
+ if (comment && utils.isTopLevelComment(comment)) {
+ if (this.features.includes('description')) {
+ this.emit(ParserEvent.DESCRIPTION, comment.description);
+ }
- if (parsedType) {
- item.type = parsedType;
+ if (this.features.includes('keywords')) {
+ this.emit(ParserEvent.KEYWORDS, comment.keywords);
}
}
}
- emitDataItem(variable, parseContext, defaultVisibility, parentComment) {
- const comment = parentComment || utils.getCommentFromSourceCode(variable.node, parseContext.sourceCode, { defaultVisibility });
-
- const item = Object.assign({}, comment, {
- name: variable.name,
- kind: variable.kind,
- static: parseContext.scopeType === SCOPE_STATIC,
- readonly: variable.kind === 'const',
- type: utils.inferTypeFromVariableDeclaration(variable),
- importPath: variable.importPath,
- originalName: variable.originalName,
- localName: variable.localName
- });
-
- if (variable.declarator && variable.declarator.init) {
- item.defaultValue = variable.declarator.init.value;
- }
-
- if (this.includeSourceLocations && variable.location) {
- item.locations = [{
- start: variable.location.start + parseContext.offset,
- end: variable.location.end + parseContext.offset,
- }];
- }
+ parseScriptBlocks(scripts) {
+ const scriptParser = this.buildScriptParser();
- this.updateType(item);
+ scriptParser.parse(scripts);
- this.emit('data', item);
+ return scriptParser;
}
- emitMethodItem(method, parseContext, defaultVisibility, parentComment) {
- const comment = parentComment || utils.getCommentFromSourceCode(method.node, parseContext.sourceCode, { defaultVisibility });
-
- this.parseKeywords(comment.keywords, method);
+ parseTemplateJavascriptExpression(expression) {
+ const scriptParser = this.buildScriptParser();
- const item = Object.assign({}, comment, {
- name: method.name,
- params: method.params,
- return: method.return,
- static: parseContext.scopeType === SCOPE_STATIC
- });
-
- if (this.includeSourceLocations && method.location) {
- item.locations = [{
- start: method.location.start + parseContext.offset,
- end: method.location.end + parseContext.offset
- }];
- }
+ scriptParser.parseScriptExpression(expression);
- this.emit('method', item);
+ return scriptParser;
}
- emitComputedItem(computed, parseContext, defaultVisibility) {
- const item = Object.assign({}, utils.getCommentFromSourceCode(computed.node, parseContext.sourceCode, { defaultVisibility }), {
- name: computed.name,
- static: parseContext.scopeType === SCOPE_STATIC,
- type: jsdoc.DEFAULT_TYPE
- });
-
- if (this.includeSourceLocations && computed.location) {
- item.locations = [{
- start: computed.location.start + parseContext.offset,
- end: computed.location.end + parseContext.offset
- }];
- }
+ parseTemplate(template) {
+ const templateParser = this.buildTemplateParser();
- this.updateType(item);
+ templateParser.parse(template);
- this.emit('computed', item);
+ return templateParser;
}
- emitEventItem(event, parseContext) {
- const item = Object.assign({}, utils.getCommentFromSourceCode(event.node, parseContext.sourceCode, { defaultVisibility: 'public' }), {
- name: event.name
- });
-
- if (this.includeSourceLocations && event.location) {
- item.locations = [{
- start: event.location.start + parseContext.offset,
- end: event.location.end + parseContext.offset
- }];
+ buildScriptParser() {
+ if (this.scriptParser) {
+ return this.scriptParser;
}
- this.emit('event', item);
- }
-
- emitRefItem(ref) {
- const item = Object.assign({}, ref, {
- visibility: 'private'
+ this.scriptParser = new ScriptParser({
+ features: this.features,
+ includeSourceLocations: this.includeSourceLocations
});
- this.emit('ref', item);
- }
+ this.subscribeToScriptParser(this.scriptParser);
- emitImportedComponentItem(component, parseContext) {
- const item = Object.assign({}, utils.getCommentFromSourceCode(component.node, parseContext.sourceCode, { defaultVisibility: 'private' }), {
- name: component.name,
- importPath: component.path,
- });
-
- if (this.includeSourceLocations && component.location) {
- item.locations = [{
- start: component.location.start + parseContext.offset,
- end: component.location.end + parseContext.offset
- }];
- }
-
- this.emit('component', item);
+ return this.scriptParser;
}
- emitGlobalComment(comment) {
- if (comment && utils.isTopLevelComment(comment)) {
- if (this.features.includes('description')) {
- this.emit('description', comment.description);
- }
-
- this.emit('keywords', comment.keywords);
+ buildTemplateParser() {
+ if (this.templateParser) {
+ return this.templateParser;
}
- }
-
- parseBodyRecursively(rootNode, parseContext, level) {
- const nodes = rootNode.body
- ? rootNode.body
- : (rootNode.length > 0 ? rootNode : [rootNode]);
-
- nodes.forEach((node, index) => {
- if (index === 0 && level === 0) {
- const firstComment = utils.getCommentFromSourceCode(node, parseContext.sourceCode, { useTrailing: false, useFirst: true });
-
- this.emitGlobalComment(firstComment);
- }
-
- if (node.type === 'BlockStatement') {
- this.parseBodyRecursively(node.body, parseContext, level);
-
- return;
- }
-
- if (node.type === 'ExpressionStatement') {
- const expressionNode = node.expression;
-
- if (expressionNode.type === 'CallExpression') {
- const callee = expressionNode.callee;
-
- if (expressionNode.arguments) {
- this.parseBodyRecursively(expressionNode.arguments, parseContext, level + 1);
- }
-
- if (callee.type === 'Identifier' && this.dispatcherNames.indexOf(callee.name) >= 0) {
- const eventItem = this.parseEventDeclaration(expressionNode);
-
- this.emitEventItem(eventItem, parseContext);
-
- return;
- }
- }
- if (expressionNode.type === 'ArrowFunctionExpression') {
- if (expressionNode.body) {
- this.parseBodyRecursively(expressionNode.body, parseContext, level + 1);
-
- return;
- }
- }
- }
-
- if (node.type === 'CallExpression') {
- const callee = node.callee;
-
- if (node.arguments) {
- this.parseBodyRecursively(node.arguments, parseContext, level + 1);
- }
-
- if (callee.type === 'Identifier' && this.dispatcherNames.includes(callee.name)) {
- const eventItem = this.parseEventDeclaration(node);
-
- this.emitEventItem(eventItem, parseContext);
-
- return;
- }
- }
-
- if (node.type === 'VariableDeclaration' && parseContext.scopeType !== SCOPE_MARKUP) {
- const variables = this.parseVariableDeclaration(node);
-
- variables.forEach(variable => {
- if (level === 0) {
- this.emitDataItem(variable, parseContext, 'private');
- }
-
- if (variable.declarator.init) {
- const idNode = variable.declarator.id;
- const initNode = variable.declarator.init;
-
- // Store top level variables in 'identifiers'
- if (level === 0 && idNode.type === 'Identifier') {
- this.identifiers[idNode.name] = variable.declarator.init;
- }
-
- if (initNode.type === 'CallExpression') {
- const callee = initNode.callee;
-
- if (initNode.arguments) {
- this.parseBodyRecursively(initNode.arguments, parseContext, level + 1);
- }
-
- if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) {
- this.dispatcherNames.push(variable.name);
- }
- } else if (initNode.type === 'ArrowFunctionExpression') {
- if (initNode.body) {
- this.parseBodyRecursively(initNode.body, parseContext, level + 1);
- }
- }
- }
- });
-
- return;
- }
-
- if (node.type === 'FunctionDeclaration') {
- this.emitMethodItem(this.parseFunctionDeclaration(node), parseContext, 'private');
-
- if (node.body) {
- this.parseBodyRecursively(node.body, parseContext, level + 1);
- }
-
- return;
- }
-
- if (node.type === 'ExportNamedDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
- const declaration = node.declaration;
- const specifiers = node.specifiers;
-
- if (declaration) {
- const exportNodeComment = utils.getCommentFromSourceCode(node, parseContext.sourceCode, { defaultVisibility: 'public', useLeading: true, useTrailing: false });
-
- if (declaration.type === 'VariableDeclaration') {
- const variables = this.parseVariableDeclaration(declaration);
-
- variables.forEach(variable => {
- this.emitDataItem(variable, parseContext, 'public', exportNodeComment);
-
- if (variable.declarator.init) {
- const initNode = variable.declarator.init;
-
- if (initNode.type === 'CallExpression') {
- const callee = initNode.callee;
-
- if (initNode.arguments) {
- this.parseBodyRecursively(initNode.arguments, parseContext, level + 1);
- }
-
- if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) {
- this.dispatcherNames.push(variable.name);
- }
- } else if (initNode.type === 'ArrowFunctionExpression') {
- if (initNode.body) {
- this.parseBodyRecursively(initNode.body, parseContext, level + 1);
- }
- }
- }
- });
-
- return;
- }
-
- if (declaration.type === 'FunctionDeclaration') {
- const func = this.parseFunctionDeclaration(declaration);
-
- this.emitMethodItem(func, parseContext, 'public', exportNodeComment);
-
- if (declaration.body) {
- this.parseBodyRecursively(declaration.body, parseContext, level + 1);
- }
-
- return;
- }
- }
-
- if (specifiers) {
- specifiers.forEach(specifier => {
- if (specifier.type === 'ExportSpecifier') {
- const exportedOrLocalName = specifier.exported
- ? specifier.exported.name
- : specifier.local.name;
-
- this.emitDataItem({
- node: specifier,
- name: exportedOrLocalName,
- localName: specifier.local.name,
- kind: 'const',
- location: {
- start: specifier.exported ? specifier.exported.start : specifier.local.start,
- end: specifier.exported ? specifier.exported.end : specifier.local.end
- }
- }, parseContext, 'public');
- }
- });
- }
- }
-
- if (node.type === 'LabeledStatement' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
- const idNode = node.label;
-
- if (idNode && idNode.type === 'Identifier' && idNode.name === '$') {
- if (node.body && node.body.type === 'ExpressionStatement') {
- const expression = node.body.expression;
-
- if (expression && expression.type === 'AssignmentExpression') {
- const leftNode = expression.left;
-
- if (leftNode.type === 'Identifier') {
- this.emitComputedItem({
- name: leftNode.name,
- location: {
- start: leftNode.start,
- end: leftNode.end
- },
- node: node
- }, parseContext, 'private');
-
- return;
- }
- }
- }
- }
- }
-
- if (node.type === 'ImportDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
- const specifier = node.specifiers[0];
- const source = node.source;
-
- if (source && source.type === 'Literal') {
- const sourceFileName = source.value;
-
- if (specifier && specifier.type === 'ImportDefaultSpecifier') {
- const importEntry = {
- identifier: specifier.local.name,
- sourceFilename: sourceFileName
- };
-
- if (!hasOwnProperty(this.imports, importEntry.identifier)) {
- this.imports[importEntry.identifier] = importEntry;
-
- if (importEntry.identifier) {
- if (importEntry.identifier[0] === importEntry.identifier[0].toUpperCase()) {
- const component = {
- node: node,
- name: importEntry.identifier,
- path: importEntry.sourceFilename,
- location: {
- start: specifier.local.start,
- end: specifier.local.end
- }
- };
-
- this.emitImportedComponentItem(component, parseContext);
-
- return;
- } else {
- const imported = specifier.imported
- ? specifier.imported.name
- : undefined;
-
- this.emitDataItem({
- node,
- name: importEntry.identifier,
- originalName: imported || importEntry.identifier,
- importPath: importEntry.sourceFilename,
- kind: 'const',
- location: {
- start: specifier.local.start,
- end: specifier.local.end
- }
- }, parseContext, 'private');
- }
- }
- }
- } else if (node.specifiers.length > 0) {
- node.specifiers.forEach((specifier) => {
- if (specifier.type === 'ImportSpecifier') {
- this.emitDataItem({
- node: specifier,
- name: specifier.local.name,
- originalName: specifier.imported
- ? specifier.imported.name
- : specifier.local.name,
- importPath: sourceFileName,
- kind: 'const',
- location: {
- start: specifier.local.start,
- end: specifier.local.end
- }
- }, parseContext, 'private');
- }
- });
- }
-
- // Import svelte API functions
- if (sourceFileName === 'svelte') {
- // Dispatcher constructors
- node.specifiers
- .filter(specifier => specifier.imported.name === 'createEventDispatcher')
- .forEach(specifier => {
- this.dispatcherConstructorNames.push(specifier.local.name);
- });
- }
- }
- }
-
- if (node.body) {
- this.parseBodyRecursively(node.body, parseContext, level + 1);
- }
+ this.templateParser = new TemplateParser({
+ features: this.features,
+ includeSourceLocations: this.includeSourceLocations
});
- }
-
- parseScriptBlock(scriptBlock) {
- const ast = espree.parse(
- scriptBlock.content,
- getAstDefaultOptions()
- );
-
- const sourceCode = new eslint.SourceCode({
- text: scriptBlock.content,
- ast: ast
- });
-
- const isStaticScope = /\sscope=('module'|"module")/gi.test(scriptBlock.attributes);
- const scriptParseContext = {
- scopeType: isStaticScope
- ? SCOPE_STATIC
- : SCOPE_DEFAULT,
- offset: scriptBlock.offset,
- sourceCode: sourceCode
- };
+ this.subscribeToTemplateParser(this.templateParser);
- this.parseBodyRecursively(ast, scriptParseContext, 0);
+ return this.templateParser;
}
- parseMarkupExpressionBlock(expression, offset) {
- // Add name for anonymous functions to prevent parser error
- const regex = /^{?\s*function\s+\(/i;
-
- expression = expression.replace(regex, function (m) {
- // When quotes in attributes used curcly braces are provided here, so we should handle it separatly
- if (m.startsWith('{')) {
- return '{function a(';
- }
-
- return 'function a(';
+ subscribeToScriptParser(scriptParser) {
+ scriptParser.on(ScriptEvent.COMPUTED, item => {
+ this.emit(ParserEvent.COMPUTED, item);
});
-
- const ast = espree.parse(
- expression,
- getAstDefaultOptions()
- );
-
- const sourceCode = new eslint.SourceCode({
- text: expression,
- ast: ast
+ scriptParser.on(ScriptEvent.DATA, item => {
+ this.emit(ParserEvent.DATA, item);
});
-
- const scriptParseContext = {
- scope: SCOPE_MARKUP,
- offset: offset,
- sourceCode: sourceCode
- };
-
- this.parseBodyRecursively(ast, scriptParseContext, 0);
- }
-
- parseEventDeclaration(node) {
- if (node.type !== 'CallExpression') {
- throw new Error('Node should have a CallExpressionType, but is ' + node.type);
- }
-
- const args = node.arguments;
-
- if (!args || !args.length) {
- return null;
- }
-
- const nameNode = args[0];
-
- let name;
-
- try {
- const chain = utils.buildPropertyAccessorChainFromAst(nameNode);
-
- // This function can throw if chain is not valid
- name = utils.getValueForPropertyAccessorChain(this.identifiers, chain);
- } catch (error) {
- name = nameNode.type === 'Literal'
- ? nameNode.value
- : undefined;
- }
-
- return {
- name: name,
- node: node,
- location: {
- start: nameNode.start,
- end: nameNode.end
- }
- };
- }
-
- parseVariableDeclaration(node) {
- if (node.type !== 'VariableDeclaration') {
- throw new Error('Node should have a VariableDeclarationType, but is ' + node.type);
- }
-
- const result = [];
-
- node.declarations.forEach(declarator => {
- const idNode = declarator.id;
-
- if (idNode.type === 'Identifier') {
- result.push({
- name: idNode.name,
- kind: node.kind,
- node: node,
- declarator: declarator,
- location: {
- start: idNode.start,
- end: idNode.end
- }
- });
- } else if (idNode.type === 'ObjectPattern') {
- idNode.properties.forEach(propertyNode => {
- const propertyIdNode = propertyNode.key;
-
- if (propertyIdNode.type === 'Identifier') {
- result.push({
- name: propertyIdNode.name,
- kind: node.kind,
- node: node,
- declarator: declarator,
- locations: {
- start: propertyIdNode.start,
- end: propertyIdNode.end
- }
- });
- }
- });
- }
+ scriptParser.on(ScriptEvent.EVENT, item => {
+ this.emit(ParserEvent.EVENT, item);
});
-
- return result;
- }
-
- parseFunctionDeclaration(node) {
- if (node.type !== 'FunctionDeclaration') {
- throw new Error('Node should have a FunctionDeclarationType, but is ' + node.type);
- }
-
- const params = [];
-
- node.params.forEach((param) => {
- if (param.type === 'Identifier') {
- params.push({
- name: param.name,
- });
- }
+ scriptParser.on(ScriptEvent.IMPORTED_COMPONENT, item => {
+ this.emit(ParserEvent.IMPORTED_COMPONENT, item);
});
-
- const output = {
- node: node,
- name: node.id.name,
- location: {
- start: node.id.start,
- end: node.id.end
- },
- params: params,
- };
-
- return output;
- }
-
- parseObjectProperty(node) {
- if (node.type !== 'Property') {
- throw new Error('Node should have a Property, but is ' + node.type);
- }
-
- if (node.key && node.key.type !== 'Identifier') {
- throw new Error('Wrong property declaration');
- }
-
- return {
- name: node.key.name
- };
- }
-
- parseTemplate() {
- let rootElementIndex = 0;
- let lastComment = null;
- let lastAttributeIndex = 0;
- let lastAttributeLocations = {};
- let lastTagName = null;
-
- const parser = new HtmlParser({
- oncomment: (data) => {
- lastComment = data.trim();
- },
- ontext: (text) => {
- if (text.trim()) {
- lastComment = null;
- }
- },
- onattribute: (name, value) => {
- if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
- lastAttributeLocations[name] = {
- start: lastAttributeIndex,
- end: parser._tokenizer._index
- };
-
- lastAttributeIndex = parser._tokenizer._index;
- }
-
- if (this.features.includes('events')) {
- if (lastTagName !== 'slot') {
- // Expose events that propogated from child events
- // Handle event syntax like ``````
- if (name.length > 3 && name.indexOf('on:') === 0 && !value) {
- const nameWithModificators = name.substr(3).split('|');
-
- const baseEvent = {
- name: nameWithModificators[0],
- parent: lastTagName,
- modificators: nameWithModificators.slice(1),
- locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
- ? [lastAttributeLocations[name]]
- : null
- };
-
- if (lastComment) {
- lastComment = `/** ${lastComment} */`;
- }
-
- const comment = utils.parseComment(lastComment || '');
-
- baseEvent.visibility = comment.visibility;
- baseEvent.description = comment.description || '';
- baseEvent.keywords = comment.keywords;
-
- if (!hasOwnProperty(this.eventsEmitted, baseEvent.name)) {
- this.eventsEmitted[baseEvent.name] = baseEvent;
-
- this.parseKeywords(comment.keywords, baseEvent);
-
- this.emit('event', baseEvent);
- }
-
- lastComment = null;
- }
-
- // Parse event handlers
- if (name.length > 3 && name.indexOf('on:') === 0 && value) {
- this.parseMarkupExpressionBlock(value);
- }
- }
- }
- },
- onopentagname: (tagName) => {
- lastTagName = tagName;
- lastAttributeIndex = parser._tokenizer._index;
- lastAttributeLocations = {};
- },
- onopentag: (tagName, attrs) => {
- const isNotStyleOrScript = !['style', 'script'].includes(tagName);
- const isTopLevelElement = parser._stack.length === 1;
-
- if (isTopLevelElement && isNotStyleOrScript) {
- if (lastComment && rootElementIndex === 0) {
- this.emitGlobalComment(utils.parseComment(lastComment));
- }
-
- rootElementIndex += 1;
- }
-
- if (tagName === 'slot') {
- if (this.features.includes('slots')) {
- const exposedParameters = Object.keys(attrs)
- .filter(name => name.length > 0 && name !== 'name')
- .map(name => ({
- name: name,
- visibility: 'public'
- }));
-
- const slot = {
- name: attrs.name || 'default',
- description: lastComment,
- visibility: 'public',
- parameters: exposedParameters
- };
-
- if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
- slot.loc = {
- start: parser.startIndex,
- end: parser.endIndex
- };
- }
-
- this.emit('slot', slot);
- }
- } else {
- if (tagName === 'svelte:options' && attrs.tag) {
- if (this.features.includes('name')) {
- this.emit('name', attrs.tag);
- }
- }
-
- if (this.features.includes('data')) {
- const bindProperties = Object.keys(attrs)
- .filter(name => name.length > 5 && name.indexOf('bind:') === 0)
- .filter(name => name !== 'bind:this')
- .map(name => {
- const sourcePropertyName = name.substr(5);
-
- let targetPropertyName = sourcePropertyName;
-
- const attributeValue = attrs[name];
-
- if (attributeValue && attributeValue.length > 0) {
- targetPropertyName = attributeValue;
- }
-
- return {
- sourcePropertyName: sourcePropertyName,
- targetPropertyName: targetPropertyName,
- parent: tagName,
- locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
- ? [lastAttributeLocations[name]]
- : null
- };
- });
-
- bindProperties.forEach(bindProperty => {
- const dataItem = {
- name: bindProperty.targetPropertyName,
- kind: undefined,
- bind: [{
- source: bindProperty.parent,
- property: bindProperty.sourcePropertyName
- }],
- locations: bindProperty.locations,
- visibility: 'private',
- static: false,
- readonly: false
- };
-
- this.emit('data', dataItem);
- });
- }
-
- if (this.features.includes('refs')) {
- if (hasOwnProperty(attrs, 'bind:this') && attrs['bind:this']) {
- const bindedVariableName = attrs['bind:this'];
-
- this.emitRefItem({
- name: bindedVariableName,
- parent: tagName,
- locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, 'bind:this')
- ? [lastAttributeLocations['bind:this']]
- : null
- });
- }
- }
- }
- }
- }, {
- lowerCaseTags: false,
- lowerCaseAttributeNames: false,
- curlyBracesInAttributes: true
+ scriptParser.on(ScriptEvent.METHOD, item => {
+ this.emit(ParserEvent.METHOD, item);
});
- parser.write(this.structure.template);
- parser.end();
+ // Special cases where more parsing is required
+ scriptParser.on(ScriptEvent.GLOBAL_COMMENT, (comment) => {
+ this.emitGlobalComment(comment);
+ });
}
- /**
- * Mutates event.
- * @param {any[]} keywords
- * @param {{ params?: any[] }} event
- */
- parseKeywords(keywords = [], event) {
- if (!event.params) {
- event.params = [];
- }
+ subscribeToTemplateParser(templateParser) {
+ // Forward emit of basic template events
+ templateParser.on(TemplateEvent.DATA, dataItem => {
+ this.emit(ParserEvent.DATA, dataItem);
+ });
+ templateParser.on(TemplateEvent.EVENT, event => {
+ this.emit(ParserEvent.EVENT, event);
+ });
+ templateParser.on(TemplateEvent.NAME, name => {
+ this.emit(ParserEvent.NAME, name);
+ });
+ templateParser.on(TemplateEvent.REF, refItem => {
+ this.emit(ParserEvent.REF, refItem);
+ });
+ templateParser.on(TemplateEvent.SLOT, slot => {
+ this.emit(ParserEvent.SLOT, slot);
+ });
- keywords.forEach(({ name, description }) => {
- if (name in PARAM_ALIASES) {
- const parsedParam = jsdoc.parseParamKeyword(description);
- const pIndex = event.params.findIndex(
- p => p.name === parsedParam.name
- );
-
- /*
- * Replace the param if there is already one present with
- * the same name. This will happen with parsed
- * FunctionDeclaration because params will already be
- * present from parsing the AST node.
- */
- if (pIndex >= 0) {
- event.params[pIndex] = parsedParam;
- } else {
- /*
- * This means @param does not match an actual param
- * in the FunctionDeclaration.
- * TODO: Implement option to choose behaviour (keep, ignore, warn, throw)
- */
- event.params.push(parsedParam);
- }
- } else if (name in RETURN_ALIASES) {
- event.return = jsdoc.parseReturnKeyword(description);
- }
+ // Special cases where more parsing is required
+ templateParser.on(TemplateEvent.EXPRESSION, (expression) => {
+ this.parseTemplateJavascriptExpression(expression);
});
- if (event.params.length === 0) {
- delete event.params;
- }
+ templateParser.on(TemplateEvent.GLOBAL_COMMENT, (comment) => {
+ this.emitGlobalComment(comment);
+ });
}
}
diff --git a/lib/v3/script.js b/lib/v3/script.js
new file mode 100644
index 0000000..ae49ddc
--- /dev/null
+++ b/lib/v3/script.js
@@ -0,0 +1,563 @@
+const espree = require('espree');
+const eslint = require('eslint');
+const EventEmitter = require('events');
+
+const { getAstDefaultOptions } = require('../options');
+const {
+ hasOwnProperty,
+ getCommentFromSourceCode,
+ buildPropertyAccessorChainFromAst,
+ getValueForPropertyAccessorChain,
+ inferTypeFromVariableDeclaration,
+} = require('../utils');
+
+const {
+ assertNodeType,
+ getInnermostBody,
+ parseFunctionDeclaration,
+ parseVariableDeclaration,
+ parseAndMergeKeywords,
+ updateType
+} = require('./v3-utils');
+
+const jsdoc = require('./../jsdoc');
+
+const { ScriptEvent } = require('./events');
+
+/**
+ * @typedef {import('./v3-utils').AstNode} AstNode
+ */
+
+const RE_ANONYMOUS_FUNCTION = /^{?\s*function\s+\(/i;
+const RE_STATIC_SCOPE = /\sscope=('module'|"module")/gi;
+
+/** @typedef {'default' | 'static' | 'markup'} ScopeType */
+
+const SCOPE_DEFAULT = 'default';
+const SCOPE_STATIC = 'static';
+const SCOPE_MARKUP = 'markup';
+
+/**
+ * @typedef ScriptParserOptions
+ * @property {Svelte3FeatureKeys[]} features
+ * @property {boolean} includeSourceLocations
+ */
+
+class ScriptParser extends EventEmitter {
+ /**
+ * @param {ScriptParserOptions} options
+ */
+ constructor(options) {
+ super();
+
+ this.includeSourceLocations = options.includeSourceLocations;
+ this.features = options.features;
+
+ // Internal properties
+ this.identifiers = Object.create(null); // Empty Map
+ this.imports = Object.create(null); // Empty Map
+ this.dispatcherConstructorNames = [];
+ this.dispatcherNames = [];
+ }
+
+ /**
+ * @sideEffect mutates `item.locations`.
+ *
+ * Attaches the node's location to the item if requested by the user.
+ *
+ * @param {import('../../typings').ISvelteItem} item the item to attach locations to
+ * @param {{ location?: { start: number, end: number } }} node the parsed node containing a location
+ * @param {{ offset: number }} context parse context containing an offset for locations
+ */
+ attachLocationsIfRequired(item, node, context) {
+ if (this.includeSourceLocations && node.location) {
+ item.locations = [{
+ start: node.location.start + context.offset,
+ end: node.location.end + context.offset,
+ }];
+ }
+ }
+
+ emitDataItem(variable, parseContext, defaultVisibility, parentComment) {
+ const comment = parentComment || getCommentFromSourceCode(variable.node, parseContext.sourceCode, { defaultVisibility });
+
+ /** @type {import('../../typings').SvelteDataItem} */
+ const item = {
+ ...comment,
+ name: variable.name,
+ kind: variable.kind,
+ static: parseContext.scopeType === SCOPE_STATIC,
+ readonly: variable.kind === 'const',
+ type: inferTypeFromVariableDeclaration(variable),
+ importPath: variable.importPath,
+ originalName: variable.originalName,
+ localName: variable.localName,
+ };
+
+ if (variable.declarator && variable.declarator.init) {
+ item.defaultValue = variable.declarator.init.value;
+ }
+
+ this.attachLocationsIfRequired(item, variable, parseContext);
+
+ updateType(item);
+
+ this.emit(ScriptEvent.DATA, item);
+ }
+
+ emitMethodItem(method, parseContext, defaultVisibility, parentComment) {
+ const comment = parentComment || getCommentFromSourceCode(method.node, parseContext.sourceCode, { defaultVisibility });
+
+ parseAndMergeKeywords(comment.keywords, method);
+
+ /** @type {import('../../typings').SvelteMethodItem} */
+ const item = {
+ ...comment,
+ name: method.name,
+ params: method.params,
+ return: method.return,
+ static: parseContext.scopeType === SCOPE_STATIC
+ };
+
+ this.attachLocationsIfRequired(item, method, parseContext);
+
+ this.emit(ScriptEvent.METHOD, item);
+ }
+
+ emitComputedItem(computed, parseContext, defaultVisibility) {
+ const comment = getCommentFromSourceCode(computed.node, parseContext.sourceCode, { defaultVisibility });
+
+ /** @type {import('../../typings').SvelteComputedItem} */
+ const item = {
+ ...comment,
+ name: computed.name,
+ static: parseContext.scopeType === SCOPE_STATIC,
+ type: jsdoc.DEFAULT_TYPE
+ };
+
+ this.attachLocationsIfRequired(item, computed, parseContext);
+
+ updateType(item);
+
+ this.emit(ScriptEvent.COMPUTED, item);
+ }
+
+ emitEventItem(event, parseContext) {
+ const comment = getCommentFromSourceCode(event.node, parseContext.sourceCode, { defaultVisibility: 'public' });
+
+ /** @type {import('../../typings').SvelteEventItem} */
+ const item = {
+ ...comment,
+ name: event.name
+ };
+
+ this.attachLocationsIfRequired(item, event, parseContext);
+
+ this.emit(ScriptEvent.EVENT, item);
+ }
+
+ emitImportedComponentItem(component, parseContext) {
+ const comment = getCommentFromSourceCode(component.node, parseContext.sourceCode, { defaultVisibility: 'private' });
+
+ /** @type {import('../../typings').SvelteComponentItem} */
+ const item = {
+ ...comment,
+ name: component.name,
+ importPath: component.path,
+ };
+
+ this.attachLocationsIfRequired(item, component, parseContext);
+
+ this.emit(ScriptEvent.IMPORTED_COMPONENT, item);
+ }
+
+ /**
+ * @typedef {import("../helpers").HtmlBlock} HtmlBlock
+ * @param {HtmlBlock[]} scripts
+ */
+ parse(scripts) {
+ scripts.forEach(script => {
+ this.parseScript(script);
+ });
+ }
+
+ /**
+ * @param {{ content: string; offset: number; attributes?: string }} script
+ * @param {ScopeType} scope if passed, overrides the scopeType used during parsing
+ */
+ parseScript(script, scope) {
+ const ast = espree.parse(
+ script.content,
+ getAstDefaultOptions()
+ );
+ const sourceCode = new eslint.SourceCode({
+ text: script.content,
+ ast: ast
+ });
+
+ const isStaticScope = RE_STATIC_SCOPE.test(script.attributes);
+ const scriptParseContext = {
+ scopeType: scope || (isStaticScope ? SCOPE_STATIC : SCOPE_DEFAULT),
+ offset: script.offset,
+ sourceCode: sourceCode
+ };
+
+ this.parseBodyRecursively(ast, scriptParseContext, 0);
+ }
+
+ /**
+ * Call this to parse javascript expressions found in the template. The
+ * content of the parsed scripts, such as dispatchers and identifiers, are
+ * available so they will be recognized when used in template javascript
+ * expressions.
+ *
+ * @param {string} expression javascript expression found in the template
+ */
+ parseScriptExpression(expression, offset = 0) {
+ // Add name for anonymous functions to prevent parser error
+ expression = expression.replace(RE_ANONYMOUS_FUNCTION, function (m) {
+ // Preserve the curly brace if it appears in the quotes
+ return m.startsWith('{') ? '{function a(' : 'function a(';
+ });
+
+ const expressionWrapper = {
+ content: expression,
+ offset: offset,
+ };
+
+ this.parseScript(expressionWrapper, SCOPE_MARKUP);
+ }
+
+ parseVariableDeclarations(declaration, context, level, visibility, comment = undefined) {
+ if (context.scopeType === SCOPE_MARKUP) {
+ return;
+ }
+
+ const variables = parseVariableDeclaration(declaration);
+
+ variables.forEach(variable => {
+ if (level === 0) {
+ this.emitDataItem(variable, context, visibility, comment);
+ }
+
+ if (!variable.declarator.init) {
+ return;
+ }
+
+ const id = variable.declarator.id;
+ const init = variable.declarator.init;
+
+ // Store top level variables in 'identifiers'
+ if (level === 0 && id.type === 'Identifier') {
+ this.identifiers[id.name] = init;
+ }
+
+ if (init.type === 'CallExpression') {
+ const callee = init.callee;
+
+ if (init.arguments) {
+ this.parseBodyRecursively(init.arguments, context, level + 1);
+ }
+
+ if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) {
+ this.dispatcherNames.push(variable.name);
+ }
+ } else if (init.type === 'ArrowFunctionExpression') {
+ this.parseBodyRecursively(init, context, level + 1);
+ }
+ }
+ );
+ }
+
+ parseEventDeclaration(node) {
+ assertNodeType(node, 'CallExpression');
+
+ const args = node.arguments;
+
+ if (!args || !args.length) {
+ return null;
+ }
+
+ const nameNode = args[0];
+
+ let name;
+
+ try {
+ const chain = buildPropertyAccessorChainFromAst(nameNode);
+
+ // This function can throw if chain is not valid
+ name = getValueForPropertyAccessorChain(this.identifiers, chain);
+ } catch (error) {
+ name = nameNode.type === 'Literal'
+ ? nameNode.value
+ : undefined;
+ }
+
+ return {
+ name: name,
+ node: node,
+ location: {
+ start: nameNode.start,
+ end: nameNode.end
+ }
+ };
+ }
+
+ /**
+ *
+ * @param {{ body: AstNode | AstNode[] } | AstNode[]} rootNode
+ * @param {*} parseContext
+ * @param {*} level
+ */
+ parseBodyRecursively(rootNode, parseContext, level) {
+ if (!rootNode) {
+ throw TypeError('parseBodyRecursively was called without a node');
+ }
+
+ const body = getInnermostBody(rootNode);
+ const nodes = Array.isArray(body) ? body : [body];
+
+ if (nodes[0] && level === 0) {
+ this.emit(ScriptEvent.GLOBAL_COMMENT, getCommentFromSourceCode(
+ nodes[0],
+ parseContext.sourceCode,
+ { useTrailing: false, useFirst: true }
+ ));
+ }
+
+ nodes.forEach((node) => {
+ if (node.type === 'BlockStatement') {
+ this.parseBodyRecursively(node, parseContext, level);
+
+ return;
+ }
+
+ if (node.type === 'ExpressionStatement') {
+ const expression = node.expression;
+
+ if (expression.type === 'CallExpression') {
+ this.parseBodyRecursively(expression, parseContext, level);
+ } else if (expression.type === 'ArrowFunctionExpression') {
+ this.parseBodyRecursively(expression, parseContext, level + 1);
+ }
+
+ return;
+ }
+
+ if (node.type === 'CallExpression') {
+ const callee = node.callee;
+
+ if (node.arguments) {
+ this.parseBodyRecursively(node.arguments, parseContext, level + 1);
+ }
+
+ if (callee.type === 'Identifier' && this.dispatcherNames.includes(callee.name)) {
+ const eventItem = this.parseEventDeclaration(node);
+
+ this.emitEventItem(eventItem, parseContext);
+ }
+
+ return;
+ }
+
+ if (node.type === 'VariableDeclaration' && parseContext.scopeType !== SCOPE_MARKUP) {
+ this.parseVariableDeclarations(node, parseContext, level, 'private');
+
+ return;
+ }
+
+ if (node.type === 'FunctionDeclaration') {
+ const func = parseFunctionDeclaration(node);
+
+ this.emitMethodItem(func, parseContext, 'private');
+ this.parseBodyRecursively(node, parseContext, level + 1);
+
+ return;
+ }
+
+ if (node.type === 'ExportNamedDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
+ const declaration = node.declaration;
+ const specifiers = node.specifiers;
+
+ if (declaration) {
+ const exportNodeComment = getCommentFromSourceCode(
+ node,
+ parseContext.sourceCode,
+ { defaultVisibility: 'public', useLeading: true, useTrailing: false }
+ );
+
+ if (declaration.type === 'VariableDeclaration') {
+ this.parseVariableDeclarations(
+ declaration,
+ parseContext,
+ level,
+ 'public',
+ exportNodeComment
+ );
+ }
+
+ if (declaration.type === 'FunctionDeclaration') {
+ const func = parseFunctionDeclaration(declaration);
+
+ this.emitMethodItem(func, parseContext, 'public', exportNodeComment);
+ this.parseBodyRecursively(declaration, parseContext, level + 1);
+ }
+ }
+
+ if (specifiers) {
+ specifiers.forEach(specifier => {
+ if (specifier.type === 'ExportSpecifier') {
+ const subNode = specifier.exported ? 'exported' : 'local';
+ const dataItem = {
+ node: specifier,
+ name: specifier[subNode].name,
+ localName: specifier.local.name,
+ kind: 'const',
+ location: {
+ start: specifier[subNode].start,
+ end: specifier[subNode].end
+ }
+ };
+
+ this.emitDataItem(dataItem, parseContext, 'public');
+ }
+ });
+ }
+
+ return;
+ }
+
+ /**
+ * Special case for reactive declarations (computed)
+ * In this case, the body is not parsed recursively.
+ */
+ if (node.type === 'LabeledStatement' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
+ const label = node.label;
+
+ if (label && label.type === 'Identifier' && label.name === '$') {
+ if (node.body && node.body.type === 'ExpressionStatement') {
+ const expression = node.body.expression;
+
+ if (expression && expression.type === 'AssignmentExpression') {
+ const leftNode = expression.left;
+
+ if (leftNode.type === 'Identifier') {
+ const computedItem = {
+ name: leftNode.name,
+ location: {
+ start: leftNode.start,
+ end: leftNode.end
+ },
+ node: node
+ };
+
+ this.emitComputedItem(computedItem, parseContext, 'private');
+ }
+ }
+ }
+ }
+
+ return;
+ }
+
+ if (node.type === 'ImportDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) {
+ const specifier = node.specifiers[0];
+ const source = node.source;
+
+ if (source && source.type === 'Literal') {
+ const sourceFileName = source.value;
+
+ if (specifier && specifier.type === 'ImportDefaultSpecifier') {
+ const importEntry = {
+ identifier: specifier.local.name,
+ sourceFilename: sourceFileName
+ };
+
+ if (!hasOwnProperty(this.imports, importEntry.identifier)) {
+ this.imports[importEntry.identifier] = importEntry;
+
+ if (importEntry.identifier) {
+ if (importEntry.identifier[0] === importEntry.identifier[0].toUpperCase()) {
+ const component = {
+ node: node,
+ name: importEntry.identifier,
+ path: importEntry.sourceFilename,
+ location: {
+ start: specifier.local.start,
+ end: specifier.local.end
+ }
+ };
+
+ this.emitImportedComponentItem(component, parseContext);
+
+ return;
+ } else {
+ const imported = specifier.imported
+ ? specifier.imported.name
+ : undefined;
+
+ const dataItem = {
+ node,
+ name: importEntry.identifier,
+ originalName: imported || importEntry.identifier,
+ importPath: importEntry.sourceFilename,
+ kind: 'const',
+ location: {
+ start: specifier.local.start,
+ end: specifier.local.end
+ }
+ };
+
+ this.emitDataItem(dataItem, parseContext, 'private');
+ }
+ }
+ }
+ } else if (node.specifiers.length > 0) {
+ node.specifiers.forEach((specifier) => {
+ if (specifier.type === 'ImportSpecifier') {
+ const dataItem = {
+ node: specifier,
+ name: specifier.local.name,
+ originalName: specifier.imported
+ ? specifier.imported.name
+ : specifier.local.name,
+ importPath: sourceFileName,
+ kind: 'const',
+ location: {
+ start: specifier.local.start,
+ end: specifier.local.end
+ }
+ };
+
+ this.emitDataItem(dataItem, parseContext, 'private');
+ }
+ });
+ }
+
+ // Import svelte API functions
+ if (sourceFileName === 'svelte') {
+ // Dispatcher constructors
+ node.specifiers
+ .filter(specifier => specifier.imported.name === 'createEventDispatcher')
+ .forEach(specifier => {
+ this.dispatcherConstructorNames.push(specifier.local.name);
+ });
+ }
+ }
+
+ return;
+ }
+
+ /**
+ * There must be a check for body presence because otherwise
+ * the parser gets stuck in an infinite loop on some nodes
+ */
+ if (node.body) {
+ this.parseBodyRecursively(node.body, parseContext, level + 1);
+ }
+ });
+ }
+}
+
+module.exports = ScriptParser;
+module.exports.SCOPE_STATIC = SCOPE_STATIC;
diff --git a/lib/v3/template.js b/lib/v3/template.js
new file mode 100644
index 0000000..7f9236b
--- /dev/null
+++ b/lib/v3/template.js
@@ -0,0 +1,242 @@
+const EventEmitter = require('events');
+const { Parser: HtmlParser } = require('htmlparser2-svelte');
+
+const { parseAndMergeKeywords } = require('./v3-utils');
+const { parseComment, hasOwnProperty } = require('../utils');
+const { TemplateEvent } = require('./events');
+
+/**
+ * @typedef {import('../../typings').Svelte3FeatureKeys} Svelte3FeatureKeys
+ *
+ * @typedef TemplateParserOptions
+ * @property {Svelte3FeatureKeys[]} features
+ * @property {boolean} includeSourceLocations
+ */
+
+class TemplateParser extends EventEmitter {
+ /**
+ * @param {TemplateParserOptions} options
+ */
+ constructor(options) {
+ super();
+
+ this.features = options.features;
+ this.includeSourceLocations = options.includeSourceLocations;
+
+ // Internal properties
+ /**
+ * Map of events already emitted. Check if an event exists in this map
+ * before emitting it again.
+ */
+ this.eventsEmitted = Object.create(null); // Empty Map
+ }
+
+ /**
+ * Parse the template markup and produce events with parsed data.
+ * @param {string} template The template markup to parse
+ */
+ parse(template) {
+ const options = {
+ lowerCaseTags: false,
+ lowerCaseAttributeNames: false,
+ curlyBracesInAttributes: true
+ };
+
+ const parser = new HtmlParser(this.getTemplateHandler(), options);
+
+ parser.write(template);
+ parser.end();
+ }
+
+ getTemplateHandler() {
+ let rootElementIndex = 0;
+ let lastComment = null;
+ let lastAttributeIndex = 0;
+ let lastAttributeLocations = {};
+ let lastTagName = null;
+ let parser = null;
+
+ return {
+ onparserinit: (parserInstance) => {
+ parser = parserInstance;
+ },
+ oncomment: (data) => {
+ lastComment = data.trim();
+ },
+ ontext: (text) => {
+ if (text.trim()) {
+ lastComment = null;
+ }
+ },
+ onattribute: (name, value) => {
+ if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
+ lastAttributeLocations[name] = {
+ start: lastAttributeIndex,
+ end: parser._tokenizer._index
+ };
+
+ lastAttributeIndex = parser._tokenizer._index;
+ }
+
+ if (this.features.includes('events')) {
+ if (lastTagName !== 'slot') {
+ // Expose events that propagated from child events
+ // Handle event syntax like ``````
+ if (name.length > 3 && name.indexOf('on:') === 0 && !value) {
+ const nameWithModificators = name.substr(3).split('|');
+
+ const baseEvent = {
+ name: nameWithModificators[0],
+ parent: lastTagName,
+ modificators: nameWithModificators.slice(1),
+ locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
+ ? [lastAttributeLocations[name]]
+ : null
+ };
+
+ if (lastComment) {
+ lastComment = `/** ${lastComment} */`;
+ }
+
+ const comment = parseComment(lastComment || '');
+
+ baseEvent.visibility = comment.visibility;
+ baseEvent.description = comment.description || '';
+ baseEvent.keywords = comment.keywords;
+
+ if (!hasOwnProperty(this.eventsEmitted, baseEvent.name)) {
+ this.eventsEmitted[baseEvent.name] = baseEvent;
+
+ parseAndMergeKeywords(comment.keywords, baseEvent);
+
+ this.emit(TemplateEvent.EVENT, baseEvent);
+ }
+
+ lastComment = null;
+ }
+
+ // Parse event handlers
+ if (name.length > 3 && name.indexOf('on:') === 0 && value) {
+ this.emit(TemplateEvent.EXPRESSION, value);
+ }
+ }
+ }
+ },
+ onopentagname: (tagName) => {
+ lastTagName = tagName;
+ lastAttributeIndex = parser._tokenizer._index;
+ lastAttributeLocations = {};
+ },
+ onopentag: (tagName, attrs) => {
+ const isNotStyleOrScript = !['style', 'script'].includes(tagName);
+ const isTopLevelElement = parser._stack.length === 1;
+
+ if (isTopLevelElement && isNotStyleOrScript) {
+ if (lastComment && rootElementIndex === 0) {
+ const parsedComment = parseComment(lastComment);
+
+ this.emit(TemplateEvent.GLOBAL_COMMENT, parsedComment);
+ }
+
+ rootElementIndex += 1;
+ }
+
+ if (tagName === 'slot') {
+ if (this.features.includes('slots')) {
+ const exposedParameters = Object.keys(attrs)
+ .filter(name => name.length > 0 && name !== 'name')
+ .map(name => ({
+ name: name,
+ visibility: 'public'
+ }));
+
+ const slot = {
+ name: attrs.name || 'default',
+ description: lastComment,
+ visibility: 'public',
+ parameters: exposedParameters
+ };
+
+ if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
+ slot.loc = {
+ start: parser.startIndex,
+ end: parser.endIndex
+ };
+ }
+
+ this.emit(TemplateEvent.SLOT, slot);
+ }
+ } else {
+ if (tagName === 'svelte:options' && attrs.tag) {
+ if (this.features.includes('name')) {
+ this.emit(TemplateEvent.NAME, attrs.tag);
+ }
+ }
+
+ if (this.features.includes('data')) {
+ const bindProperties = Object.keys(attrs)
+ .filter(name => name.length > 5 && name.indexOf('bind:') === 0)
+ .filter(name => name !== 'bind:this')
+ .map(name => {
+ const sourcePropertyName = name.substr(5);
+
+ let targetPropertyName = sourcePropertyName;
+
+ const attributeValue = attrs[name];
+
+ if (attributeValue && attributeValue.length > 0) {
+ targetPropertyName = attributeValue;
+ }
+
+ return {
+ sourcePropertyName: sourcePropertyName,
+ targetPropertyName: targetPropertyName,
+ parent: tagName,
+ locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
+ ? [lastAttributeLocations[name]]
+ : null
+ };
+ });
+
+ bindProperties.forEach(bindProperty => {
+ const dataItem = {
+ name: bindProperty.targetPropertyName,
+ kind: undefined,
+ bind: [{
+ source: bindProperty.parent,
+ property: bindProperty.sourcePropertyName
+ }],
+ locations: bindProperty.locations,
+ visibility: 'private',
+ static: false,
+ readonly: false
+ };
+
+ this.emit(TemplateEvent.DATA, dataItem);
+ });
+ }
+
+ if (this.features.includes('refs')) {
+ if (hasOwnProperty(attrs, 'bind:this') && attrs['bind:this']) {
+ const bindedVariableName = attrs['bind:this'];
+
+ const refItem = {
+ visibility: 'private',
+ name: bindedVariableName,
+ parent: tagName,
+ locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, 'bind:this')
+ ? [lastAttributeLocations['bind:this']]
+ : null
+ };
+
+ this.emit(TemplateEvent.REF, refItem);
+ }
+ }
+ }
+ }
+ };
+ }
+}
+
+module.exports = TemplateParser;
+module.exports.TemplateEvent = TemplateEvent;
diff --git a/lib/v3/v3-utils.js b/lib/v3/v3-utils.js
new file mode 100644
index 0000000..273ea86
--- /dev/null
+++ b/lib/v3/v3-utils.js
@@ -0,0 +1,206 @@
+const {
+ parseParamKeyword,
+ parseReturnKeyword,
+ parseTypeKeyword
+} = require('../jsdoc');
+
+class InvalidNodeTypeError extends TypeError {
+ constructor(expected, actual = '', ...args) {
+ super(expected, actual, ...args);
+ this.message = `Node should be of type '${expected}', but it was ${actual}.`;
+ }
+}
+
+/**
+ * @typedef {{ type: string }} AstNode
+ */
+
+/**
+ * @throws InvalidNodeTypeError if the node is not of the correct type
+ * @param {AstNode} node
+ * @param {string} type
+ */
+function assertNodeType(node, type) {
+ if (node.type !== type) {
+ throw new InvalidNodeTypeError(type, node.type);
+ }
+}
+
+/** All `@param` JSDoc aliases. */
+const PARAM_ALIASES = {
+ arg: true,
+ argument: true,
+ param: true
+};
+
+/** All `@returns` JSDoc aliases. */
+const RETURN_ALIASES = {
+ return: true,
+ returns: true,
+};
+
+/**
+ * Returns the innermost body prop from node.
+ * returns the same node if it does not have a body.
+ * @param {AstNode & { body?: AstNode | AstNode[] }} node an AST node
+ */
+function getInnermostBody(node) {
+ while (node && node.body) {
+ node = node.body;
+ }
+
+ return node;
+}
+
+/**
+ * @param {AstNode} node a 'FunctionDeclaration' AST node
+ */
+function parseFunctionDeclaration(node) {
+ assertNodeType(node, 'FunctionDeclaration');
+
+ const params = [];
+
+ node.params.forEach((param) => {
+ if (param.type === 'Identifier') {
+ params.push({
+ name: param.name,
+ });
+ }
+ });
+
+ const output = {
+ node: node,
+ name: node.id.name,
+ location: {
+ start: node.id.start,
+ end: node.id.end
+ },
+ params: params,
+ };
+
+ return output;
+}
+
+/**
+ * @param {AstNode} node a 'VariableDeclaration' AST node
+ */
+function parseVariableDeclaration(node) {
+ assertNodeType(node, 'VariableDeclaration');
+
+ const result = [];
+
+ node.declarations.forEach(declarator => {
+ const idNode = declarator.id;
+
+ if (idNode.type === 'Identifier') {
+ result.push({
+ name: idNode.name,
+ kind: node.kind,
+ node: node,
+ declarator: declarator,
+ location: {
+ start: idNode.start,
+ end: idNode.end
+ }
+ });
+ } else if (idNode.type === 'ObjectPattern') {
+ idNode.properties.forEach(propertyNode => {
+ const propertyIdNode = propertyNode.key;
+
+ if (propertyIdNode.type === 'Identifier') {
+ result.push({
+ name: propertyIdNode.name,
+ kind: node.kind,
+ node: node,
+ declarator: declarator,
+ locations: {
+ start: propertyIdNode.start,
+ end: propertyIdNode.end
+ }
+ });
+ }
+ });
+ }
+ });
+
+ return result;
+}
+
+/**
+ * @sideEffect Mutates `item.type`.
+ *
+ * Updates the item.type` from `@type` in `item.keywords`, if any.
+ *
+ * @param {{ name: string; description: string}[]} item
+ */
+function updateType(item) {
+ const typeKeyword = item.keywords.find(kw => kw.name === 'type');
+
+ if (typeKeyword) {
+ const parsedType = parseTypeKeyword(typeKeyword.description);
+
+ if (parsedType) {
+ item.type = parsedType;
+ }
+ }
+}
+
+/**
+ * @sideEffect Mutates `event.params` and `event.return`.
+ *
+ * Parses the `keywords` argument and merges the result in `event`.
+ *
+ * If a param exists as a JSDoc `@param`, it will overwrite the param of the
+ * same name already in event, if any. If a `@param` in JSDoc does
+ * not match the name of an actual param of the function, it is appended at
+ * end of the `event` params.
+ *
+ * @param {{ name: string; description: string}[]} keywords
+ * @param {{ params?: any[]; return?: any }} event
+ */
+function parseAndMergeKeywords(keywords = [], event) {
+ if (!event.params) {
+ event.params = [];
+ }
+
+ keywords.forEach(({ name, description }) => {
+ if (name in PARAM_ALIASES) {
+ const parsedParam = parseParamKeyword(description);
+ const pIndex = event.params.findIndex(
+ p => p.name === parsedParam.name
+ );
+
+ /*
+ * Replace the param if there is already one present with
+ * the same name. This will happen with parsed
+ * FunctionDeclaration because params will already be
+ * present from parsing the AST node.
+ */
+ if (pIndex >= 0) {
+ event.params[pIndex] = parsedParam;
+ } else {
+ /*
+ * This means @param does not match an actual param
+ * in the FunctionDeclaration. For now, we keep it.
+ * TODO: Implement option to choose behaviour (keep, ignore, warn, throw)
+ */
+ event.params.push(parsedParam);
+ }
+ } else if (name in RETURN_ALIASES) {
+ event.return = parseReturnKeyword(description);
+ }
+ });
+
+ if (event.params.length === 0) {
+ delete event.params;
+ }
+}
+
+module.exports = {
+ assertNodeType,
+ getInnermostBody,
+ parseFunctionDeclaration,
+ parseVariableDeclaration,
+ parseAndMergeKeywords,
+ updateType,
+};
diff --git a/test/svelte3/integration/events/event.dispatcher.identifier.svelte b/test/svelte3/integration/events/event.dispatcher.identifier.svelte
index 23f2f8c..5d54c74 100644
--- a/test/svelte3/integration/events/event.dispatcher.identifier.svelte
+++ b/test/svelte3/integration/events/event.dispatcher.identifier.svelte
@@ -7,10 +7,18 @@
NOTIFY: 'notify'
}
};
-
const SIMPLE_EVENT = 'plain-notify';
dispatch(EVENT.SIGNAL.NOTIFY);
-
dispatch(SIMPLE_EVENT);
+
+ export const EXPORTED_EVENT = {
+ SIGNAL: {
+ NOTIFY: 'exported-notify'
+ }
+ };
+ export const EXPORTED_SIMPLE_EVENT = 'exported-plain-notify';
+
+ dispatch(EXPORTED_EVENT.SIGNAL.NOTIFY);
+ dispatch(EXPORTED_SIMPLE_EVENT);
\ No newline at end of file
diff --git a/test/svelte3/integration/events/events.spec.js b/test/svelte3/integration/events/events.spec.js
index 02469e3..af268c0 100644
--- a/test/svelte3/integration/events/events.spec.js
+++ b/test/svelte3/integration/events/events.spec.js
@@ -333,7 +333,7 @@ describe('SvelteDoc v3 - Events', () => {
}).then((doc) => {
expect(doc, 'Document should be provided').to.exist;
expect(doc.events, 'Document events should be parsed').to.exist;
- expect(doc.events.length).to.equal(2);
+ expect(doc.events.length).to.equal(4);
let event = doc.events[0];
@@ -347,6 +347,18 @@ describe('SvelteDoc v3 - Events', () => {
expect(event.name).to.equal('plain-notify');
expect(event.visibility).to.equal('public');
+ event = doc.events[2];
+
+ expect(event, 'Event should be a valid entity').to.exist;
+ expect(event.name).to.equal('exported-notify');
+ expect(event.visibility).to.equal('public');
+
+ event = doc.events[3];
+
+ expect(event, 'Event should be a valid entity').to.exist;
+ expect(event.name).to.equal('exported-plain-notify');
+ expect(event.visibility).to.equal('public');
+
done();
}).catch(e => {
done(e);
diff --git a/typings.d.ts b/typings.d.ts
index aa9f23b..5724200 100644
--- a/typings.d.ts
+++ b/typings.d.ts
@@ -70,18 +70,7 @@ export type JSVisibilityScope = 'public' | 'protected' | 'private';
export type JSVariableDeclarationKind = 'var' | 'let' | 'const';
-export interface ISvelteItem {
- /**
- * The name of the item.
- */
- name: string;
-
- /**
- * The list of source code locations for this item.
- * Provided only if requested by specific option parameter.
- */
- locations?: SourceLocation[];
-
+export interface IScopedCommentItem {
/**
* The description of the item, provided from related comment.
*/
@@ -96,6 +85,19 @@ export interface ISvelteItem {
keywords?: JSDocKeyword[];
}
+export interface ISvelteItem extends IScopedCommentItem {
+ /**
+ * The name of the item.
+ */
+ name: string;
+
+ /**
+ * The list of source code locations for this item.
+ * Provided only if requested by specific option parameter.
+ */
+ locations?: SourceLocation[];
+}
+
export interface SvelteDataBindMapping {
/**
* The parent component name or DOM element from which are was binded.