Repository: ProjectEvergreen/greenwood (js)

Issue:
Create a plugin for import JSX plugin (using WCC)
## Type of Change
- [x] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [ ] Bug
- [ ] Other (please clarify below)

## Summary
With the newest release of [WCC@0.6.0](https://github.com/ProjectEvergreen/wcc/releases/tag/0.6.0), it now supports using JSX inside custom element definitions, with runtime overhead.  It is still very experimental, but would be nice to at least lay the ground work for this now.

## Details
It should be pretty easy to implement, you can see an example working [here](https://github.com/thescientist13/greenwood-counter-jsx/blob/master/greenwood-plugin-import-jsx.js):
```js
import { load } from 'wc-compiler/src/jsx-loader.js';
import { ResourceInterface } from '@greenwood/cli/src/lib/resource-interface.js';
import { pathToFileURL } from 'url';

class JsxResource extends ResourceInterface {
  constructor(compilation, options) {
    super(compilation, options);
    this.extensions = ['.jsx'];
    this.contentType = 'text/javascript';
  }

  async serve(url) {
    return new Promise(async (resolve, reject) => {
      try {
        const result = await load(pathToFileURL(url));

        resolve({
          body: result.source,
          contentType: this.contentType
        });
      } catch (e) {
        reject(e);
      }
    });
  }
}

const greenwoodPluginImportJsx = (options = {}) => {
  return [{
    type: 'resource',
    name: 'plugin-import-jsx:resource',
    provider: (compilation) => new JsxResource(compilation, options)
  }];
};

export {
  greenwoodPluginImportJsx
};
```

Will also want to bump to latest version of WCC as well.

Pull request:
Feature/issue 972 WCC JSX import plugin
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #972 

## Summary of Changes
1. Created a custom plugin for using JSX <> WCC
1. Upgrade to latest version of WCC
1. Add SSR prerender and bundling test cases
1. Add README and document on _Custom Plugins_ docs page

Patch:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 51a7084e..a581d6cd 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -45,7 +45,7 @@
     "remark-rehype": "^7.0.0",
     "rollup": "^2.58.0",
     "unified": "^9.2.0",
-    "wc-compiler": "~0.6.1"
+    "wc-compiler": "~0.7.0"
   },
   "devDependencies": {
     "@babel/runtime": "^7.10.4",
diff --git a/packages/plugin-import-jsx/README.md b/packages/plugin-import-jsx/README.md
new file mode 100644
index 00000000..8e6b372a
--- /dev/null
+++ b/packages/plugin-import-jsx/README.md
@@ -0,0 +1,61 @@
+# @greenwood/plugin-import-jsx
+
+## Overview
+Enables usage of `import` syntax for loading [JSX rendering Web Components](https://merry-caramel-524e61.netlify.app/docs/#jsx) compatible with [**WCC**](https://github.com/ProjectEvergreen/wcc).  (This is _**not**_ React JSX!)
+
+> This package assumes you already have `@greenwood/cli` installed.
+
+## Installation
+You can use your favorite JavaScript package manager to install this package.
+
+_examples:_
+```bash
+# npm
+npm install @greenwood/plugin-import-jsx --save-dev
+
+# yarn
+yarn add @greenwood/plugin-import-jsx --dev
+```
+
+## Usage
+Add this plugin to your _greenwood.config.js_.
+
+```javascript
+import { greenwoodPluginImportJsx } from '@greenwood/plugin-import-jsx';
+
+export default {
+  ...
+
+  plugins: [
+    greenwoodPluginImportJsx()
+  ]
+}
+```
+
+This will then allow you to use `import` to include [WCC]() compatible JSX rendering Web Components.
+```js
+export default class FooterComponent extends HTMLElement {
+  connectedCallback() {
+    this.render();
+  }
+
+  render() {
+    return (
+      <footer>
+        <h4>My Blog</h4>
+      </footer>
+    );
+  }
+}
+
+customElements.define('app-footer', FooterComponent);
+```
+
+A couple notes:
+- For SSR and `prerender` use cases, [follow these steps](/docs/server-rendering/#custom-imports-experimental)
+- For client side / browser code specifically, it is recommended to append `?type=jsx`, e.g.
+  ```js
+  import '../path/to/footer.jsx?type=jsx';
+  ```
+
+> _The plan is to coalesce around [import assertions](https://github.com/ProjectEvergreen/greenwood/issues/923) in time for the v1.0 release so the same standard syntax can be used on the client and the server._
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/package.json b/packages/plugin-import-jsx/package.json
new file mode 100644
index 00000000..d77a81e4
--- /dev/null
+++ b/packages/plugin-import-jsx/package.json
@@ -0,0 +1,33 @@
+{
+  "name": "@greenwood/plugin-import-jsx",
+  "version": "0.28.0-alpha.4",
+  "description": "A Greenwood plugin to write JSX rendering Web Components compatible with WCC.",
+  "repository": "https://github.com/ProjectEvergreen/greenwood",
+  "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-jsx",
+  "author": "Owen Buckley <owen@thegreenhouse.io>",
+  "license": "MIT",
+  "keywords": [
+    "Greenwood",
+    "JSX",
+    "Full Stack framework",
+    "NodeJS",
+    "WCC"
+  ],
+  "main": "src/index.js",
+  "type": "module",
+  "files": [
+    "src/"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "peerDependencies": {
+    "@greenwood/cli": "^0.28.0-alpha.4"
+  },
+  "dependencies": {
+    "wc-compiler": "~0.7.0"
+  },
+  "devDependencies": {
+    "@greenwood/cli": "^0.28.0-alpha.4"
+  }
+}
diff --git a/packages/plugin-import-jsx/src/index.js b/packages/plugin-import-jsx/src/index.js
new file mode 100644
index 00000000..d4c29993
--- /dev/null
+++ b/packages/plugin-import-jsx/src/index.js
@@ -0,0 +1,43 @@
+/*
+ * 
+ * Compile Web Components rendering with JSX using wc-compiler.
+ *
+ */
+import escodegen from 'escodegen';
+import { parseJsx } from 'wc-compiler/src/jsx-loader.js';
+import { ResourceInterface } from '@greenwood/cli/src/lib/resource-interface.js';
+
+class ImportJsxResource extends ResourceInterface {
+  constructor(compilation, options) {
+    super(compilation, options);
+    this.extensions = ['jsx'];
+    this.contentType = 'text/javascript';
+  }
+
+  async shouldServe(url) {
+    const { pathname } = url;
+
+    return pathname.split('.').pop() === this.extensions[0] && (url.searchParams.has('type') && url.searchParams.get('type') === this.extensions[0]);
+  }
+
+  async serve(url) {
+    // TODO refactor when WCC refactors
+    // https://github.com/ProjectEvergreen/wcc/issues/116
+    const tree = parseJsx(url);
+    const result = escodegen.generate(tree);
+
+    return new Response(result, {
+      headers: new Headers({
+        'Content-Type': this.contentType
+      })
+    });
+  }
+}
+
+const greenwoodPluginImportJsx = (options = {}) => [{
+  type: 'resource',
+  name: 'plugin-import-jsx:resource',
+  provider: (compilation) => new ImportJsxResource(compilation, options)
+}];
+
+export { greenwoodPluginImportJsx };
\ No newline at end of file
diff --git a/www/pages/plugins/custom-plugins.md b/www/pages/plugins/custom-plugins.md
index 2d648a37..f6ca7af9 100644
--- a/www/pages/plugins/custom-plugins.md
+++ b/www/pages/plugins/custom-plugins.md
@@ -18,6 +18,7 @@ The available plugins built and maintained by Greenwood team and contributors ha
 | [Import CommonJs](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-commonjs) | Enables usage of `import` syntax for loading CommonJS modules from _node_modules_. |
 | [Import CSS](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-css) | Enables usage of `import` syntax for loading CSS files. |
 | [Import JSON](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-json) | Enables usage of `import` syntax for loading JSON files. |
+| [Import JSX](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-jsx) | Enables usage of `import` syntax for loading JSX rendering Web Components compatible with [**WCC**](https://github.com/ProjectEvergreen/wcc). |
 | [Polyfills](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-polyfills) | Although most modern browsers now support the APIs that make up Web Component, for older browsers use this plugin to Let Greenwood automatically take care of progressively loading polyfills for you. |
 | [PostCSS](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-postcss) | Allows usage of [**PostCSS**](https://postcss.org/) plugins and configuration in your project. |
 | [Renderer Lit](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-renderer-lit) | A server-side rendering plugin for Lit based Greenwood projects. |
diff --git a/yarn.lock b/yarn.lock
index a246e608..f23a3e35 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11679,10 +11679,10 @@ w3c-xmlserializer@^2.0.0:
   dependencies:
     xml-name-validator "^3.0.0"
 
-wc-compiler@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/wc-compiler/-/wc-compiler-0.6.1.tgz#acae127206c4fb028dccd530efa948c91efa7c20"
-  integrity sha512-riK9RNwus8U1O9VFjZ3Zph8nFGj6ivzWGQbWHZxkHkKbhS1/W/mwIw4e+20V9Uy8CsLy3u0j2HLDOxzAro5pJg==
+wc-compiler@~0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/wc-compiler/-/wc-compiler-0.7.0.tgz#0ceac8af462502ff69a80eb3861f75eaa39c37f1"
+  integrity sha512-jWORep0UIvnZi8+4ek2ZfX1TJik3B5QW/38MZ6UZN/st3TORt13vk50Arr2Rc3zlpam5AGE3Oxi7no8WmEBBXQ==
   dependencies:
     acorn "^8.7.0"
     acorn-jsx "^5.3.2"


Test patch:
diff --git a/packages/plugin-import-jsx/test/cases/default/default.prerender.spec.js b/packages/plugin-import-jsx/test/cases/default/default.prerender.spec.js
new file mode 100644
index 00000000..a186a63a
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/default.prerender.spec.js
@@ -0,0 +1,87 @@
+/*
+ * Use Case
+ * Run Greenwood with greenwoodPluginImportJsx plugin with bundling of JSX for the client side using wc-compiler.
+ * 
+ * User Result
+ * Should generate a static Greenwood build with JSX properly bundled.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * import { greenwoodPluginImportJsx } from '@greenwood/plugin-import-jsx';
+ *
+ * {
+ *   plugins: [{
+ *     greenwoodPluginImportJsx()
+ *   }]
+ * }
+ *
+ * User Workspace
+ * package.json
+ * src/
+ *   components/
+ *     footer.jsx
+ *   pages/
+ *     index.md
+ *   templates/
+ *     app.html
+ */
+import chai from 'chai';
+import glob from 'glob-promise';
+import { JSDOM } from 'jsdom';
+import path from 'path';
+import { runSmokeTest } from '../../../../../test/smoke-test.js';
+import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js';
+import { Runner } from 'gallinago';
+import { fileURLToPath, URL } from 'url';
+
+const expect = chai.expect;
+
+describe('(Experimental) Build Greenwood With: ', function() {
+  const LABEL = 'Import JSX Plugin for client side bundling';
+  const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+  const outputPath = fileURLToPath(new URL('.', import.meta.url));
+  let runner;
+
+  before(async function() {
+    this.context = {
+      publicDir: path.join(outputPath, 'public')
+    };
+    runner = new Runner(false, true);
+  });
+
+  describe(LABEL, function() {
+    before(async function() {
+      await runner.setup(outputPath, getSetupFiles(outputPath));
+      await runner.runCommand(cliPath, 'build');
+    });
+
+    runSmokeTest(['public'], LABEL);
+
+    describe('bundling JSX using ESM (import)', function() {
+      let dom;
+      let scripts;
+
+      before(async function() {
+        scripts = await glob.promise(path.join(this.context.publicDir, '*.js'));
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
+      });
+
+      it('should contain one bundled output file in the output directory', function() {
+        expect(scripts.length).to.be.equal(1);
+      });
+
+      it('should have the expected <script> tag in the <head> for the <app-footer> component', function() {
+        const scripts = dom.window.document.querySelectorAll('head > script');
+
+        expect(scripts.length).to.equal(1);
+      });
+    });
+  });
+
+  after(function() {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+
+});
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/default/greenwood.config.js b/packages/plugin-import-jsx/test/cases/default/greenwood.config.js
new file mode 100644
index 00000000..1a8814d6
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/greenwood.config.js
@@ -0,0 +1,7 @@
+import { greenwoodPluginImportJsx } from '../../../src/index.js';
+
+export default {
+  plugins: [
+    ...greenwoodPluginImportJsx()
+  ]
+};
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/default/package.json b/packages/plugin-import-jsx/test/cases/default/package.json
new file mode 100644
index 00000000..796aafbc
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/package.json
@@ -0,0 +1,5 @@
+{
+  "name": "test-plugin-import-jsx-build-default",
+  "type": "module",
+  "version": "0.27.0-alpha.0"
+}
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/default/src/components/footer.jsx b/packages/plugin-import-jsx/test/cases/default/src/components/footer.jsx
new file mode 100644
index 00000000..e128ffbc
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/src/components/footer.jsx
@@ -0,0 +1,15 @@
+export default class FooterComponent extends HTMLElement {
+  connectedCallback() {
+    this.render();
+  }
+
+  render() {
+    return (
+      <footer>
+        <h4>My Blog</h4>
+      </footer>
+    );
+  }
+}
+
+customElements.define('app-footer', FooterComponent);
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/default/src/pages/index.md b/packages/plugin-import-jsx/test/cases/default/src/pages/index.md
new file mode 100644
index 00000000..82c330a8
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/src/pages/index.md
@@ -0,0 +1,3 @@
+# Home Page
+
+Welcome to the home page!
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/default/src/templates/app.html b/packages/plugin-import-jsx/test/cases/default/src/templates/app.html
new file mode 100644
index 00000000..970f7016
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/default/src/templates/app.html
@@ -0,0 +1,12 @@
+<html>
+  <head>
+    <title>My Personal Website</title>
+    <script type="module" src="../components/footer.jsx?type=jsx"></script>
+  </head>
+
+  <body>
+    <page-outlet></page-outlet>
+    <app-footer></app-footer>
+  </body>
+
+</html>
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/exp-build.prerender.spec.js b/packages/plugin-import-jsx/test/cases/exp-build.prerender/exp-build.prerender.spec.js
new file mode 100644
index 00000000..d58f08bd
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/exp-build.prerender.spec.js
@@ -0,0 +1,89 @@
+/*
+ * Use Case
+ * Run Greenwood with greenwoodPluginImportJsx plugin with prerendering of JSX on the server side using wc-compiler.
+ * 
+ * User Result
+ * Should generate a static Greenwood build with JSX properly prerendered.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * import { greenwoodPluginImportJsx } from '@greenwood/plugin-import-jsx';
+ *
+ * {
+ *   plugins: [{
+ *     greenwoodPluginImportJsx()
+ *   }]
+ * }
+ *
+ * User Workspace
+ * package.json
+ * src/
+ *   components/
+ *     footer.jsx
+ *   pages/
+ *     index.md
+ *   templates/
+ *     app.html
+ */
+import chai from 'chai';
+import glob from 'glob-promise';
+import { JSDOM } from 'jsdom';
+import path from 'path';
+import { runSmokeTest } from '../../../../../test/smoke-test.js';
+import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js';
+import { Runner } from 'gallinago';
+import { fileURLToPath, URL } from 'url';
+
+const expect = chai.expect;
+
+describe('(Experimental) Build Greenwood With: ', function() {
+  const LABEL = 'Import JSX Plugin with static pre-rendering';
+  const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+  const outputPath = fileURLToPath(new URL('.', import.meta.url));
+  let runner;
+
+  before(async function() {
+    this.context = {
+      publicDir: path.join(outputPath, 'public')
+    };
+    runner = new Runner(false, true);
+  });
+
+  describe(LABEL, function() {
+    before(async function() {
+      await runner.setup(outputPath, getSetupFiles(outputPath));
+      await runner.runCommand(cliPath, 'build');
+    });
+
+    runSmokeTest(['public'], LABEL);
+
+    describe('importing JSX using ESM (import)', function() {
+      let dom;
+      let scripts;
+
+      before(async function() {
+        scripts = await glob.promise(path.join(this.context.publicDir, '*.js'));
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
+      });
+
+      it('should contain one bundled output file in the output directory', function() {
+        expect(scripts.length).to.be.equal(0);
+      });
+
+      it('should have the expected content from importing values from package.json in index.html', function() {
+        const headings = dom.window.document.querySelectorAll('app-footer footer h4');
+        const year = new Date().getFullYear();
+
+        expect(headings.length).to.equal(1);
+        expect(headings[0].textContent.trim()).to.equal(`My Blog - ${year}`);
+      });
+    });
+  });
+
+  after(function() {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+
+});
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/greenwood.config.js b/packages/plugin-import-jsx/test/cases/exp-build.prerender/greenwood.config.js
new file mode 100644
index 00000000..66762bfc
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/greenwood.config.js
@@ -0,0 +1,8 @@
+import { greenwoodPluginImportJsx } from '../../../src/index.js';
+
+export default {
+  prerender: true,
+  plugins: [
+    ...greenwoodPluginImportJsx()
+  ]
+};
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/package.json b/packages/plugin-import-jsx/test/cases/exp-build.prerender/package.json
new file mode 100644
index 00000000..e40d098c
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/package.json
@@ -0,0 +1,5 @@
+{
+  "name": "test-plugin-import-jsx-build-prerender",
+  "type": "module",
+  "version": "0.27.0-alpha.0"
+}
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/components/footer.jsx b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/components/footer.jsx
new file mode 100644
index 00000000..e02458a3
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/components/footer.jsx
@@ -0,0 +1,17 @@
+export default class FooterComponent extends HTMLElement {
+  connectedCallback() {
+    this.render();
+  }
+
+  render() {
+    const year = new Date().getFullYear();
+
+    return (
+      <footer>
+        <h4>My Blog - {year}</h4>
+      </footer>
+    );
+  }
+}
+
+customElements.define('app-footer', FooterComponent);
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/pages/index.md b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/pages/index.md
new file mode 100644
index 00000000..82c330a8
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/pages/index.md
@@ -0,0 +1,3 @@
+# Home Page
+
+Welcome to the home page!
\ No newline at end of file
diff --git a/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/templates/app.html b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/templates/app.html
new file mode 100644
index 00000000..b17ccc36
--- /dev/null
+++ b/packages/plugin-import-jsx/test/cases/exp-build.prerender/src/templates/app.html
@@ -0,0 +1,12 @@
+<html>
+  <head>
+    <title>My Personal Website</title>
+    <script type="module" src="../components/footer.jsx?type=jsx" data-gwd-opt="static"></script>
+  </head>
+
+  <body>
+    <page-outlet></page-outlet>
+    <app-footer></app-footer>
+  </body>
+
+</html>
\ No newline at end of file