diff --git a/Gruntfile.js b/Gruntfile.js index a1f05df498..ffbc4bcca1 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -383,6 +383,11 @@ module.exports = function(grunt) { // Load release task grunt.loadTasks('tasks/release'); + // Load typescript task + grunt.registerTask('typescript', function() { + require('./tasks/typescript/task.js')(grunt); + }); + // Load the external libraries used. grunt.loadNpmTasks('grunt-contrib-compress'); grunt.loadNpmTasks('grunt-contrib-connect'); @@ -426,7 +431,7 @@ module.exports = function(grunt) { 'mochaTest' ]); grunt.registerTask('test:nobuild', ['eslint:test', 'connect', 'mocha']); - grunt.registerTask('yui', ['yuidoc:prod', 'minjson']); + grunt.registerTask('yui', ['yuidoc:prod', 'minjson', 'typescript']); grunt.registerTask('yui:test', ['yuidoc:prod', 'connect', 'mocha:yui']); grunt.registerTask('default', ['test']); grunt.registerTask('saucetest', ['connect', 'saucelabs-mocha']); diff --git a/package.json b/package.json index 0520828155..9c27e22355 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "grunt-release-it": "^1.0.1", "grunt-saucelabs": "8.6.1", "grunt-update-json": "^0.2.1", + "html2plaintext": "^1.1.1", "husky": "^0.14.3", "jscs-stylish": "^0.3.1", "karma": "^1.7.1", @@ -87,7 +88,8 @@ "mocha": "^3.2.0", "phantomjs": "^2.1.7", "prettier": "^1.7.4", - "request": "^2.81.0" + "request": "^2.81.0", + "word-wrap": "^1.2.3" }, "license": "LGPL-2.1", "dependencies": { @@ -100,6 +102,7 @@ "whatwg-fetch": "^2.0.3" }, "main": "./lib/p5.js", + "types": "./lib/p5.d.ts", "files": [ "license.txt", "lib/p5.min.js", @@ -107,7 +110,9 @@ "lib/addons/p5.sound.js", "lib/addons/p5.sound.min.js", "lib/addons/p5.dom.js", - "lib/addons/p5.dom.min.js" + "lib/addons/p5.dom.min.js", + "lib/p5.d.ts", + "lib/p5.global-mode.d.ts" ], "description": "[![Build Status](https://travis-ci.org/processing/p5.js.svg?branch=master)](https://travis-ci.org/processing/p5.js) [![npm version](https://badge.fury.io/js/p5.svg)](https://www.npmjs.com/package/p5)", "bugs": { diff --git a/src/core/core.js b/src/core/core.js index 5c364b8c58..09d442553d 100644 --- a/src/core/core.js +++ b/src/core/core.js @@ -26,13 +26,14 @@ var constants = require('./constants'); * "global" - all properties and methods are attached to the window * "instance" - all properties and methods are bound to this p5 object * - * @private + * @class p5 + * @constructor * @param {function} sketch a closure that can set optional preload(), * setup(), and/or draw() properties on the * given p5 instance - * @param {HTMLElement|boolean} [node] element to attach canvas to, if a + * @param {HTMLElement|Boolean} [node] element to attach canvas to, if a * boolean is passed in use it as sync - * @param {boolean} [sync] start synchronously (optional) + * @param {Boolean} [sync] start synchronously (optional) * @return {p5} a p5 instance */ var p5 = function(sketch, node, sync) { diff --git a/tasks/release/release-github.js b/tasks/release/release-github.js index b22efb50ec..031a070edc 100644 --- a/tasks/release/release-github.js +++ b/tasks/release/release-github.js @@ -63,6 +63,12 @@ module.exports = function(grunt) { './lib/addons/p5.sound.min.js', 'application/javascript' ], + p5ts: ['p5.d.ts', './lib/p5.d.ts', 'text/plain'], + p5globalts: [ + 'p5.global-mode.d.ts', + './lib/p5.global-mode.d.ts', + 'text/plain' + ], p5zip: ['p5.zip', './p5.zip', 'application/zip'] }; diff --git a/tasks/typescript/emit.js b/tasks/typescript/emit.js new file mode 100644 index 0000000000..861b555a1e --- /dev/null +++ b/tasks/typescript/emit.js @@ -0,0 +1,119 @@ +var fs = require('fs'); +var h2p = require('html2plaintext'); +var wrap = require('word-wrap'); + +function shortenDescription(desc) { + return wrap(h2p(desc).replace(/[\r\n]+/, ''), { + width: 50 + }); +} + +function createEmitter(filename) { + var indentLevel = 0; + var lastText = ''; + var currentSourceFile; + var fd = fs.openSync(filename, 'w'); + + var emit = function(text) { + var indentation = []; + var finalText; + + for (var i = 0; i < indentLevel; i++) { + indentation.push(' '); + } + + finalText = indentation.join('') + text + '\n'; + fs.writeSync(fd, finalText); + + lastText = text; + }; + + emit.description = function(classitem, overload) { + var desc = classitem.description; + if (!desc) { + return; + } + + function emitDescription(desc) { + shortenDescription(desc) + .split('\n') + .forEach(function(line) { + emit(' * ' + line); + }); + } + + emit.sectionBreak(); + emit('/**'); + emitDescription(desc); + emit(' *'); + if (overload) { + var alloverloads = [classitem]; + if (classitem.overloads) { + alloverloads = alloverloads.concat(classitem.overloads); + } + if (overload.params) { + overload.params.forEach(function(p) { + var arg = p.name; + var p2; + for (var i = 0; !p2 && i < alloverloads.length; i++) { + if (alloverloads[i].params) { + p2 = alloverloads[i].params.find( + p3 => p3.description && p3.name === arg + ); + if (p2) { + if (p.optional) { + arg = '[' + arg + ']'; + } + emitDescription('@param ' + arg + ' ' + p2.description); + break; + } + } + } + }); + } + if (overload.chainable) { + emitDescription('@chainable'); + } else if (overload.return && overload.return.description) { + emitDescription('@return ' + overload.return.description); + } + } + emit(' */'); + }; + + emit.setCurrentSourceFile = function(file) { + if (file !== currentSourceFile) { + currentSourceFile = file; + emit.sectionBreak(); + emit('// ' + file); + emit.sectionBreak(); + } + }; + + emit.sectionBreak = function() { + if (lastText !== '' && !/\{$/.test(lastText)) { + emit(''); + } + }; + + emit.getIndentLevel = function() { + return indentLevel; + }; + + emit.indent = function() { + indentLevel++; + }; + + emit.dedent = function() { + indentLevel--; + }; + + emit.close = function() { + fs.closeSync(fd); + }; + + emit('// This file was auto-generated. Please do not edit it.\n'); + + return emit; +} + +module.exports = createEmitter; diff --git a/tasks/typescript/generate-typescript-annotations.js b/tasks/typescript/generate-typescript-annotations.js new file mode 100644 index 0000000000..1a5e83a21a --- /dev/null +++ b/tasks/typescript/generate-typescript-annotations.js @@ -0,0 +1,508 @@ +/// @ts-check +const createEmitter = require('./emit'); + +function position(file, line) { + return file + ', line ' + line; +} + +function classitemPosition(classitem) { + return position(classitem.file, classitem.line); +} + +function overloadPosition(classitem, overload) { + return position(classitem.file, overload.line); +} + +// mod is used to make yuidocs "global". It actually just calls generate() +// This design was selected to avoid rewriting the whole file from +// https://github.com/toolness/friendly-error-fellowship/blob/2093aee2acc53f0885fcad252a170e17af19682a/experiments/typescript/generate-typescript-annotations.js +function mod(yuidocs, localFileame, globalFilename, sourcePath) { + var emit; + var constants = {}; + var missingTypes = {}; + + // http://stackoverflow.com/a/2008353/2422398 + var JS_SYMBOL_RE = /^[$A-Z_][0-9A-Z_$]*$/i; + + var P5_CLASS_RE = /^p5\.([^.]+)$/; + + var P5_ALIASES = [ + 'p5', + // These are supposedly "classes" in our docs, but they don't exist + // as objects, and their methods are all defined on p5. + 'p5.dom', + 'p5.sound' + ]; + + var EXTERNAL_TYPES = new Set([ + 'HTMLCanvasElement', + 'HTMLElement', + 'Float32Array', + 'AudioParam', + 'AudioNode', + 'GainNode', + 'DelayNode', + 'ConvolverNode', + 'Event', + 'Blob' + ]); + + var YUIDOC_TO_TYPESCRIPT_PARAM_MAP = { + Object: 'object', + Any: 'any', + Number: 'number', + Integer: 'number', + String: 'string', + Constant: 'any', + undefined: 'undefined', + Null: 'null', + Array: 'any[]', + Boolean: 'boolean', + '*': 'any', + Void: 'void', + P5: 'p5', + // When the docs don't specify what kind of function we expect, + // then we need to use the global type `Function` + Function: 'Function', + // Special ignore for hard to fix YUIDoc from p5.sound + 'Tone.Signal': 'any', + SoundObject: 'any' + }; + + function getClassitems(className) { + return yuidocs.classitems.filter(function(classitem) { + // Note that we first find items with the right class name, + // but we also check for classitem.name because + // YUIDoc includes classitems that we want to be undocumented + // just because we used block comments. + // We have other checks in place for finding missing method names + // on public methods so a missing classitem.name implies that + // the method is undocumented on purpose. + // See https://github.com/processing/p5.js/issues/1252 and + // https://github.com/processing/p5.js/pull/2301 + return classitem.class === className && classitem.name; + }); + } + + function isValidP5ClassName(className) { + return ( + (P5_CLASS_RE.test(className) && className in yuidocs.classes) || + (P5_CLASS_RE.test('p5.' + className) && + 'p5.' + className in yuidocs.classes) + ); + } + + /** + * @param {string} type + */ + function validateType(type) { + return translateType(type); + } + + function validateMethod(classitem, overload) { + var errors = []; + var paramNames = {}; + var optionalParamFound = false; + + if (!(JS_SYMBOL_RE.test(classitem.name) || classitem.is_constructor)) { + errors.push('"' + classitem.name + '" is not a valid JS symbol name'); + } + + (overload.params || []).forEach(function(param) { + if (param.optional) { + optionalParamFound = true; + } else if (optionalParamFound) { + errors.push( + 'required param "' + param.name + '" follows an ' + 'optional param' + ); + } + + if (param.name in paramNames) { + errors.push('param "' + param.name + '" is defined multiple times'); + } + paramNames[param.name] = true; + + if (!JS_SYMBOL_RE.test(param.name)) { + errors.push('param "' + param.name + '" is not a valid JS symbol name'); + } + + if (!validateType(param.type)) { + errors.push( + 'param "' + param.name + '" has invalid type: ' + param.type + ); + } + + if (param.type === 'Constant') { + var constantRe = /either\s+(?:[A-Z0-9_]+\s*,?\s*(?:or)?\s*)+/g; + var execResult = constantRe.exec(param.description); + var match; + if (execResult) { + match = execResult[0]; + } + if (classitem.name === 'endShape' && param.name === 'mode') { + match = 'CLOSE'; + } + if (match) { + var values = []; + + var reConst = /[A-Z0-9_]+/g; + var matchConst; + while ((matchConst = reConst.exec(match)) !== null) { + values.push(matchConst); + } + var paramWords = param.name + .split('.') + .pop() + .replace(/([A-Z])/g, ' $1') + .trim() + .toLowerCase() + .split(' '); + var propWords = classitem.name + .split('.') + .pop() + .replace(/([A-Z])/g, ' $1') + .trim() + .toLowerCase() + .split(' '); + + var constName; + if (paramWords.length > 1 || propWords[0] === 'create') { + constName = paramWords.join('_'); + } else if ( + propWords[propWords.length - 1] === + paramWords[paramWords.length - 1] + ) { + constName = propWords.join('_'); + } else { + constName = propWords[0] + '_' + paramWords[paramWords.length - 1]; + } + + constName = constName.toUpperCase(); + constants[constName] = values; + + param.type = constName; + } + } + }); + + if (overload.return && !validateType(overload.return.type)) { + errors.push('return has invalid type: ' + overload.return.type); + } + + return errors; + } + + /** + * + * @param {string} type + * @param {string} [defaultType] + */ + function translateType(type, defaultType) { + if (type === void 0) { + return defaultType; + } + + type = type.trim(); + + if (type === '') { + return ''; + } + + if (type.length > 2 && type.substring(type.length - 2) === '[]') { + return translateType(type.substr(0, type.length - 2), defaultType) + '[]'; + } + + var matchFunction = type.match(/Function\(([^)]*)\)/i); + if (matchFunction) { + var paramTypes = matchFunction[1].split(','); + const mappedParamTypes = paramTypes.map((t, i) => { + const paramName = 'p' + (i + 1); + const paramType = translateType(t, 'any'); + return paramName + ': ' + paramType; + }); + return '(' + mappedParamTypes.join(',') + ') => any'; + } + + var parts = type.split('|'); + if (parts.length > 1) { + return parts.map(t => translateType(t, defaultType)).join('|'); + } + + const staticallyMappedType = YUIDOC_TO_TYPESCRIPT_PARAM_MAP[type]; + if (staticallyMappedType != null) { + return staticallyMappedType; + } + + if (EXTERNAL_TYPES.has(type)) { + return type; + } + + if (isValidP5ClassName(type)) { + return type; + } + + if (constants[type]) { + return type; + } + + missingTypes[type] = true; + return defaultType; + } + + function translateParam(param) { + var name = param.name; + if (name === 'class') { + name = 'theClass'; + } + + return ( + name + + (param.optional ? '?' : '') + + ': ' + + translateType(param.type, 'any') + ); + } + + function generateClassMethod(className, classitem) { + if (classitem.overloads) { + classitem.overloads.forEach(function(overload) { + generateClassMethodWithParams(className, classitem, overload); + }); + } else { + generateClassMethodWithParams(className, classitem, classitem); + } + } + + function generateClassMethodWithParams(className, classitem, overload) { + var errors = validateMethod(classitem, overload); + var params = (overload.params || []).map(translateParam); + var returnType = overload.chainable + ? className + : overload.return ? translateType(overload.return.type, 'any') : 'void'; + var decl; + + if (classitem.is_constructor) { + decl = 'constructor(' + params.join(', ') + ')'; + } else { + decl = + (overload.static ? 'static ' : '') + + classitem.name + + '(' + + params.join(', ') + + '): ' + + returnType; + } + + if (emit.getIndentLevel() === 0) { + decl = 'declare function ' + decl + ';'; + } + + if (errors.length) { + emit.sectionBreak(); + emit( + '// TODO: Fix ' + + classitem.name + + '() errors in ' + + overloadPosition(classitem, overload) + + ':' + ); + emit('//'); + errors.forEach(function(error) { + console.log( + classitem.name + + '() ' + + overloadPosition(classitem, overload) + + ', ' + + error + ); + emit('// ' + error); + }); + emit('//'); + emit('// ' + decl); + emit(''); + } else { + emit.description(classitem, overload); + emit(decl); + } + } + + function generateClassConstructor(className) { + var classitem = yuidocs.classes[className]; + if (classitem.is_constructor) { + generateClassMethod(className, classitem); + } + } + + function generateClassProperty(className, classitem) { + if (JS_SYMBOL_RE.test(classitem.name)) { + // TODO: It seems our properties don't carry any type information, + // which is unfortunate. YUIDocs supports the @type tag on properties, + // and even encourages using it, but we don't seem to use it. + var translatedType = translateType(classitem.type, 'any'); + var defaultValue = classitem.default; + if (classitem.final && translatedType === 'string' && !defaultValue) { + defaultValue = classitem.name.toLowerCase().replace(/_/g, '-'); + } + + var decl; + if (defaultValue) { + decl = classitem.name + ': '; + if (translatedType === 'string') { + decl += "'" + defaultValue.replace(/'/g, "\\'") + "'"; + } else { + decl += defaultValue; + } + } else { + decl = classitem.name + ': ' + translatedType; + } + + emit.description(classitem); + + if (emit.getIndentLevel() === 0) { + const declarationType = classitem.final ? 'const ' : 'var '; + emit('declare ' + declarationType + decl + ';'); + } else { + const modifier = classitem.final ? 'readonly ' : ''; + emit(modifier + decl); + } + } else { + emit.sectionBreak(); + emit( + '// TODO: Property "' + + classitem.name + + '", defined in ' + + classitemPosition(classitem) + + ', is not a valid JS symbol name' + ); + emit.sectionBreak(); + } + } + + function generateClassProperties(className) { + getClassitems(className).forEach(function(classitem) { + classitem.file = classitem.file.replace(/\\/g, '/'); + emit.setCurrentSourceFile(classitem.file); + if (classitem.itemtype === 'method') { + generateClassMethod(className, classitem); + } else if (classitem.itemtype === 'property') { + generateClassProperty(className, classitem); + } else { + emit( + '// TODO: Annotate ' + + classitem.itemtype + + ' "' + + classitem.name + + '", defined in ' + + classitemPosition(classitem) + ); + } + }); + } + + function generateP5Properties(className) { + emit.sectionBreak(); + emit('// Properties from ' + className); + emit.sectionBreak(); + + generateClassConstructor(className); + generateClassProperties(className); + } + + function generateP5Subclass(className) { + var info = yuidocs.classes[className]; + var nestedClassName = className.match(P5_CLASS_RE)[1]; + + info.file = info.file.replace(/\\/g, '/'); + emit.setCurrentSourceFile(info.file); + + emit( + 'class ' + + nestedClassName + + (info.extends ? ' extends ' + info.extends : '') + + ' {' + ); + emit.indent(); + + generateClassConstructor(className); + generateClassProperties(className); + + emit.dedent(); + emit('}'); + } + + function emitConstants() { + emit('// Constants '); + Object.keys(constants).forEach(function(key) { + var values = constants[key]; + + emit('type ' + key + ' ='); + values.forEach(function(v, i) { + var str = ' typeof ' + v; + str = (i ? '|' : ' ') + str; + if (i === values.length - 1) { + str += ';'; + } + emit(' ' + str); + }); + + emit(''); + }); + } + + function generate() { + var p5Aliases = []; + var p5Subclasses = []; + + Object.keys(yuidocs.classes).forEach(function(className) { + if (P5_ALIASES.indexOf(className) !== -1) { + p5Aliases.push(className); + } else if (P5_CLASS_RE.test(className)) { + p5Subclasses.push(className); + } else { + throw new Error( + className + + ' is documented as a class but ' + + "I'm not sure how to generate a type definition for it" + ); + } + }); + + emit = createEmitter(localFileame); + + emit('declare class p5 {'); + emit.indent(); + + p5Aliases.forEach(generateP5Properties); + + emit.dedent(); + emit('}\n'); + + emit('declare namespace p5 {'); + emit.indent(); + + p5Subclasses.forEach(generateP5Subclass); + + emit.dedent(); + emit('}\n'); + + emit.close(); + + emit = createEmitter(globalFilename); + + emit('///\n'); + + p5Aliases.forEach(generateP5Properties); + + emitConstants(); + + emit.close(); + + for (var t in missingTypes) { + console.log('MISSING: ', t); + } + } + + generate(); +} + +module.exports = mod; diff --git a/tasks/typescript/task.js b/tasks/typescript/task.js new file mode 100644 index 0000000000..f5ef564a71 --- /dev/null +++ b/tasks/typescript/task.js @@ -0,0 +1,12 @@ +var generate = require('./generate-typescript-annotations'); +var path = require('path'); + +module.exports = function(grunt) { + var yuidocs = require('../../docs/reference/data.json'); + var base = path.join(__dirname, '../../lib'); + generate( + yuidocs, + path.join(base, 'p5.d.ts'), + path.join(base, 'p5.global-mode.d.ts') + ); +};