diff --git a/.circleci/config.yml b/.circleci/config.yml index e2c130225d..f5413f4d1c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -50,3 +50,11 @@ jobs: - store_artifacts: path: build/apidoc destination: apidoc + + - run: + name: Build Website + command: npm run build-site + + - store_artifacts: + path: public + destination: website diff --git a/.gitignore b/.gitignore index 6df267a88f..7f117a4ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /coverage/ /dist/ node_modules/ +/.cache/ +/public/ diff --git a/config/jsdoc/api-info/conf.json b/config/jsdoc/api-info/conf.json new file mode 100644 index 0000000000..7dec9899d4 --- /dev/null +++ b/config/jsdoc/api-info/conf.json @@ -0,0 +1,23 @@ +{ + "opts": { + "recurse": true, + "template": "node_modules/jsdoc-json" + }, + "tags": { + "allowUnknownTags": true + }, + "source": { + "includePattern": "\\.js$", + "include": [ + "src/ol" + ] + }, + "plugins": [ + "jsdoc-plugin-typescript", + "config/jsdoc/api-info/plugins/api", + "config/jsdoc/api-info/plugins/module" + ], + "typescript": { + "moduleRoot": "src" + } +} diff --git a/config/jsdoc/api-info/plugins/api.js b/config/jsdoc/api-info/plugins/api.js new file mode 100644 index 0000000000..fef1012aaa --- /dev/null +++ b/config/jsdoc/api-info/plugins/api.js @@ -0,0 +1,15 @@ + +/** + * Handle the api annotation. + * @param {Object} dictionary The tag dictionary. + */ +exports.defineTags = dictionary => { + + dictionary.defineTag('api', { + onTagged: (doclet, tag) => { + doclet.api = true; + } + }); + +}; + diff --git a/config/jsdoc/api-info/plugins/module.js b/config/jsdoc/api-info/plugins/module.js new file mode 100644 index 0000000000..e714c31a86 --- /dev/null +++ b/config/jsdoc/api-info/plugins/module.js @@ -0,0 +1,170 @@ +/** + * This plugin adds an `exportMap` property to @module doclets. Each export map + * is an object with properties named like the local identifier and values named + * like the exported identifier. + * + * For example, the code below + * + * export {foo as bar}; + * + * would be a map like `{foo: 'bar'}`. + * + * In the case of an export declaration with a source, the export identifier is + * prefixed by the source. For example, this code + * + * export {foo as bar} from 'ol/bam'; + * + * would be a map like `{'ol/bam foo': 'bar'}`. + * + * If a default export is a literal or object expression, the local name will be + * an empty string. For example + * + * export default {foo: 'bar'}; + * + * would be a map like `{'': 'default'}`. + */ +const assert = require('assert'); +const path = require('path'); + + +/** + * A lookup of export maps per source filepath. + */ +const exportMapLookup = {}; + +function loc(filepath, node) { + return `${filepath}:${node.loc.start.line}`; +} + +function nameFromChildIdentifier(filepath, node) { + assert.ok(node.id, `expected identifer in ${loc(filepath, node)}`); + assert.strictEqual(node.id.type, 'Identifier', `expected identifer in ${loc(filepath, node)}`); + return node.id.name; +} + +function handleExportNamedDeclaration(filepath, node) { + if (!(filepath in exportMapLookup)) { + exportMapLookup[filepath] = {}; + } + const exportMap = exportMapLookup[filepath]; + + const declaration = node.declaration; + if (declaration) { + // `export class Foo{}` or `export function foo() {}` + if (declaration.type === 'ClassDeclaration' || declaration.type === 'FunctionDeclaration') { + const name = nameFromChildIdentifier(filepath, declaration); + exportMap[name] = name; + return; + } + + // `export const foo = 'bar', bam = 42` + if (declaration.type === 'VariableDeclaration') { + const declarations = declaration.declarations; + assert.ok(declarations.length > 0, `expected variable declarations in ${loc(filepath, declaration)}`); + for (const declarator of declarations) { + assert.strictEqual(declarator.type, 'VariableDeclarator', `unexpected "${declarator.type}" in ${loc(filepath, declarator)}`); + const name = nameFromChildIdentifier(filepath, declarator); + exportMap[name] = name; + } + return; + } + + throw new Error(`Unexpected named export "${declaration.type}" in ${loc(filepath, declaration)}`); + } + + let prefix = ''; + const source = node.source; + if (source) { + // `export foo from 'bar'` + assert.strictEqual(source.type, 'Literal', `unexpected export source "${source.type}" in ${loc(filepath, source)}`); + prefix = `${source.value} `; + } + + const specifiers = node.specifiers; + assert.ok(specifiers.length > 0, `expected export specifiers in ${loc(filepath, node)}`); + // `export {foo, bar}` or `export {default as Foo} from 'bar'` + for (const specifier of specifiers) { + assert.strictEqual(specifier.type, 'ExportSpecifier', `unexpected export specifier in ${loc(filepath, specifier)}`); + + const local = specifier.local; + assert.strictEqual(local.type, 'Identifier', `unexpected local specifier "${local.type} in ${loc(filepath, local)}`); + + const exported = specifier.exported; + assert.strictEqual(local.type, 'Identifier', `unexpected exported specifier "${exported.type} in ${loc(filepath, exported)}`); + + exportMap[prefix + local.name] = exported.name; + } +} + +function handleDefaultDeclaration(filepath, node) { + if (!(filepath in exportMapLookup)) { + exportMapLookup[filepath] = {}; + } + const exportMap = exportMapLookup[filepath]; + + const declaration = node.declaration; + if (declaration) { + // `export default class Foo{}` or `export default function foo () {}` + if (declaration.type === 'ClassDeclaration' || declaration.type === 'FunctionDeclaration') { + const name = nameFromChildIdentifier(filepath, declaration); + exportMap[name] = 'default'; + return; + } + + // `export default foo` + if (declaration.type === 'Identifier') { + exportMap[declaration.name] = 'default'; + return; + } + + // `export default {foo: 'bar'}` or `export default 42` + if (declaration.type === 'ObjectExpression' || declaration.type === 'Literal') { + exportMap[''] = 'default'; + return; + } + } + + throw new Error(`Unexpected default export "${declaration.type}" in ${loc(filepath, declaration)}`); +} + +exports.astNodeVisitor = { + visitNode: (node, event, parser, filepath) => { + if (node.type === 'ExportNamedDeclaration') { + return handleExportNamedDeclaration(filepath, node); + } + + if (node.type === 'ExportDefaultDeclaration') { + return handleDefaultDeclaration(filepath, node); + } + } +}; + +const moduleLookup = {}; + +exports.handlers = { + + // create a lookup of @module doclets + newDoclet: event => { + const doclet = event.doclet; + if (doclet.kind === 'module') { + const filepath = path.join(doclet.meta.path, doclet.meta.filename); + + assert.ok(!(filepath in moduleLookup), `duplicate @module doc in ${filepath}`); + moduleLookup[filepath] = doclet; + } + }, + + // assign the `exportMap` property to @module doclets + parseComplete: event => { + for (const filepath in moduleLookup) { + assert.ok(filepath in exportMapLookup, `missing ${filepath} in export map lookup`); + moduleLookup[filepath].exportMap = exportMapLookup[filepath]; + } + + // make sure there was a @module doclet for each export map + for (const filepath in exportMapLookup) { + assert.ok(filepath in moduleLookup, `missing @module doclet in ${filepath}`); + } + } + +}; diff --git a/gatsby-config.js b/gatsby-config.js new file mode 100644 index 0000000000..4a3d0ed079 --- /dev/null +++ b/gatsby-config.js @@ -0,0 +1,11 @@ +module.exports = { + plugins: [ + 'gatsby-plugin-emotion', + { + resolve: 'gatsby-plugin-typography', + options: { + pathToConfigModule: 'site/util/typography' + } + } + ] +}; diff --git a/gatsby-node.js b/gatsby-node.js new file mode 100644 index 0000000000..70dbf1543f --- /dev/null +++ b/gatsby-node.js @@ -0,0 +1,23 @@ + +function getDocs() { + // TODO: build if not present + const info = require('./build/api-info.json'); + + return info.docs.filter(doc => !doc.ignore && (doc.api || doc.kind === 'module')); +} + +function createPages({actions: {createPage}}) { + createPage({ + path: '/api', + component: require.resolve('./site/pages/API.js'), + context: {docs: getDocs()} + }); + + createPage({ + path: '/info', + component: require.resolve('./site/pages/Info.js'), + context: {docs: getDocs()} + }); +} + +exports.createPages = createPages; diff --git a/package.json b/package.json index 332aafd0f7..93e8c7114c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "copy-css": "shx cp src/ol/ol.css build/ol/ol.css", "transpile": "shx rm -rf build/ol && shx mkdir -p build/ol && shx cp -rf src/ol build/ol/src && node tasks/serialize-workers && tsc --project config/tsconfig-build.json", "typecheck": "tsc --pretty", - "apidoc": "jsdoc -R config/jsdoc/api/index.md -c config/jsdoc/api/conf.json -P package.json -d build/apidoc" + "apidoc": "jsdoc -R config/jsdoc/api/index.md -c config/jsdoc/api/conf.json -P package.json -d build/apidoc", + "api-info": "jsdoc --configure config/jsdoc/api-info/conf.json --destination build/api-info.json", + "serve-site": "gatsby develop", + "build-site": "npm run api-info && gatsby build" }, "main": "index.js", "repository": { @@ -43,6 +46,8 @@ "devDependencies": { "@babel/core": "^7.4.0", "@babel/preset-env": "^7.4.4", + "@emotion/core": "^10.0.10", + "@emotion/styled": "^10.0.11", "@openlayers/eslint-plugin": "^4.0.0-beta.2", "@types/arcgis-rest-api": "^10.4.4", "@types/geojson": "^7946.0.7", @@ -57,9 +62,14 @@ "coveralls": "3.0.3", "eslint": "^5.16.0", "eslint-config-openlayers": "^11.0.0", + "eslint-config-tschaub": "^13.1.0", + "eslint-plugin-react": "^7.13.0", "expect.js": "0.3.1", "front-matter": "^3.0.2", "fs-extra": "^8.0.0", + "gatsby": "^2.4.3", + "gatsby-plugin-emotion": "^4.0.6", + "gatsby-plugin-typography": "^2.2.13", "glob": "^7.1.4", "globby": "^9.2.0", "handlebars": "4.1.2", @@ -68,6 +78,7 @@ "istanbul-instrumenter-loader": "^3.0.1", "jquery": "3.4.1", "jsdoc": "3.6.2", + "jsdoc-json": "^2.0.2", "jsdoc-plugin-typescript": "^2.0.1", "karma": "^4.1.0", "karma-chrome-launcher": "2.2.0", @@ -84,7 +95,13 @@ "pixelmatch": "^4.0.2", "pngjs": "^3.4.0", "proj4": "2.5.0", + "prop-types": "^15.7.2", "puppeteer": "~1.16.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-markdown": "^4.0.8", + "react-syntax-highlighter": "^10.2.1", + "react-typography": "^0.16.19", "rollup": "^1.12.0", "rollup-plugin-babel": "^4.3.2", "rollup-plugin-commonjs": "^10.0.0", @@ -95,6 +112,8 @@ "sinon": "^7.3.2", "terser-webpack-plugin": "^1.2.3", "typescript": "^3.4.5", + "typography": "^0.16.19", + "typography-theme-alton": "^0.16.19", "url-polyfill": "^1.1.5", "walk": "^2.3.9", "webpack": "4.31.0", diff --git a/site/.eslintrc.json b/site/.eslintrc.json new file mode 100644 index 0000000000..2a8c7093a6 --- /dev/null +++ b/site/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "tschaub/react" +} diff --git a/site/components/Class.js b/site/components/Class.js new file mode 100644 index 0000000000..57640c4673 --- /dev/null +++ b/site/components/Class.js @@ -0,0 +1,29 @@ +import {object} from 'prop-types'; +import React from 'react'; +import Markdown from 'react-markdown'; +import Code from './Code'; + +function Class({cls, module}) { + const exportedName = module.getExportedName(cls.name); + let importCode; + if (exportedName === 'default') { + importCode = `import ${cls.name} from '${module.id}';`; + } else { + importCode = `import {${exportedName}} from '${module.id}';`; + } + + return ( +
+

{cls.name}

+ + +
+ ); +} + +Class.propTypes = { + cls: object.isRequired, + module: object.isRequired +}; + +export default Class; diff --git a/site/components/Code.js b/site/components/Code.js new file mode 100644 index 0000000000..c6d3f9c20b --- /dev/null +++ b/site/components/Code.js @@ -0,0 +1,26 @@ +import React, {PureComponent} from 'react'; +import {string} from 'prop-types'; +import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'; +import {coy} from 'react-syntax-highlighter/dist/styles/prism'; + +class Code extends PureComponent { + render() { + let language = this.props.language; + if (!language) { + language = 'js'; + } + + return ( + + {this.props.value} + + ); + } +} + +Code.propTypes = { + value: string.isRequired, + language: string +}; + +export default Code; diff --git a/site/components/Func.js b/site/components/Func.js new file mode 100644 index 0000000000..0674fb56be --- /dev/null +++ b/site/components/Func.js @@ -0,0 +1,29 @@ +import {object} from 'prop-types'; +import React from 'react'; +import Markdown from 'react-markdown'; +import Code from './Code'; + +function Func({func, module}) { + const exportedName = module.getExportedName(func.name); + let importCode; + if (exportedName === 'default') { + importCode = `import ${func.name} from '${module.id}';`; + } else { + importCode = `import {${exportedName}} from '${module.id}';`; + } + + return ( +
+

{func.name}

+ + +
+ ); +} + +Func.propTypes = { + func: object.isRequired, + module: object.isRequired +}; + +export default Func; diff --git a/site/components/Module.js b/site/components/Module.js new file mode 100644 index 0000000000..4daf9f1d49 --- /dev/null +++ b/site/components/Module.js @@ -0,0 +1,25 @@ +import {object} from 'prop-types'; +import React from 'react'; +import Class from './Class'; +import Func from './Func'; + +function Module({module}) { + return ( +
+
+

{module.id}

+ {module.classes.map(cls => ( + + ))} + {module.functions.map(func => ( + + ))} +
+ ); +} + +Module.propTypes = { + module: object.isRequired +}; + +export default Module; diff --git a/site/components/layout.js b/site/components/layout.js new file mode 100644 index 0000000000..70d4589436 --- /dev/null +++ b/site/components/layout.js @@ -0,0 +1,8 @@ +import styled from '@emotion/styled'; +import {baseSpacingPx} from '../util/typography'; + +export const Page = styled.div({ + display: 'flex', + flexDirection: 'column', + margin: 2 * baseSpacingPx +}); diff --git a/site/pages/API.js b/site/pages/API.js new file mode 100644 index 0000000000..720075b398 --- /dev/null +++ b/site/pages/API.js @@ -0,0 +1,26 @@ +import {object} from 'prop-types'; +import React from 'react'; +import {Page} from '../components/layout'; +import Module from '../components/Module'; +import {getHelper} from '../util/api'; + +function API({pageContext: {docs}}) { + const helper = getHelper(docs); + + return ( + +

API

+ {helper.modules + .filter(module => module.visible) + .map(module => ( + + ))} +
+ ); +} + +API.propTypes = { + pageContext: object.isRequired +}; + +export default API; diff --git a/site/pages/Info.js b/site/pages/Info.js new file mode 100644 index 0000000000..bd76d7c7b6 --- /dev/null +++ b/site/pages/Info.js @@ -0,0 +1,33 @@ +import {object} from 'prop-types'; +import React from 'react'; +import {Page} from '../components/layout'; + +function Info({pageContext: {docs}}) { + return ( + +

API

+ + + + + + + + {docs.map(doc => ( + + + + + + ))} + +
kindlongnamememberof
{doc.kind}{doc.longname}{doc.memberof}
+
+ ); +} + +Info.propTypes = { + pageContext: object.isRequired +}; + +export default Info; diff --git a/site/util/api.js b/site/util/api.js new file mode 100644 index 0000000000..ffb128ec99 --- /dev/null +++ b/site/util/api.js @@ -0,0 +1,179 @@ +class FunctionDoc { + constructor(doc) { + this.name = doc.name; + this.doc = doc; + } +} + +class ClassDoc { + constructor(name) { + this.name = name; + } + + processDoc(doc) { + if (doc.kind === 'class') { + this.doc = doc; + } + } +} + +class ModuleDoc { + constructor(id) { + this.id = id; + + this.classLookup = {}; + this.classes = []; + + this.functionLookup = {}; + this.functions = []; + } + + processDoc(doc) { + if (doc.kind === 'module') { + this.doc = doc; + return; + } + + if (doc.kind === 'class') { + const name = nameFromLongname(doc.longname); + if (!(name in this.classLookup)) { + const cls = new ClassDoc(name); + this.classLookup[name] = cls; + this.classes.push(cls); + } + + this.classLookup[name].processDoc(doc); + return; + } + + if (doc.kind === 'function') { + if (nameFromLongname(doc.memberof)) { + // belongs to a class or other + return; + } + + if (doc.name in this.functionLookup) { + throw new Error(`Duplicate function ${doc.name} in ${this.id}`); + } + + const func = new FunctionDoc(doc); + this.functionLookup[doc.name] = func; + this.functions.push(func); + return; + } + } + + finalize() { + this.classes.sort(byName); + this.functions.sort(byName); + this.visible = this.classes.length > 0 || this.functions.length > 0; + } + + getExportedName(localName) { + if (!this.doc || !this.doc.exportMap) { + throw new Error(`Expected to find export map in module doc: ${this.id}`); + } + + if (!(localName in this.doc.exportMap)) { + throw new Error( + `No local name "${localName}" in export map for module: ${this.id}` + ); + } + + return this.doc.exportMap[localName]; + } +} + +const longnameRE = /^module:(?.*?)([~\.](?\w+)(#(?\w+))?(:(?\w+))?)?$/; + +function moduleIDFromLongname(longname) { + const match = longname.match(longnameRE); + if (!match) { + throw new Error(`could not match module id in longname: ${longname}`); + } + return match.groups.module; +} + +function nameFromLongname(longname) { + const match = longname.match(longnameRE); + if (!match) { + throw new Error(`could not match name in longname: ${longname}`); + } + return match.groups.name; +} + +function memberFromLongname(longname) { + const match = longname.match(longnameRE); + if (!match) { + throw new Error(`could not match member in longname: ${longname}`); + } + return match.groups.member; +} + +function byName(a, b) { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; +} + +function byModuleId(a, b) { + const aParts = a.id.split('/'); + const bParts = b.id.split('/'); + const len = Math.max(aParts.length, bParts.length); + for (let i = 0; i < len; ++i) { + if (aParts[i] && bParts[i]) { + if (aParts[i] < bParts[i]) { + return -1; + } + if (aParts[i] > bParts[i]) { + return 1; + } + } else if (!aParts[i]) { + return -1; + } else { + return 1; + } + } + return 0; +} + +class DocHelper { + constructor(docs) { + this.moduleLookup = {}; + this.modules = []; + + docs.forEach(doc => { + const moduleID = moduleIDFromLongname(doc.longname); + if (!(moduleID in this.moduleLookup)) { + const module = new ModuleDoc(moduleID); + this.moduleLookup[moduleID] = module; + this.modules.push(module); + } + + const module = this.moduleLookup[moduleID]; + module.processDoc(doc); + }); + + this.modules.sort(byModuleId); + this.modules.forEach(module => module.finalize()); + } +} + +let cachedDocs; +let cachedHelper; + +export function getHelper(docs) { + if (docs !== cachedDocs) { + if (cachedDocs) { + console.warn('creating new doc helper'); // eslint-disable-line + } + cachedHelper = new DocHelper(docs); + cachedDocs = docs; + } + + return cachedHelper; +} diff --git a/site/util/typography.js b/site/util/typography.js new file mode 100644 index 0000000000..ba66ee6c33 --- /dev/null +++ b/site/util/typography.js @@ -0,0 +1,9 @@ +import Typography from 'typography'; +import theme from 'typography-theme-alton'; + +const typography = new Typography(theme); + +export const baseSpacingPx = parseInt(theme.baseFontSize, 10); + +export {theme}; +export default typography;