Add typescript definition generation

This add a gulp task (`gulp docs:typescript`) to automatically generate
a typescript definition for the library. This should solve the problem
of having an out of sync type definition when we change the API.

This task takes advantage of existing JSDoc parsing to generate a
temporary file which is later formatted through a mustache template to
generate the final definition. This definition is then tested by
compiling a typescript file that use it.
The generated definition is added to the `gulp zip` task in order to be
published along with the bundled library.

So 2 new dev-dependencies are added with this change: `mustache` and
`typescript` packages. Using node and mustache to generate the
definition instead of relying on existing templating system is
motivated by a better development experience, with easier debugging
possibilities... through the usage of more modern tools.

As a side note, support of "rest parameters" (when a parameter can be
present multiple times) is added to existing JSDoc parser in order to
support this pattern on typescript side (E.g. for `Color#set()` method
which accept any sequence of parameters that is supported by `Color`
constructors).
This commit is contained in:
sasensi 2018-11-21 12:18:21 +01:00 committed by Jürg Lehni
parent 0cced9788c
commit bbd65324bc
11 changed files with 1591 additions and 9 deletions

View file

@ -22,6 +22,7 @@ gulp.task('zip', ['clean:zip', 'dist'], function() {
gulp.src([ gulp.src([
'dist/paper-full*.js', 'dist/paper-full*.js',
'dist/paper-core*.js', 'dist/paper-core*.js',
'dist/index.d.ts',
'dist/node/**/*', 'dist/node/**/*',
'LICENSE.txt', 'LICENSE.txt',
'examples/**/*', 'examples/**/*',

View file

@ -14,14 +14,15 @@ var gulp = require('gulp'),
del = require('del'), del = require('del'),
rename = require('gulp-rename'), rename = require('gulp-rename'),
shell = require('gulp-shell'), shell = require('gulp-shell'),
options = require('../utils/options.js'); options = require('../utils/options.js'),
run = require('run-sequence');
var docOptions = { var docOptions = {
local: 'docs', // Generates the offline docs local: 'docs', // Generates the offline docs
server: 'serverdocs' // Generates the website templates for the online docs server: 'serverdocs' // Generates the website templates for the online docs
}; };
gulp.task('docs', ['docs:local', 'build:full'], function() { gulp.task('docs', ['docs:local', 'docs:typescript', 'build:full'], function() {
return gulp.src('dist/paper-full.js') return gulp.src('dist/paper-full.js')
.pipe(rename({ basename: 'paper' })) .pipe(rename({ basename: 'paper' }))
.pipe(gulp.dest('dist/docs/assets/js/')); .pipe(gulp.dest('dist/docs/assets/js/'));
@ -32,15 +33,57 @@ Object.keys(docOptions).forEach(function(name) {
var mode = docOptions[name]; var mode = docOptions[name];
return gulp.src('src') return gulp.src('src')
.pipe(shell( .pipe(shell(
['java -cp jsrun.jar:lib/* JsRun app/run.js', [
' -c=conf/', name, '.conf ', 'java -cp jsrun.jar:lib/* JsRun app/run.js',
' -D="renderMode:', mode, '" ', ' -c=conf/', name, '.conf ',
' -D="version:', options.version, '"'].join(''), ' -D="renderMode:', mode, '" ',
' -D="version:', options.version, '"'
].join(''),
{ cwd: 'gulp/jsdoc' }) { cwd: 'gulp/jsdoc' })
) );
}); });
gulp.task('clean:docs:' + name, function() { gulp.task('clean:docs:' + name, function() {
return del([ 'dist/' + docOptions[name] + '/**' ]); return del(['dist/' + docOptions[name] + '/**']);
}); });
}); });
// The goal of the typescript task is to automatically generate a type
// definition for the library.
gulp.task('docs:typescript', function() {
return run(
'docs:typescript:clean:before',
'docs:typescript:build',
'docs:typescript:clean:after'
);
});
// First clean eventually existing type definition...
gulp.task('docs:typescript:clean:before', function() {
return del('dist/index.d.ts');
});
// ...then build the definition...
gulp.task('docs:typescript:build', function() {
// First parse JSDoc comments and store parsed data in a temporary file...
return gulp.src('src')
.pipe(shell(
[
'java -cp jsrun.jar:lib/* JsRun app/run.js',
' -c=conf/typescript.conf ',
' -D="file:../../gulp/typescript/typescript-definition-data.json"',
' -D="version:', options.version, '"',
' -D="date:', options.date, '"'
].join(''),
{ cwd: 'gulp/jsdoc' })
)
// ...then generate definition from parsed data...
.pipe(shell('node gulp/typescript/typescript-definition-generator.js'))
// ...finally test the definition by compiling a typescript file.
.pipe(shell('node node_modules/typescript/bin/tsc gulp/typescript/typescript-definition-test.ts'));
});
// ...finally remove all unneeded temporary files that were used for building.
gulp.task('docs:typescript:clean:after', function() {
return del([
'gulp/typescript/typescript-definition-data.json',
'gulp/typescript/typescript-definition-test.js'
]);
});

View file

@ -0,0 +1,317 @@
/**
* This script generates a type definition by taking JSDoc roughly parsed data,
* formatting it and passing it to a mustache template.
*/
const fs = require('fs');
const mustache = require('mustache');
// Retrieve JSDoc data.
const data = JSON.parse(fs.readFileSync(__dirname + '/typescript-definition-data.json', 'utf8'));
const classes = data.classes;
let globals = data.global.properties;
// Format classes.
classes.forEach(cls => {
// Format class.
// Store name as `className` and not simply `name`, to avoid name conflict
// in static constructors block.
cls.className = cls._name;
// Store closest parent if there is one.
cls.extends = cls.inheritsFrom && cls.inheritsFrom.length > 0
? cls.inheritsFrom[0]
: null;
// Store comment using class tag as description.
cls.comment = formatComment(cls.comment, 'class');
// Build a filter for deprecated or inherited methods or properties.
const filter = _ => !_.deprecated && _.memberOf == cls.alias && !_.isNamespace;
// Format properties.
cls.properties = cls.properties
.filter(filter)
.map(_ => ({
name: _._name,
type: formatType(_.type),
static: formatStatic(_.isStatic),
readOnly: formatReadOnly(_.readOnly),
comment: formatComment(_.comment)
}));
// Format methods.
const methods = cls.methods
.filter(filter)
.map(_ => {
const name = formatMethodName(_._name);
const isStaticConstructor = _.isStatic && _.isConstructor;
return {
name: name,
// Constructors don't need return type.
type: !_.isConstructor
? formatType(getMethodReturnType(_), true)
: '',
static: formatStatic(_.isStatic),
// This flag is only used below to filter methods.
isStaticConstructor: isStaticConstructor,
comment: formatComment(_.comment, 'desc', _.isConstructor),
params: _._params
? _._params
// Filter internal parameters (starting with underscore).
.filter(_ => !/^_/.test(_.name))
.map(_ => formatParameter(_, isStaticConstructor && cls))
.join(', ')
: ''
};
})
.sort(sortMethods);
// Divide methods in 2 parts: static constructors and other. Because static
// constructors need a special syntax in type definition.
cls.methods = [];
cls.staticConstructors = [];
methods.forEach(method => {
if (method.isStaticConstructor) {
// Group static constructors by method name.
let staticConstructors = cls.staticConstructors.find(_ => _.name === method.name);
if (!staticConstructors) {
staticConstructors = {
name: method.name,
constructors: []
};
cls.staticConstructors.push(staticConstructors);
}
staticConstructors.constructors.push(method);
} else {
cls.methods.push(method);
}
});
// Store a conveniance flag to check whether class has static constructors.
cls.hasStaticConstructors = cls.staticConstructors.length > 0;
});
// Format global vriables.
globals = globals
// Filter global variables that make no sense in type definition.
.filter(_ => !/^on/.test(_._name) && _._name !== 'paper')
.map(_ => ({
name: _._name,
type: formatType(_.type),
comment: formatComment(_.comment)
}));
// Format data trough a mustache template.
// Prepare data for the template.
const context = {
classes: classes,
globals: globals,
version: data.version,
date: data.date,
// {{#doc}} blocks are used in template to automatically generate a JSDoc
// comment with a custom indent.
doc: () => formatJSDoc
};
// Retrieve template content.
const template = fs.readFileSync(__dirname + '/typescript-definition-template.mustache', 'utf8');
// Render template.
const output = mustache.render(template, context);
// Write output in a file.
fs.writeFileSync(__dirname + '/../../dist/index.d.ts', output, 'utf8');
//
// METHODS
//
function formatReadOnly(isReadOnly) {
return isReadOnly ? 'readonly ' : null;
}
function formatStatic(isStatic) {
return isStatic ? 'static ' : null;
}
function formatType(type, isMethodReturnType, staticConstructorClass) {
return ': ' + parseType(type, isMethodReturnType, staticConstructorClass);
}
function parseType(type, isMethodReturnType, staticConstructorClass) {
// Always return a type even if input type is empty. In that case, return
// `void` for method return type and `any` for the rest.
if (!type) {
return isMethodReturnType ? 'void' : 'any';
}
if (type === '*') {
return 'any';
}
// Prefer `any[]` over `Array<any>` to be more consistent with other types.
if (type === 'Array') {
return 'any[]';
}
// Handle multiple types possibility by splitting on `|` then re-joining
// back parsed types.
return type.split('|').map(type => {
// Handle rest parameter pattern: `...Type` => `Type[]`
const matches = type.match(/^\.\.\.(.+)$/);
if (matches) {
return parseType(matches[1]) + '[]';
}
// Get type without array suffix `[]` for easier matching.
const singleType = type.replace(/(\[\])+$/, '');
// Handle eventual type conflict in static constructors block. For
// example, in `Path.Rectangle(rectangle: Rectangle)` method,
// `rectangle` parameter type must be mapped to `paper.Rectangle` as it
// is declared inside a `Path` namespace and would otherwise be wrongly
// assumed as being the type of `Path.Rectangle` class.
if (staticConstructorClass && staticConstructorClass.methods.find(_ => _.isStatic && _.isConstructor && formatMethodName(_._name) === singleType)
) {
return 'paper.' + type;
}
// Convert primitive types to their lowercase equivalent to suit
// typescript best practices.
return ['Number', 'String', 'Boolean', 'Object'].indexOf(singleType) >= 0
? type.toLowerCase()
: type;
}).join(' | ');
}
function formatMethodName(methodName) {
// Overloaded methods were parsed as `method^0`, `method^1`... here, we
// turn them back to `method` as typescript allow overloading.
methodName = methodName.replace(/\^[0-9]+$/, '');
// Real contructors are called `initialize` in the library.
methodName = methodName.replace(/^initialize$/, 'constructor');
return methodName;
}
function formatParameter(_, staticConstructorClass) {
let content = '';
// Handle rest parameter pattern `...Type`. Parameter name needs to be
// prefixed with `...` as in ES6. E.g. `...parameter: type[]`.
if (_.type.match(/^\.\.\.(.+)$/)) {
content += '...';
}
content += formatParameterName(_.name);
// Optional parameters are formatted as: `parameter?: type`.
if (_.isOptional) {
content += '?';
}
content += formatType(_.type, false, staticConstructorClass);
return content;
}
function formatParameterName(parameterName) {
// Avoid usage of reserved keyword as parameter name.
// E.g. `function` => `callback`.
if (parameterName === 'function') {
return 'callback';
}
return parameterName;
}
function formatComment(comment, descriptionTagName = 'desc', skipReturn = false) {
const tags = comment.tags;
let content = '';
// Retrieve description tag.
const descriptionTag = tags.find(_ => _.title === descriptionTagName);
if (descriptionTag) {
// Don't display group titles.
content += descriptionTag.desc.replace(/\{@grouptitle .+?\}/g, '').trim();
}
// Preserve some of the JSDoc tags that can be usefull even in type
// definition. Format their values to make sure that only informations
// that make sense are kept. E.g. method parameters types are already
// provided in the signature...
content += formatCommentTags(tags, 'see');
content += formatCommentTags(tags, 'option');
content += formatCommentTags(tags, 'param', _ => _.name + ' - ' + _.desc);
if (!skipReturn) {
content += formatCommentTags(tags, 'return', _ => _.desc.trim().replace(/^\{|\}$/g, '').replace(/@([a-zA-Z]+)/, '$1'));
}
// Make sure links are followable (e.g. by IDEs) by removing parameters.
// {@link Class#method(param)} => {@link Class#method}
content = content.replace(/(\{@link [^\}]+?)\(.*?\)(\})/g, '$1$2');
content = content.trim();
return content;
}
function formatCommentTags(tags, tagName, formatter) {
let content = '';
// Default formatter simply outputs description.
formatter = formatter || (_ => _.desc);
// Only keep tags that have a description.
tags = tags.filter(_ => _.desc && _.title === tagName);
if (tags.length > 0) {
content += '\n';
// Display tag as it was in original JSDoc, followed by formatted value.
tags.forEach(_ => content += '\n@' + tagName + ' ' + formatter(_));
}
return content;
}
/**
* This outputs a JSDoc comment indented at the given offset and including the
* parsed comment for current mustache block.
* @param {Number} offset the number of spaces to use for indentation
* @param {Function} render the mustache render method
* @return {string} the formatted JSDoc comment
*/
function formatJSDoc(offset, render) {
// First render current block comment. Use `{{&}}` syntax to make sure
// special characters are not escaped.
let content = render('{{&comment}}');
if (!content) {
return '';
}
// Build indentation.
offset = parseInt(offset);
if (offset > 0) {
offset++;
}
const indentation = new Array(offset).join(' ');
// Prefix each line with the indentation.
content = content.split('\n')
.map(_ => indentation + ' * ' + _)
.join('\n');
// Wrap content in JSDoc delimiters: `/**` and `*/`.
return '/** \n' + content + '\n' + indentation + ' */';
}
function getMethodReturnType(_) {
return _.returnType || _.returns.length > 0 && _.returns[0].type;
}
function sortMethods(methodA, methodB) {
// This places constructors before other methods as it is a best practice.
// This also place constructors with only one object parameter after other
// constructors to avoid type inference errors due to constructors
// overloading order. E.g. if `constructor(object: object)` is defined
// before `constructor(instance: Class)`, calling `constructor(instance)`
// will always be mapped to `contructor(object: object)`, since everything
// is an object in JavaScript. This is problematic because most of Paper.js
// classes have a constructor accepting an object.
const aIsContructor = methodA.name === 'constructor';
const bIsContructor = methodB.name === 'constructor';
if (aIsContructor && bIsContructor) {
if (methodA.params === 'object: object') {
return 1;
}
if (methodB.params === 'object: object') {
return -1;
}
}
else if (aIsContructor) {
return -1;
}
else if (bIsContructor) {
return 1;
}
return 0;
}

View file

@ -0,0 +1,59 @@
/*!
* Paper.js v{{version}} - The Swiss Army Knife of Vector Graphics Scripting.
* http://paperjs.org/
*
* Copyright (c) 2011 - 2016, Juerg Lehni & Jonathan Puckey
* http://scratchdisk.com/ & https://puckey.studio/
*
* Distributed under the MIT license. See LICENSE file for details.
*
* All rights reserved.
*
* Date: {{date}}
*
* This is an auto-generated type definition.
*/
declare module paper {
{{#globals}}
{{#doc}}4{{/doc}}
let {{name}}{{type}}
{{/globals}}
{{#classes}}
{{#doc}}4{{/doc}}
class {{className}} {{#extends}}extends {{extends}}{{/extends}} {
{{#properties}}
{{#doc}}8{{/doc}}
{{static}}{{readOnly}}{{name}}{{type}}
{{/properties}}
{{#methods}}
{{#doc}}8{{/doc}}
{{static}}{{name}}({{params}}){{type}}
{{/methods}}
}
{{#hasStaticConstructors}}
namespace {{className}} {
{{#staticConstructors}}
class {{name}} extends {{className}} {
{{#constructors}}
{{#doc}}12{{/doc}}
constructor({{params}})
{{/constructors}}
}
{{/staticConstructors}}
}
{{/hasStaticConstructors}}
{{/classes}}
}
declare module 'paper' {
export = paper
}

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,7 @@
"jshint-summary": "^0.4.0", "jshint-summary": "^0.4.0",
"merge-stream": "^1.0.0", "merge-stream": "^1.0.0",
"minimist": "^1.2.0", "minimist": "^1.2.0",
"mustache": "^3.0.1",
"prepro": "^2.4.0", "prepro": "^2.4.0",
"qunitjs": "^1.23.0", "qunitjs": "^1.23.0",
"require-dir": "^0.3.0", "require-dir": "^0.3.0",
@ -59,7 +60,8 @@
"run-sequence": "^1.2.2", "run-sequence": "^1.2.2",
"source-map-support": "^0.4.0", "source-map-support": "^0.4.0",
"stats.js": "0.16.0", "stats.js": "0.16.0",
"straps": "^3.0.1" "straps": "^3.0.1",
"typescript": "^3.1.6"
}, },
"browser": { "browser": {
"canvas": false, "canvas": false,

View file

@ -104,6 +104,7 @@ var Matrix = Base.extend(/** @lends Matrix# */{
* also work for calls of `set()`. * also work for calls of `set()`.
* *
* @function * @function
* @param {...*} value
* @return {Point} * @return {Point}
*/ */
set: '#initialize', set: '#initialize',

View file

@ -170,6 +170,7 @@ var Point = Base.extend(/** @lends Point# */{
* for calls of `set()`. * for calls of `set()`.
* *
* @function * @function
* @param {...*} value
* @return {Point} * @return {Point}
*/ */
set: '#initialize', set: '#initialize',

View file

@ -159,6 +159,7 @@ var Rectangle = Base.extend(/** @lends Rectangle# */{
* constructors also work for calls of `set()`. * constructors also work for calls of `set()`.
* *
* @function * @function
* @param {...*} value
* @return {Rectangle} * @return {Rectangle}
*/ */
set: '#initialize', set: '#initialize',

View file

@ -130,6 +130,7 @@ var Size = Base.extend(/** @lends Size# */{
* for calls of `set()`. * for calls of `set()`.
* *
* @function * @function
* @param {...*} value
* @return {Size} * @return {Size}
*/ */
set: '#initialize', set: '#initialize',

View file

@ -691,6 +691,7 @@ var Color = Base.extend(new function() {
* constructors also work for calls of `set()`. * constructors also work for calls of `set()`.
* *
* @function * @function
* @param {...*} value
* @return {Color} * @return {Color}
*/ */
set: '#initialize', set: '#initialize',