diff --git a/.gitignore b/.gitignore index 5b034cd..4d44c02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules/ -dist/ +build/ yarn.lock yarn-error.log diff --git a/README.md b/README.md index 4a647e5..2546451 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# TerriaJS sample plugin +# 🔌 TerriaJS sample plugin -This repository implements a sample TerriaJS plugin. The plugin implements a -custom tool for drawing an interactive 3D box on the map. It serves as an -example for setting up an loading an external plugin library that adds some new -functionality to Terria without forking it. +This repository implements a sample TerriaJS plugin which adds a custom tool to +Terria map for drawing an interactive 3D box. + +It serves as an example for setting up and loading an external plugin library +that adds new functionality to Terria without forking it. Plugins allow extending Terria in two ways: @@ -21,121 +22,175 @@ library and are pre-requisites for understanding the code: - [yarn](yarnpkg.com) - Package manager -Additional documentation for developing with terria is available at -[https://docs.terria.io](https://docs.terria.io/). You can also reach us through our [discussion forum](https://github.com/TerriaJS/terriajs/discussions) if you require additional help. +Additional documentation for developing with Terria is available at +[https://docs.terria.io](https://docs.terria.io/). +👷 This plugin repository is a work in progress and will be updated as the different APIs evolve. Meanwhile expect breaking changes -This plugin repository is a work in progress and will be updated as the different -APIs evolve. Meanwhile expect breaking changes 👷 +💬 Reach us through our [discussion forum](https://github.com/TerriaJS/terriajs/discussions) if you require additional help. -### Current status -- [x] Load external plugins in TerriaJS at build time -- [x] Support for registering custom data types (aka catalog items) -- [x] Initial, limited support for extending UI to add custom workflows -- [ ] Testing -- [ ] Linting +## Guides -# Adding the plugin to your terriamap +- [Installing the plugin](#-installing-the-plugin) +- [Developing your own plugin](#-developing-your-own-plugin) -### Clone terriamap -```bash -git clone https://github.com/terriajs/terriamap -cd terriamap -``` +## 🚀 Installing the plugin -### Add this plugin as dependency in package.json -```bash -yarn add -W 'terriajs-plugin-sample' -``` +If you just want to try out the plugin to see how it works, add the plugin as a dependency to your terriamap and register it in `plugins.ts` file. The steps below show how to do that. -### Add it to the plugin registry file `plugins.ts` -```typescript -const plugins: any[] = [ - import("terriajs-plugin-sample") -]; -... -export default plugins; -``` +1. **Clone terriamap** -Note: The file `plugins.ts` is in the terriamap project root directory. + ```bash + git clone https://github.com/terriajs/terriamap + cd terriamap + ``` -### Now build your terriamap and start the server +2. **Add the plugin package as dependency** -``` -# From the terriamap directory run -yarn run gulp dev -``` + ```bash + yarn add -W terriajs-plugin-sample + ``` + +3. **Add the plugin to `terriamap/plugins.ts`** + + ```typescript + const plugins: any[] = [ + import("terriajs-plugin-sample") + ]; + ... + export default plugins; + ``` + +4. **Build terriamap and run a dev server** + + ```bash + # From the terriamap directory run + yarn run gulp dev + ``` + +#### Testing the plugin Once the server is running visit http://localhost:3001 to load the app. You should see a new plugin button added to the map toolbar on the right hand side. Opening the tool will prompt the user to draw a rectangle on the map, this will place a 3d box of the same dimension on the map. Screenshot of the plugin in action: ![Sample plugin](sample-plugin.png "Sample plugin") -# Plugin development workflow +## 👩‍🔬 Developing your own plugin -This section assumes you have completed the steps for [adding the plugin to your terriamap](#adding-the-plugin-to-your-terriamap). +### Setting up development enviroment Developing the plugin requires correctly setting up the yarn workspace. Your local directory structure should look something like: + ``` terriamap/ packages/ - ├── plugin-sample - └── terriajs + └── plugin-sample ``` -The `terriajs` and `plugin-sample` repositories must be checked out under `terriamap/packages/` folder +This `plugin-sample` repository must be checked out and setup correctly under `terriamap/packages` directory. The steps below shows how to do that. -### Checkout terriajs and sample-plugin into the packages folder +1. Checkout `plugin-sample` into the packages folder -```bash -cd terriamap/ -mkdir -p packages -git clone https://github.com/terriajs/terriajs packages/terriajs -git clone https://github.com/terriajs/plugin-sample packages/plugin-sample -``` + ```bash + cd terriamap/ + mkdir -p packages + git clone https://github.com/terriajs/plugin-sample packages/plugin-sample + ``` -### Add the plugin package to the [yarn workspace](https://classic.yarnpkg.com/lang/en/docs/workspaces/) settings of your terriamap `package.json` file. +2. Add the plugin package to the [yarn workspace](https://classic.yarnpkg.com/lang/en/docs/workspaces/) settings of your terriamap's `package.json` file. -Edit `package.json` for terriamap: + Edit `terriamap/package.json`: -```json - { - "private": true, - "workspaces": { - "packages": [ - "packages/terriajs", - "packages/cesium", - "packages/terriajs-server" - "packages/plugin-sample" // <-- plugin-sample added here - ], + ```json + { + "private": true, + "workspaces": { + "packages": [ + "packages/terriajs", + "packages/cesium", + "packages/terriajs-server" + "packages/plugin-sample" // <-- plugin-sample added here + ], + + ... + + "dependencies": { + "terriajs-plugin-api": "0.0.1-alpha.16", + "terriajs-plugin-sample": "0.0.1-alpha.8", // <-- plugin-sample version should match the version in packages/plugin-sample/package.json + ``` - ... +3. Install the new dependencies + + Make sure you are in the `terriamap` directory and run: - "dependencies": { - "terriajs-plugin-api": "0.0.1-alpha.16", - "terriajs-plugin-sample": "0.0.1-alpha.8", // <-- plugin-sample version should match the version in packages/plugin-sample/package.json -``` + ```bash + yarn install + ``` -### Build terriamap +4. Build the plugin-sample -From your `terriamap` folder run: + ```bash + cd terriamap/packages/plugin-sample + # Start a plugin build process that watches for file changes + yarn run watch + ``` + +5. Build terriamap + + Now, from your `terriamap` folder run: + + ```bash + yarn install + # Starts a terriamap dev server that watches for code changes and rebuilds the map + yarn run gulp dev + ``` + +👉 You need to keep both the yarn commands running, then start making make changes to the plugin code, terriamap will automatically +rebuild your changes. + +👉 Watch for errors from the plugin build process. Note that the app page doesn't reload automatically when the code rebuilds, you +have to refresh the page to see your changes. + +### Troubleshooting + +The plugin provides a script to check if the dev environment has been set up correctly. ```bash -yarn install -# Starts a terriamap dev server that watches for code changes and rebuilds the map -yarn run gulp dev +$ cd packages/plugin-sample +$ yarn check-dev-env ``` -### Build plugin-sample +If it generates an output like below with all checks passing, then your dev enviroment setup is probably correct. ```bash -cd terriamap/packages/plugin-sample -# Start a plugin build process that watches for file changes -yarn run watch +$ node scripts/checkDevEnv.js +✅ Find map workspace - Yes (/home/user/terriamap) +✅ Plugin added to workspaces setting - Yes +✅ Plugin added to dependencies - Yes +✅ Package versions match - Yes (1.0.0 matches ^1.0.0) +✅ Plugin import resolves correctly - Yes +✅ Plugin added to plugins registry - Yes +✅ terriajs-plugin-api versions match - Yes (0.0.1-alpha.16 matches ^0.0.1-alpha.15) +Done in 0.10s. ``` -Note: you need to keep both the yarn commands running, then start making make changes to the plugin code, terriamap will automatically -rebuild your changes. +### Plugin scripts + +The following scripts are available to help with development + +`yarn build` - Bundle `src` and `specs` folders, typecheck and lint. + +`yarn watch` - Watch files and rebuild plugin. + +`yarn test` - Runs the tests + +`yarn typecheck` - Typechecks the files using typescript compiler + +`yarn check-dev-env` - Verifies that the plugin development enviroment is setup correctly + +### Plugin API + +Documentation for the plugin API is still in works, meanwhile please inspect the [terriajs-plugin-api](https://github.com/terriajs/plugin-api) repository for available APIs. + + -Watch for errors from the plugin build process. Note that the app page doesn't reload automatically when the code rebuilds, you -have to refresh the page to see your changes. diff --git a/karma.conf.cjs b/karma.conf.cjs new file mode 100644 index 0000000..4c58195 --- /dev/null +++ b/karma.conf.cjs @@ -0,0 +1,55 @@ +"use strict"; + +module.exports = function (config) { + config.set({ + basePath: "build/specs", + proxies: { + "/data/": "/base/data", + "/images/": "/base/images", + "/test/": "/base/test", + "/build/TerriaJS/build/Cesium/build": "/base/Cesium", + "/build/Cesium": "/base/TerriaJS/build/Cesium", + "/build": "/base" + }, + + autoWatch: true, + autoWatchBatchDelay: 500, // Delay between tests, hopefully enough time for the bundler to finish writing everything + + reporters: ["spec"], + + specReporter: { + suppressErrorSummary: false, + suppressFailed: false, + suppressPassed: false, + suppressSkipped: false + }, + + files: [ + { pattern: "stdin.js", watched: true, nocache: true }, + { + pattern: "**/*", + included: false, + served: true, + watched: false, + nocache: true + } + ], + singleRun: true, + failOnEmptyTestSuite: false, + frameworks: ["jasmine"], + browsers: ["ChromeHeadless"], + detectBrowsers: { + enabled: true, + usePhantomJS: false, + postDetection(availableBrowsers) { + return availableBrowsers.filter((b) => /chrom/i.test(b)); + } + }, + plugins: [ + require("karma-spec-reporter"), + require("karma-jasmine"), + require("karma-chrome-launcher"), + require("karma-detect-browsers") + ] + }); +}; diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..0eeb6c7 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,5 @@ +👉 Note: + +This folder contains plugin library code. + +The code for your plugin should go under [src](../src) folder. diff --git a/src/declarations.d.ts b/lib/declarations.d.ts similarity index 59% rename from src/declarations.d.ts rename to lib/declarations.d.ts index 8800d4b..ea80a6f 100644 --- a/src/declarations.d.ts +++ b/lib/declarations.d.ts @@ -2,3 +2,8 @@ declare module "assets/icons/*.svg" { const icon: import("terriajs-plugin-api").IconGlyph; export default icon; } + +declare module "sprite.svg" { + const sprite: string; + export default sprite; +} diff --git a/lib/withSvgSprite.ts b/lib/withSvgSprite.ts new file mode 100644 index 0000000..9c7138a --- /dev/null +++ b/lib/withSvgSprite.ts @@ -0,0 +1,28 @@ +import { TerriaPlugin } from "terriajs-plugin-api"; + +/** + * Load SVG sprite when the plugin is intialized. + * + * SVG icons can be imported in plugin code as: `import someIcon from "assets/icons/someIcon.svg"`. + * During build, these SVG assets are merged into a single sprite. This + * function ensures the sprite is added to the DOM when the plugin is initialized. + */ +export default function withSvgSprite(plugin: TerriaPlugin): TerriaPlugin { + return { + ...plugin, + register(...args) { + document.readyState === "complete" + ? loadSvgSprite() + : window.addEventListener("load", () => loadSvgSprite()); + plugin.register(...args); + } + }; +} + +function loadSvgSprite() { + import("sprite.svg").then(({ default: sprite }) => { + const div = document.createElement("div"); + div.innerHTML = sprite; + document.body.appendChild(div); + }); +} diff --git a/package.json b/package.json index d912c5e..bb0f3cf 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,48 @@ { "name": "terriajs-plugin-sample", - "version": "0.0.1-alpha.8", + "version": "1.0.0", "description": "A sample terriajs plugin.", - "module": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", "repository": "https://github.com/terriajs/plugin-sample", "license": "Apache-2.0", - "prepare": "rollup -c rollup.config.ts", - "dependencies": { + "module": "build/src/index.js", + "types": "types/index.d.ts", + "files": [ + "build/src", + "types" + ], + "peerDependencies": { "terriajs-plugin-api": "0.0.1-alpha.16" }, "devDependencies": { - "@rollup/plugin-commonjs": "^23.0.2", - "@rollup/plugin-node-resolve": "^13.1.3", - "@rollup/plugin-replace": "^5.0.1", - "@rollup/plugin-typescript": "^8.3.1", + "@types/jasmine": "^5.1.4", + "esbuild": "^0.20.2", + "esbuild-plugin-polyfill-node": "^0.3.0", + "jasmine": "^5.1.0", + "karma": "^6.4.3", + "karma-chrome-launcher": "^3.2.0", + "karma-detect-browsers": "^2.3.3", + "karma-jasmine": "^5.1.0", + "karma-spec-reporter": "^0.0.36", + "npm-run-all": "^4.1.5", "prettier": "2.7.1", - "rollup": "^2.70.1", - "rollup-plugin-terser": "^7.0.2", + "semver-intersect": "^1.5.0", + "svg-sprite": "^2.0.4", "typescript": "~5.2.0" }, "scripts": { - "build": "rollup -c rollup.config.ts", - "watch": "rollup -c rollup.config.ts -w", - "test": "exit 0" + "prepublishOnly": "yarn clean && yarn build", + "check-dev-env": "node scripts/checkDevEnv.js", + "build": "run-p -lc bundle typecheck lint", + "bundle": "node scripts/bundle.js", + "typecheck": "tsc --noEmit --pretty", + "test": "karma start karma.conf.cjs --single-run", + "clean": "rimraf ./build", + "lint": "eslint src lib specs", + "watch": "run-p -lc watch:*", + "watch:bundle": "node scripts/bundle.js --dev --watch", + "prewatch:test": "node scripts/copyCesiumAssets.js", + "watch:test": "karma start karma.conf.cjs --no-single-run", + "watch:typecheck": "tsc --noEmit --pretty --watch --preserveWatchOutput" } } diff --git a/rollup.config.ts b/rollup.config.ts deleted file mode 100644 index e0d453f..0000000 --- a/rollup.config.ts +++ /dev/null @@ -1,72 +0,0 @@ -import nodeResolve from "@rollup/plugin-node-resolve"; -import typescript from "@rollup/plugin-typescript"; -import * as path from "path"; -import { terser } from "rollup-plugin-terser"; -import packageJson from "./package.json"; - -// Paths to exclude from the bundle -const externalPaths = [ - /^.*\/node_modules\/.*$/, - /^terriajs-cesium\/.*$/, - /^.*@babel\/runtime.*$/, - /^.*\/terriajs\/.*$/ -]; - -export default { - input: "src/index.ts", - output: { - format: "esm", - dir: "dist/" - }, - // preserveSymlinks is required to prevent rollup from expanding references to packages in yarn workspace to relative paths - preserveSymlinks: true, - external: (depPath) => { - // exclude files in exclusionList from the build pipeline - return externalPaths.some((ext) => { - if (typeof ext === "string") { - return depPath === ext; - } else if (ext instanceof RegExp) { - return ext.test(depPath); - } else { - return false; - } - }); - }, - plugins: [ - nodeResolve(), - resolveSvgIcons(), - typescript() - /*terser() // enable terser if you want to minify your code */ - ] -}; - -/** - * Resolve `asset/icons/*.svg` imports and transform it to be picked up by the terriamap webpack loader. - * See: "terriamap/buildprocess/configureWebpackForPlugins.js" - */ -function resolveSvgIcons() { - return { - name: "resolve-svg-icons", - resolveId(importee) { - // rewrite `assets/icons` path to absolute path - return importee.startsWith(path.join("assets", "icons")) - ? path.resolve("./", importee) - : null; - }, - transform(code, id) { - // Transform icon asset files to require() the original svg file - const isIconAsset = - id.endsWith(".svg") && - path.relative(path.join("assets", "icons"), path.dirname(id)) === ""; - - if (isIconAsset) { - const relativeIconPath = path.relative(path.join("."), id); - return { - code: `export default require("${packageJson.name}/${relativeIconPath}")` - }; - } else { - return null; - } - } - }; -} diff --git a/scripts/bundle.js b/scripts/bundle.js new file mode 100644 index 0000000..067877d --- /dev/null +++ b/scripts/bundle.js @@ -0,0 +1,187 @@ +import esbuild from "esbuild"; +import { polyfillNode } from "esbuild-plugin-polyfill-node"; +import globby from "globby"; +import path from "path"; +import yargs from "yargs"; +import { copyCesiumAssets } from "./copyCesiumAssets.js"; +import preferEsmModule from "./esbuild/preferEsmModule.js"; +import replaceModule from "./esbuild/replaceModule.js"; +import selectLoader from "./esbuild/selectLoader.js"; +import skipExternalModules from "./esbuild/skipExternalModules.js"; +import svgSprite from "./esbuild/svgSprite.js"; +import isMain from "./isMain.js"; + +/** + * Output build directory + */ +export const BUILD_DIR = "build"; + +/** + * Shared esbuild config for bundling both src and specs + */ +export const config = { + bundle: true, + color: true, + + define: { + global: "globalThis" + }, + + tsconfig: "./tsconfig.json", + + plugins: [ + // The official @cesium/widgets package imports from `@cesium/engine`. + // Replace it with the `terriajs-cesium` fork instead. + replaceModule("@cesium/engine", "terriajs-cesium"), + + // There are places in the TerriaJS code base where some modules are imported using CJS style `require("x").default`. + // This causes, esbuild to include their NodeJS exports instead of the browser specific ESM modules. + // This plugin forces the use of ESM browser modules instead. + preferEsmModule(["proj4", "i18next"]), + + // Generates sprite.svg.js for icons + svgSprite, + + // Handle the webpackish import paths in TerriaJS code base + selectLoader({ + loaders: [ + { + filter: /^[!]*raw-loader!(.*)$/, + loader: "text" + }, + { + filter: /^file-loader!(.*)$/, + loader: "file" + }, + { + filter: /^worker-loader!(.*)$/, + loader: "empty" + }, + { + filter: /^[!]*style-loader!.*?([^!]*\.css)$/, + loader: "css" + }, + { + filter: /^[!]*style-loader!.*?([^!]*\.scss)$/, + loader: "empty" + } + ] + }) + ], + + loader: { + ".jsx": "tsx", + ".gif": "file", + ".png": "file", + ".jpg": "file", + ".svg": "file", + ".html": "text", + ".glb": "file", + ".xml": "text", + ".DAC": "file", + ".wasm": "file", + ".scss": "empty" + } +}; + +/** + * Invoke the esbuild bundler. + */ +async function runBuilder(config, opts) { + return esbuild + .context(config) + .then((builder) => + opts.watch + ? builder.watch() + : builder.rebuild().then(() => builder.dispose()) + ); +} + +/** + * Create a bundle for spec files. + * + * This will package all the dependencies as a single standalone script that + * Karma can then load and run. + */ +async function bundleSpecs(opts) { + const specsDir = "specs"; + const glob = "**/*Spec.ts"; + const specs = ["SpecMain.ts", ...(await globby(glob, { cwd: specsDir }))]; + const specsBuildDir = path.join(BUILD_DIR, "specs"); + const mergedSpecs = specs.map((s) => `import "./${s}"`).join(";"); + + return Promise.all([ + copyCesiumAssets(), + runBuilder( + { + ...config, + outdir: specsBuildDir, + stdin: { + contents: mergedSpecs, + resolveDir: "./specs", + sourcefile: "specs.js" + }, + + // Options for browser build which will be loaded by Karma + platform: "browser", + target: "esNext", + format: "iife", + minify: false, + sourcemap: true, + + plugins: (config.plugins ?? []).concat([ + // Polyfill NodeJS functions for the browser + polyfillNode() + ]) + }, + opts + ) + ]); +} + +/** + * Create a bundle for src files. + * + * This will only bundle the local code leaving every other dependency to be + * bundled by terriamap's build system. + */ +async function bundleSrc(opts) { + return runBuilder( + { + ...config, + entryPoints: ["src/index.ts"], + outdir: path.join(BUILD_DIR, "src"), + + // The src bundle is further bundled by Terriamap webpack build system + // which expects the package to use es2019 + target: "es2019", + format: "esm", + + // Enable splitting so that dynamic imports result in a separate bundle + splitting: true, + + // eslint-disable-next-line no-unneeded-ternary + minify: opts.dev ? false : true, + // eslint-disable-next-line no-unneeded-ternary + sourcemap: opts.dev ? true : false, + + plugins: (config.plugins ?? []).concat( + // Skip non-local modules from the bundle, these will be included later + // when terriamap builds the plugin + skipExternalModules + ) + }, + opts + ); +} + +/** + * Bundle src and spec files. + */ +export function bundle(opts) { + return Promise.all([bundleSrc(opts), bundleSpecs(opts)]); +} + +if (isMain(import.meta.url)) { + await bundle(yargs(process.argv).argv); +} diff --git a/scripts/checkDevEnv.js b/scripts/checkDevEnv.js new file mode 100644 index 0000000..5a62471 --- /dev/null +++ b/scripts/checkDevEnv.js @@ -0,0 +1,284 @@ +import fs from "fs/promises"; +import micromatch from "micromatch"; +import { createRequire } from "node:module"; +import path from "path"; +import { intersect as semverIntersect } from "semver-intersect"; +import findYarnWorkspaceRoot from "./findYarnWorkspaceRoot.js"; +import isMain from "./isMain.js"; + +/** + * Checks if the plugin development environment is sane. + */ +async function checkDevEnv() { + const checks = { + mapWorkspace: { name: "Find map workspace", fn: checkMapWorkspace }, + pluginAddedToWorkspace: { + name: "Plugin added to workspaces setting", + fn: checkPluginAddedToWorkspace + }, + pluginAddedToDeps: { + name: "Plugin added to dependencies", + fn: checkPluginAddedToDeps + }, + pluginVersionsMatch: { + name: "Package versions match", + fn: checkPluginVersions + }, + pluginImportResolvesCorrectly: { + name: "Plugin import resolves correctly", + fn: checkPluginImportResolvesCorrectly + }, + pluginAddedToPluginsRegistry: { + name: "Plugin added to plugins registry", + fn: checkPluginAddedToRegistry + }, + apiVersionsMatch: { + name: "terriajs-plugin-api versions match", + fn: checkApiVersions + } + }; + + const context = { + pluginDir: process.cwd(), + packageJson: await readJsonFile(path.join(process.cwd(), "package.json")) + }; + + for (const check of Object.values(checks)) { + const checkNext = await check.fn(check, context); + if (!check.result?.ok && !checkNext) { + break; + } + } + + return checks; +} + +async function checkMapWorkspace(out, context) { + const workspace = await findYarnWorkspaceRoot(); + const workspaceDeps = Object.assign( + {}, + workspace?.packageJson?.dependencies, + workspace?.packageJson?.devDependencies + ); + const requiredWorkspaceDeps = ["terriajs", "terriajs-plugin-api"]; + const hasRequiredWorkspaceDeps = requiredWorkspaceDeps.every( + (d) => !!workspaceDeps[d] + ); + + context.workspace = workspace; + out.result = + !workspace || !hasRequiredWorkspaceDeps + ? { + error: [ + "You need to place this plugin directory in a terria map workspace directory.", + workspace.dir + ? `Current workspace root '${workspace.dir}' does not look like a terria map project.` + : "Usually this is the `terriamap/packages` directory." + ].join("\n ") + } + : { ok: `Yes (${workspace.dir})`, workspace }; +} + +/** + * Check if the plugin directory is checked out under a terriamap workspace. + */ +async function checkPluginAddedToWorkspace(out, { workspace, pluginDir }) { + const relativePluginDir = path.relative(workspace.dir, pluginDir); + const addedToWorkspace = workspace.packages.some((pattern) => + micromatch.isMatch(relativePluginDir, pattern) + ); + out.result = addedToWorkspace + ? { ok: "Yes" } + : { + error: `"${relativePluginDir}" should be added to the "workspaces.packages" settings in '${workspace.packageJsonFile}'` + }; +} + +/** + * Check if `dependencies` in terriamap/package.json includes this plugin. + */ +async function checkPluginAddedToDeps(out, { workspace, packageJson }) { + const packageName = packageJson.name; + const packageDep = workspace.packageJson?.["dependencies"]?.[packageName]; + out.result = packageDep + ? { ok: "Yes" } + : { + error: `Plugin should be added to "dependencies" settings in '${workspace.packageJsonFile}'` + }; +} + +/** + * Check whether terriamap/package.json has the correct version of this plugin. + */ +async function checkPluginVersions(out, { workspace, pluginDir, packageJson }) { + const packageName = packageJson?.name; + const localVersion = packageJson?.version; + const workspaceVersion = Object.assign( + {}, + packageJson.peerDependencies, + workspace.packageJson.dependencies, + workspace.packageJson.devDependencies + )[packageName]; + + let ok = false; + try { + ok = !!semverIntersect(localVersion, workspaceVersion); + } catch (err) { + /*nothing to do*/ + } + + const pluginPackageJsonFile = path.join( + path.relative(workspace.dir, pluginDir), + "package.json" + ); + out.result = ok + ? { + ok: `Yes (${localVersion} matches ${workspaceVersion})` + } + : { + error: [ + `Version in ${pluginPackageJsonFile}: ${localVersion}`, + `Version in ${workspace.packageJsonFile}: ${workspaceVersion}` + ].join("\n ") + }; + return true; +} + +/** + * Check whether the version of terriajs-plugin-api dependency for this plugin and terriamap, both match. + */ +async function checkApiVersions(out, { workspace, pluginDir, packageJson }) { + const localVersion = Object.assign( + {}, + packageJson.peerDependencies, + packageJson.dependencies, + packageJson.devDependencies + )["terriajs-plugin-api"]; + const workspaceVersion = Object.assign( + {}, + packageJson.peerDependencies, + workspace.packageJson.dependencies, + workspace.packageJson.devDependencies + )["terriajs-plugin-api"]; + + let ok = false; + try { + ok = !!semverIntersect(localVersion, workspaceVersion); + } catch (err) { + /*nothing to do*/ + } + + const pluginPackageJsonFile = path.join( + path.relative(workspace.dir, pluginDir), + "package.json" + ); + out.result = ok + ? { + ok: `Yes (${localVersion} matches ${workspaceVersion})` + } + : { + error: [ + `Version in ${pluginPackageJsonFile}: ${localVersion}`, + `Version in ${workspace.packageJsonFile}: ${workspaceVersion}` + ].join("\n ") + }; + return true; +} + +/** + * Check if importing the plugin correctly resolves to this plugin directory. + */ +async function checkPluginImportResolvesCorrectly( + out, + { workspace, pluginDir, packageJson } +) { + const pluginName = packageJson.name; + const resolvedDir = await nodeResolveDirectory(`${pluginName}/package.json`); + out.result = + resolvedDir === pluginDir + ? { ok: "Yes" } + : { + error: `Importing "${pluginName}" does not correctly resolve to '${pluginDir}'.\n Make sure you have run "yarn install" from the workspace root '${workspace.dir}'` + }; +} + +/** + * Check if the plugin has been added to terriamap/plugins.ts. + */ +async function checkPluginAddedToRegistry(out, { workspace, packageJson }) { + // Transpile the plugins.ts file to JS and check if it imports this plugin library + const pluginRegistryFile = path.join(workspace.dir, "plugins.ts"); + const esbuild = await import("esbuild"); + const pluginsJs = await esbuild + .build({ + entryPoints: [pluginRegistryFile], + write: false, + minify: true + }) + .then((out) => out.outputFiles[0].text); + + const pluginsFn = await import( + `data:text/javascript;base64,${btoa(pluginsJs)}` + ).then((module) => module.default.toString()); + + const name = packageJson.name; + const importsPlugin = pluginsFn.includes(`import("${name}")`); + + out.result = importsPlugin + ? { ok: "Yes" } + : { + error: `"${name}" missing in plugin registry file '${pluginRegistryFile}'` + }; +} + +function matchVersion(packageName, packageJson, workspacePackageJson) { + const localVersion = Object.assign( + {}, + packageJson.peerDependencies, + packageJson.dependencies, + packageJson.devDependencies + )[packageName]; + + const workspaceVersion = Object.assign( + {}, + packageJson.peerDependencies, + workspacePackageJson.dependencies, + workspacePackageJson.devDependencies + )[packageName]; + + let ok = false; + try { + ok = !!semverIntersect(localVersion, workspaceVersion); + } catch (err) { + /*nothing to do*/ + } + return { ok, packageName, localVersion, workspaceVersion }; +} + +async function nodeResolveDirectory(file) { + const require = createRequire(import.meta.url); + try { + return path.dirname(await fs.realpath(require.resolve(file))); + } catch (err) { + return undefined; + } +} + +async function readJsonFile(file) { + return JSON.parse(await fs.readFile(file)); +} + +if (isMain(import.meta.url)) { + checkDevEnv().then((checks) => { + Object.values(checks).forEach((check) => { + if (check?.result?.error) { + console.log("❌", check.name, "-", "No"); + console.log(" ", check.result.error); + } else if (check?.result?.ok) { + console.log("✅", check.name, "-", check.result.ok); + } else { + console.log("❓", check.name, "-", "not checked"); + } + }); + }); +} diff --git a/scripts/copyCesiumAssets.js b/scripts/copyCesiumAssets.js new file mode 100644 index 0000000..8b67b97 --- /dev/null +++ b/scripts/copyCesiumAssets.js @@ -0,0 +1,34 @@ +import fs from "fs/promises"; +import { createRequire } from "node:module"; +import path from "path"; +import { BUILD_DIR } from "./bundle.js"; +import isMain from "./isMain.js"; + +export function copyCesiumAssets() { + const outDir = path.join(BUILD_DIR, "specs", "Cesium"); + const require = createRequire(import.meta.url); + const cesiumDir = path.dirname( + require.resolve("terriajs-cesium/package.json") + ); + + const copy = (src, dest) => + fs.cp(src, dest, { recursive: true, force: true, errorOnExist: false }); + + return Promise.all([ + copy( + path.join(cesiumDir, "Build", "Workers"), + path.join(outDir, "Workers") + ), + copy(path.join(cesiumDir, "Source", "Assets"), path.join(outDir, "Assets")), + copy( + path.join(cesiumDir, "Source", "ThirdParty"), + path.join(outDir, "ThirdParty") + ) + ]).catch(() => { + /* can error if there are parallel copy attempts */ + }); +} + +if (isMain(import.meta.url)) { + copyCesiumAssets(); +} diff --git a/scripts/esbuild/preferEsmModule.js b/scripts/esbuild/preferEsmModule.js new file mode 100644 index 0000000..51e8327 --- /dev/null +++ b/scripts/esbuild/preferEsmModule.js @@ -0,0 +1,17 @@ +export default function preferEsmModule(moduleNames) { + return { + name: "prefer-esm-module", + setup(build) { + moduleNames.forEach((moduleName) => { + build.onResolve({ filter: new RegExp(`^${moduleName}$`) }, (args) => { + return args.kind === "require-call" + ? build.resolve(args.path, { + kind: "import-statement", + resolveDir: args.resolveDir + }) + : undefined; + }); + }); + } + }; +} diff --git a/scripts/esbuild/replaceModule.js b/scripts/esbuild/replaceModule.js new file mode 100644 index 0000000..e9b5e62 --- /dev/null +++ b/scripts/esbuild/replaceModule.js @@ -0,0 +1,16 @@ +export default function replaceModule(module, replacementModule) { + return { + name: "replace-module", + setup(build) { + build.onResolve( + { filter: new RegExp(`^${module.replace("/", "\\/")}$`) }, + async (args) => { + return build.resolve(replacementModule, { + kind: args.kind, + resolveDir: args.resolveDir + }); + } + ); + } + }; +} diff --git a/scripts/esbuild/selectLoader.js b/scripts/esbuild/selectLoader.js new file mode 100644 index 0000000..116bf4d --- /dev/null +++ b/scripts/esbuild/selectLoader.js @@ -0,0 +1,47 @@ +import fs from "fs/promises"; + +const selectLoader = (options = {}) => ({ + name: "selectLoaderPlugin", + setup(build, { transform } = {}) { + for (const { filter, loader } of options.loaders) { + build.onResolve( + { + filter: filter + }, + async (args) => { + const pathOnly = args.path.match(filter)[1]; + const result = await build.resolve(pathOnly, { + kind: args.kind, + importer: args.importer, + namespace: "file", + resolveDir: args.resolveDir, + pluginData: args.resolveData + }); + return { + ...result, + namespace: "selectLoaderPlugin", + pluginData: { + ...result.pluginData, + loader + } + }; + } + ); + + build.onLoad( + { + filter: /.*/, + namespace: "selectLoaderPlugin" + }, + async (args) => { + return { + contents: await fs.readFile(args.path), + loader: args.pluginData.loader + }; + } + ); + } + } +}); + +export default selectLoader; diff --git a/scripts/esbuild/skipExternalModules.js b/scripts/esbuild/skipExternalModules.js new file mode 100644 index 0000000..7155d4b --- /dev/null +++ b/scripts/esbuild/skipExternalModules.js @@ -0,0 +1,14 @@ +export default { + name: "skipExternalNodeModules", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === "entry-point" || args.path.startsWith(".")) { + return; + } else { + // Mark all non local imports as external. These will be bundled + // by the terriamap build process + return { path: args.path, external: true }; + } + }); + } +}; diff --git a/scripts/esbuild/svgSprite.js b/scripts/esbuild/svgSprite.js new file mode 100644 index 0000000..6fed33b --- /dev/null +++ b/scripts/esbuild/svgSprite.js @@ -0,0 +1,62 @@ +import fs from "fs/promises"; +import path from "path"; +import SvgSprite from "svg-sprite"; + +/** + * esbuild plugin for generating SVG sprite + */ +export default { + name: "svg-sprite-builder", + async setup(build) { + const symbolPrefix = JSON.parse( + await fs.readFile("package.json", "utf-8") + ).name.replace(/[^0-9a-z]/i, "-"); + const sprite = new SvgSprite({ + mode: { + symbol: { inline: true } + }, + shape: { + id: { generator: `${symbolPrefix}-%s` } + } + }); + + build.onResolve({ filter: /^sprite.svg$/ }, (args) => { + // sprite.svg.js will be generated when build ends (see below). + return { path: "./sprite.svg.js", external: true }; + }); + + build.onResolve({ filter: /assets\/icons\/.*\.svg$/ }, (args) => { + const pluginDir = process.cwd(); + return { + path: path.join(pluginDir, args.path), + namespace: "svg-sprite-builder" + }; + }); + + build.onLoad( + { filter: /.*\.svg$/, namespace: "svg-sprite-builder" }, + async (args) => { + const baseName = path.basename(args.path, path.extname(args.path)); + const symbol = `${symbolPrefix}-${baseName}`; + const svg = await fs.readFile(args.path, "utf-8"); + sprite.add(args.path, path.basename(args.path), svg); + return { + contents: `export default { id: "${symbol}" }`, + loader: "js" + }; + } + ); + + build.onEnd(async (args) => { + const { result } = await sprite.compileAsync(); + const outdir = + build.initialOptions.outdir || + path.dirname(build.initialOptions.outfile); + const spriteFile = path.join(outdir, "sprite.svg.js"); + return fs.writeFile( + spriteFile, + `export default '${result.symbol.sprite.contents}';` + ); + }); + } +}; diff --git a/scripts/esbuild/writeBuildTimestamp.js b/scripts/esbuild/writeBuildTimestamp.js new file mode 100644 index 0000000..55e4857 --- /dev/null +++ b/scripts/esbuild/writeBuildTimestamp.js @@ -0,0 +1,8 @@ +import fs from "fs/promises"; + +export default function writeBuildTimestamp(file) { + return { + name: "writeBuildTimestamp", + setup(build) {} + }; +} diff --git a/scripts/findYarnWorkspaceRoot.js b/scripts/findYarnWorkspaceRoot.js new file mode 100644 index 0000000..405187a --- /dev/null +++ b/scripts/findYarnWorkspaceRoot.js @@ -0,0 +1,54 @@ +import fs from "fs/promises"; +import path from "path"; + +export default async function findYarnWorkspaceRoot() { + const initial = process.cwd(); + let previous = null; + let current = path.normalize(initial); + + do { + const packageJsonFile = path.join(current, "package.json"); + const manifest = await readPackageJson(packageJsonFile); + const ws = extractWorkspaces(manifest); + if (ws && ws.packages) { + return { + dir: current, + packages: ws.packages, + packageJsonFile, + packageJson: manifest + }; + } + + previous = current; + current = path.dirname(current); + } while (current !== previous); + + return null; +} + +async function readPackageJson(file) { + return fs + .readFile(file, "utf-8") + .then((str) => JSON.parse(str)) + .catch((err) => undefined); +} + +function extractWorkspaces(manifest) { + if (!manifest || !manifest.workspaces) { + return undefined; + } + + if (Array.isArray(manifest.workspaces)) { + return { packages: manifest.workspaces }; + } + + if ( + (manifest.workspaces.packages && + Array.isArray(manifest.workspaces.packages)) || + (manifest.workspaces.nohoist && Array.isArray(manifest.workspaces.nohoist)) + ) { + return manifest.workspaces; + } + + return undefined; +} diff --git a/scripts/isMain.js b/scripts/isMain.js new file mode 100644 index 0000000..81bc7dd --- /dev/null +++ b/scripts/isMain.js @@ -0,0 +1,5 @@ +import { fileURLToPath } from "url"; + +export default function isMain(importMetaUrl) { + return fileURLToPath(importMetaUrl) === process.argv[1]; +} diff --git a/specs/Models/Box3dCatalogItemSpec.ts b/specs/Models/Box3dCatalogItemSpec.ts new file mode 100644 index 0000000..0c6ac04 --- /dev/null +++ b/specs/Models/Box3dCatalogItemSpec.ts @@ -0,0 +1,15 @@ +import { Terria } from "terriajs-plugin-api"; +import Box3dCatalogItem from "../../src/Models/Box3dCatalogItem"; + +describe("Box3dCatalogItemSpec", function () { + let terria: Terria; + + beforeEach(function () { + terria = new Terria(); + }); + + it("can be created", function () { + const box3d = new Box3dCatalogItem("test", terria); + expect(box3d).toBeDefined(); + }); +}); diff --git a/specs/SpecMain.ts b/specs/SpecMain.ts new file mode 100644 index 0000000..ac33255 --- /dev/null +++ b/specs/SpecMain.ts @@ -0,0 +1,17 @@ +beforeAll(() => { + // Set base href to root. This is required for correctly loading Cesium + // assets from a Karma context or debug file. + setBaseHref("/"); +}); + +/** + * Set the base href tag + */ +function setBaseHref(href: string) { + let base = document.getElementsByTagName("base")[0]; + if (!base) { + base = document.createElement("base"); + document.head.appendChild(base); + } + base.href = href; +} diff --git a/specs/jasmine.d.ts b/specs/jasmine.d.ts new file mode 100644 index 0000000..1714786 --- /dev/null +++ b/specs/jasmine.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/Views/DrawRectangle.tsx b/src/Views/DrawRectangle.tsx index 1c9c2a6..d7a1f83 100644 --- a/src/Views/DrawRectangle.tsx +++ b/src/Views/DrawRectangle.tsx @@ -23,7 +23,7 @@ export const DrawRectangle: React.FC = ({ onDrawingComplete }) => { return () => { userDrawing.endDrawing(); }; - }, []); + }, [terria, onDrawingComplete]); return

Draw a rectangle on the screen to create a box

; }; diff --git a/src/Views/Main.tsx b/src/Views/Main.tsx index c2c7e3d..e52b8f2 100644 --- a/src/Views/Main.tsx +++ b/src/Views/Main.tsx @@ -29,7 +29,7 @@ const Main: React.FC = (props) => { // Add it to the workbench so that it appears on the map terria.workbench.add(boxItem); } - }, []); + }, [terria]); // WorkflowPanel opens as a left-side panel replacein the Workbench // It can be used to implement custom workflow UIs diff --git a/src/index.ts b/src/index.ts index e26aefb..431fa8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { TerriaPluginContext } from "terriajs-plugin-api"; import Box3dCatalogItem from "./Models/Box3dCatalogItem"; +import withSvgSprite from "../lib/withSvgSprite"; export const toolId = "3d-box-tool"; @@ -14,6 +15,7 @@ const plugin: TerriaPlugin = { description: "A sample plugin that provides a tool for drawing a 3D Box and viewing its measurements.", version: "0.0.1", + register({ viewState }: TerriaPluginContext) { // Register our custom catalog item with Terria CatalogMemberFactory.register(Box3dCatalogItem.type, Box3dCatalogItem); @@ -34,4 +36,4 @@ const plugin: TerriaPlugin = { } }; -export default plugin; +export default withSvgSprite(plugin); diff --git a/tsconfig.json b/tsconfig.json index 123e3ba..82100e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,31 +3,21 @@ "module": "esNext", "target": "es6", "moduleResolution": "node", - "outDir": "dist/", - "rootDir": "src/", + "outDir": "build/tsc", "jsx": "react", "experimentalDecorators": true, "emitDecoratorMetadata": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, - "allowJs": true, - "checkJs": false, "strict": true, + // Set allowJs to true if you want to use plain js/jsx modules - also enable checkJs + "allowJs": false, + "checkJs": false, + // Declaration emit does not work currently when using terriajs models. + "declaration": false, "esModuleInterop": true, - //"skipLibCheck": true - // Although this can result in subtle bugs, they are required for us to - // ignore TS errors on js files inside terria. We'll get rid of them when - // we have a proper terria bundle with type declarations. - //"noImplicitAny": false, - //"strictNullChecks": false, - // Should these thirdparty types be included in terriajs tsconfig "types" settings, - // so that we can avoid specifying it here? - // Refer: https://www.typescriptlang.org/tsconfig#types - "typeRoots": [ - "../../node_modules/terriajs/lib/ThirdParty" - //"../../node_modules" - // "node_modules" - ] + "useDefineForClassFields": true, + "typeRoots": ["../../node_modules/terriajs/lib/ThirdParty"] }, - "include": ["./src/**/*"] + "include": ["./src", "./lib", "./specs"] } diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..215a081 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,3 @@ +import { TerriaPlugin } from "terriajs-plugin-api"; +declare const plugin: TerriaPlugin; +export default plugin;