sveltedoc-parser
Version:
Generate a JSON documentation for a Svelte file
755 lines (610 loc) • 27.6 kB
JavaScript
const EventEmitter = require('events');
const espree = require('espree');
const eslint = require('eslint');
const HtmlParser = require('htmlparser2-svelte').Parser;
const path = require('path');
const utils = require('./utils');
const jsdoc = require('./jsdoc');
const {
normalize: normalizeOptions,
validateFeatures,
getAstDefaultOptions
} = require('./options');
const hasOwnProperty = utils.hasOwnProperty;
const SUPPORTED_FEATURES = [
'name',
'data',
'computed',
'methods',
'actions',
'helpers',
'components',
'description',
'keywords',
'events',
'slots',
'transitions',
'refs',
'store'
];
const EVENT_EMIT_RE = /\bfire\s*\(\s*((?:'[^']*')|(?:"[^"]*")|(?:`[^`]*`))/;
function generateSourceCode(options) {
const generated = {
ast: null,
sourceCode: null,
scriptOffset: 0,
};
if (!(hasOwnProperty(options.source, 'script') && options.source.script)) {
return generated;
}
try {
generated.ast = espree.parse(
options.source.script,
getAstDefaultOptions()
);
generated.sourceCode = new eslint.SourceCode({
text: options.source.script,
ast: generated.ast
});
} catch (e) {
const script = utils.escapeImportKeyword(options.source.script);
generated.ast = espree.parse(script, getAstDefaultOptions());
generated.sourceCode = new eslint.SourceCode({
text: script,
ast: generated.ast
});
}
generated.scriptOffset = options.source.scriptOffset || 0;
return generated;
}
class Parser extends EventEmitter {
constructor(options) {
super();
Parser.validateOptions(options);
// External options
this.filename = options.filename;
this.source = options.source;
this.template = options.source.template;
this.features = options.features;
this.includeSourceLocations = options.includeSourceLocations;
// Internal Properties
this.defaultMethodVisibility = utils.DEFAULT_VISIBILITY;
this.defaultActionVisibility = utils.DEFAULT_VISIBILITY;
this.componentName = null;
this.eventsEmitted = {};
this.identifiers = {};
this.imports = {};
// Generated Espree AST
const { ast, sourceCode, scriptOffset } = generateSourceCode(options);
this.ast = ast;
this.sourceCode = sourceCode;
this.scriptOffset = scriptOffset;
}
static getDefaultOptions() {
return {
includeSourceLocations: true,
features: [...SUPPORTED_FEATURES]
};
}
static validateOptions(options) {
normalizeOptions(options, Parser.getDefaultOptions());
validateFeatures(options, SUPPORTED_FEATURES);
if ('source' in options) {
['script', 'scriptOffset'].forEach(key => {
if (!(key in options.source)) {
throw new TypeError('options.source must have keys \'script\' and \'scriptOffset\'');
}
});
}
}
static getEventName(feature) {
return feature.endsWith('s')
? feature.substring(0, feature.length - 1)
: feature;
}
parseObjectExpression(property) {
if (!this.features.includes(property.key.name)) {
// If events feature are enabled we should also include methods to extract events from methods
if (!(property.key.name === 'methods' && this.features.includes('events'))) {
return;
}
}
const eventName = Parser.getEventName(property.key.name);
property.value.properties.forEach((p) => {
let defaultVisibility = utils.DEFAULT_VISIBILITY;
if (property.key.name === 'methods' && this.defaultMethodVisibility) {
defaultVisibility = this.defaultMethodVisibility;
}
if (property.key.name === 'actions' && this.defaultActionVisibility) {
defaultVisibility = this.defaultActionVisibility;
}
const entry = utils.getCommentFromSourceCode(p, this.sourceCode, { defaultVisibility });
if (p.type === 'ExperimentalSpreadProperty') {
if (p.argument.name in this.identifiers) {
const value = this.identifiers[p.argument.name];
const spreadProperty = Object.assign({}, property, { value });
return this.parseObjectExpression(spreadProperty);
} else {
return;
}
}
entry.value = utils.value(p);
entry.name = Object.keys(entry.value)[0];
entry.value = entry.value[entry.name];
if (this.includeSourceLocations) {
if (p.key && p.key.start >= 0 && p.key.end >= p.key.start) {
entry.locations = [{
start: p.key.start + this.scriptOffset,
end: p.key.end + this.scriptOffset
}];
}
}
if (property.key.name === 'computed') {
if (entry.value instanceof utils.NodeFunction) {
entry.dependencies = utils.getDependencies(
entry.value, this.source.script);
} else {
const value = entry.value;
const NodeFunction = utils.NodeFunction;
if (value instanceof Object && value.get instanceof NodeFunction) {
entry.dependencies = utils.getDependencies(
entry.value.get, this.source.script);
} else {
entry.dependencies = [];
}
}
delete entry.value;
} else if (property.key.name === 'events') {
const event = {
name: entry.name,
parent: null,
description: entry.description,
visibility: 'private',
keywords: entry.keywords
};
if (hasOwnProperty(this.eventsEmitted, event.name)) {
const emitedEvent = this.eventsEmitted[event.name];
if (emitedEvent.parent) {
event.visibility = 'public';
this.eventsEmitted[event.name] = event;
this.parseKeywords(entry.keywords, event);
this.emit('event', event);
} else {
// This event already defined
}
} else {
this.eventsEmitted[event.name] = event;
this.parseKeywords(entry.keywords, event);
this.emit('event', event);
}
return;
} else if (property.key.name === 'components') {
if (p.value && p.value.type === 'Identifier') {
if (hasOwnProperty(this.imports, p.value.name)) {
entry.importPath = this.imports[p.value.name].sourceFilename;
}
}
} else if (entry.value instanceof utils.NodeFunction) {
entry.params = entry.value.params.map(
(param) => ({ name: param.name })
);
} else if (property.key.name === 'data') {
const typeKeyword = entry.keywords.find(kw => kw.name === 'type');
if (typeKeyword) {
const parsedType = jsdoc.parseTypeKeyword(typeKeyword.description);
if (parsedType) {
entry.type = parsedType;
}
}
// If type can't be pased, but value are suplied, try to parse it
if (!entry.type) {
if (hasOwnProperty(entry, 'value')) {
const parsedType = jsdoc.parseJSTypeFromValueNode(entry.value);
if (parsedType) {
entry.type = parsedType;
} else {
entry.type = {
kind: 'type',
text: 'any',
type: 'any'
};
}
} else {
entry.type = {
kind: 'type',
text: 'any',
type: 'any'
};
}
}
}
delete entry.describeModel;
this.parseKeywords(entry.keywords, entry);
if (this.features.includes(property.key.name)) {
this.emit(eventName, entry);
}
this.subWalk(entry.value);
});
}
parseArrayExpression(property) {
if (!this.features.includes(property.key.name)) {
return;
}
const eventName = Parser.getEventName(property.key.name);
property.value.elements.forEach((p) => {
const entry = utils.getCommentFromSourceCode(p, this.sourceCode);
entry.name = p.value;
entry.type = jsdoc.DEFAULT_TYPE;
delete entry.describeModel;
this.emit(eventName, entry);
});
}
parseProperty(property) {
const propertyValue = utils.value(property);
if (hasOwnProperty(propertyValue, 'name') && !this.componentName) {
this.componentName = propertyValue.name;
return;
}
const entryValue = propertyValue[Object.keys(propertyValue)[0]];
this.subWalk(entryValue);
}
extractProperties(property) {
switch (property.value.type) {
case 'FunctionExpression': {
const expression = property.value.body.body.find((p) => {
return p.type === 'ReturnStatement';
});
if (expression) {
if (expression.argument.type === 'ObjectExpression') {
return this.parseObjectExpression({
key: property.key,
value: expression.argument
});
}
}
this.parseProperty(property);
break;
}
case 'ArrowFunctionExpression':
if (property.value.body.type === 'ObjectExpression') {
this.parseObjectExpression({
key: property.key,
value: property.value.body
});
} else {
this.parseProperty(property);
}
break;
case 'ObjectExpression':
this.parseObjectExpression(property);
break;
case 'ArrayExpression':
this.parseArrayExpression(property);
break;
default:
this.parseProperty(property);
}
}
parseComponentName() {
if (this.componentName === null) {
if (this.filename) {
this.componentName = path.parse(this.filename).name;
}
}
if (this.componentName) {
this.emit('name', utils.unCamelcase(this.componentName));
}
}
walk() {
process.nextTick(() => {
try {
this.internalWalk();
} catch (error) {
this.emit('failure', error);
}
});
return this;
}
internalWalk() {
if (this.template) {
this.parseTemplate();
}
if (this.features.includes('name')) {
this.parseComponentName();
}
if (this.ast === null) {
return this.emit('end');
}
this.ast.body.forEach((body) => {
const entry = utils.getCommentFromSourceCode(body, this.sourceCode, { defaultVisibility: this.defaultMethodVisibility, features: this.features });
if (entry.description) {
this.emit('description', entry.description);
}
if (entry.keywords.length) {
this.emit('keywords', entry.keywords);
}
if (body.type === 'ImportDeclaration') {
const specifier = body.specifiers[0];
if (specifier && specifier.type === 'ImportDefaultSpecifier') {
const source = body.source;
if (source && source.type === 'Literal') {
const importEntry = {
identifier: specifier.local.name,
sourceFilename: source.value
};
if (!hasOwnProperty(this.imports, importEntry.identifier)) {
this.imports[importEntry.identifier] = importEntry;
this.emit('import', importEntry);
}
}
}
}
if (body.type !== 'ExportDefaultDeclaration' && body.type !== 'ExpressionStatement') {
if (body.type === 'VariableDeclaration') {
this.identifiers[body.declarations[0].id.name] = body.declarations[0].init;
}
return;
}
if (body.declaration) {
switch (body.declaration.type) {
case 'ObjectExpression':
body.declaration.properties.forEach((property) => this.extractProperties(property));
break;
case 'Identifier':
if (hasOwnProperty(this.identifiers, body.declaration.name)) {
this.identifiers[body.declaration.name].properties.forEach((property) => this.extractProperties(property));
}
break;
}
} else if (body.expression !== null && body.expression.right && body.expression.right.properties) {
body.expression.right.properties.forEach((property) => this.extractProperties(property));
}
});
this.emit('end');
}
subWalk(entry) {
if (entry instanceof utils.NodeFunction) {
const tokens = utils.tokensInterval(this.ast.tokens, entry.range);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'Identifier' && token.value === 'fire') {
const nextIndex = i + 2;
if (!tokens[nextIndex]) {
break;
}
if (!this.features.includes('events')) {
continue;
}
const next = tokens[nextIndex];
const event = {
name: null,
parent: null,
description: null,
visibility: utils.DEFAULT_VISIBILITY,
keywords: []
};
const range = [
tokens[i - 3].range[1],
tokens[i - 2].range[0]
];
event.description = this.ast.comments.reverse().find((c) => {
return c.range[0] > range[0] && c.range[1] < range[1];
});
if (event.description) {
const comment = utils.parseComment(event.description.value);
event.visibility = comment.visibility;
event.description = comment.description || '';
event.keywords = comment.keywords;
const keyword = comment.keywords.find((keyword) => {
return keyword.name === 'event';
});
if (keyword) {
if (!keyword.description) {
this.emit('error', new Error('Missing keyword value for @event'));
continue;
}
event.name = keyword.description;
}
} else {
event.description = '';
}
if (event.name === null) {
if (this.includeSourceLocations) {
if (next.start >= 0 && next.end >= next.start) {
event.locations = [{
start: next.start + this.scriptOffset,
end: next.end + this.scriptOffset
}];
}
}
switch (next.type) {
case 'String':
event.name = next.value.replace(/['"]/g, '');
break;
case 'Identifier':
if (next.value in this.identifiers) {
const startingAtFirstArg = tokens.slice(nextIndex);
const chain = utils.buildPropertyAccessorChainFromTokens(startingAtFirstArg);
event.name = utils.getValueForPropertyAccessorChain(this.identifiers, chain);
}
if (!event.name) {
event.name = utils.getIdentifierValue(
tokens, next.value, next.range[0]);
if (typeof event.name === 'object') {
event.name = utils.getIdentifierValueFromStart(
this.ast.tokens, event.name.notFoundIdentifier);
}
}
break;
}
}
if (!event.name) {
event.name = utils.UNHANDLED_EVENT_NAME;
} else {
if (hasOwnProperty(this.eventsEmitted, event.name)) {
const emitedEvent = this.eventsEmitted[event.name];
if (emitedEvent.visibility === 'public') {
continue;
}
}
this.eventsEmitted[event.name] = event;
}
this.parseKeywords(event.keywords, event);
this.emit('event', event);
i += 2;
}
}
}
}
parseTemplate() {
let lastComment = null;
let lastAttributeIndex = 0;
let lastAttributeLocations = {};
const parser = new HtmlParser({
oncomment: (data) => {
lastComment = data.trim();
},
ontext: (text) => {
if (text.trim()) {
lastComment = null;
}
},
onattribute: (name) => {
if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
lastAttributeLocations[name] = {
start: lastAttributeIndex,
end: parser._tokenizer._index
};
lastAttributeIndex = parser._tokenizer._index;
}
},
onopentagname: () => {
lastAttributeIndex = parser._tokenizer._index;
lastAttributeLocations = {};
},
onopentag: (tagName, attrs) => {
if (tagName === 'slot') {
if (this.features.includes('slots')) {
const slot = {
name: attrs.name || 'default',
visibility: 'public',
description: lastComment
};
if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) {
slot.locations = [{
start: parser.startIndex,
end: parser.endIndex
}];
}
this.emit('slot', slot);
}
} else {
if (this.features.includes('refs')) {
const refs = Object.keys(attrs)
.filter(name => name.length > 4 && name.indexOf('ref:') === 0 && !attrs[name])
.map(name => ({
name: name.substr(4),
parent: tagName,
locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
? [lastAttributeLocations[name]]
: null
}));
// Fill additional informations about ref
refs.forEach((ref) => {
if (lastComment) {
lastComment = `/** ${lastComment} */`;
}
const comment = utils.parseComment(lastComment || '');
ref.visibility = comment.visibility;
ref.description = comment.description || '';
ref.keywords = comment.keywords;
this.parseKeywords(comment.keywords, ref);
this.emit('ref', ref);
lastComment = null;
});
lastComment = null;
}
if (this.features.includes('events')) {
// Expose events that propogated from child events
// Handle event syntax like ```<button on:click>Some link</button>```
const propogatedEvents = Object.keys(attrs)
.filter(name => name.length > 3 && name.indexOf('on:') === 0 && !attrs[name])
.map(name => {
const nameWithModificators = name.substr(3).split('|');
return {
name: nameWithModificators[0],
parent: tagName,
modificators: nameWithModificators.slice(1),
locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
? [lastAttributeLocations[name]]
: null
};
});
// Expose event firing from template methods
// Handle event syntax like ```<button on:click="fire('customClickEvent')">Some link</button>```
const firedEvents = Object.keys(attrs)
.map(name => {
const value = attrs[name];
const matches = EVENT_EMIT_RE.exec(value);
if (!matches) {
return null;
}
return {
name: matches[1].substr(1, matches[1].length - 2).trim(),
parent: null,
locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name)
? [lastAttributeLocations[name]]
: null
};
})
.filter(item => item !== null);
const baseEvents = propogatedEvents.concat(firedEvents);
// Fill additional informations about events
baseEvents.forEach((event) => {
if (lastComment) {
lastComment = `/** ${lastComment} */`;
}
const comment = utils.parseComment(lastComment || '');
event.visibility = comment.visibility;
event.description = comment.description || '';
event.keywords = comment.keywords;
if (!hasOwnProperty(this.eventsEmitted, event.name)) {
this.eventsEmitted[event.name] = event;
this.parseKeywords(comment.keywords, event);
this.emit('event', event);
}
lastComment = null;
});
lastComment = null;
}
}
}
}, {
lowerCaseTags: false,
lowerCaseAttributeNames: false
});
parser.write(this.template);
parser.end();
}
parseKeywords(keywords = [], event) {
event.params = [];
keywords.forEach(({ name, description }) => {
switch (name) {
case 'arg':
case 'param':
case 'argument':
event.params.push(jsdoc.parseParamKeyword(description));
break;
case 'return':
case 'returns':
event.return = jsdoc.parseReturnKeyword(description);
break;
}
});
if (event.params.length === 0) {
delete event.params;
}
}
}
module.exports = Parser;
module.exports.SUPPORTED_FEATURES = SUPPORTED_FEATURES;