├── .c8rc.json
├── .editorconfig
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── 1-feature-request.yml
│ ├── 2-enhancement.yml
│ ├── 3-bug-report.yml
│ ├── 4-chore.yml
│ └── config.yml
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── ci-jsx-win.yml
│ ├── ci-jsx.yml
│ ├── ci-win.yml
│ └── ci.yml
├── .gitignore
├── .ls-lint.yml
├── .npmrc
├── .nvmrc
├── CONTRIBUTING.md
├── README.md
├── build.js
├── docs
├── assets
│ ├── favicon.ico
│ └── wcc-logo.png
├── components
│ ├── footer.js
│ ├── header.js
│ └── navigation.js
├── layout.js
└── pages
│ ├── docs.md
│ ├── examples.md
│ └── index.md
├── eslint.config.js
├── netlify.toml
├── package-lock.json
├── package.json
├── sandbox.js
├── sandbox
├── components
│ ├── card.js
│ ├── card.jsx
│ ├── counter-dsd.jsx
│ ├── counter.jsx
│ ├── greeting.ts
│ ├── header.js
│ ├── header.jsx
│ └── picture-frame.js
└── index.html
├── src
├── dom-shim.js
├── index.d.ts
├── jsx-loader.js
├── register.js
└── wcc.js
├── test-loader.js
├── test-register.js
├── test
└── cases
│ ├── attributes
│ ├── attributes.spec.js
│ └── src
│ │ ├── components
│ │ └── counter.js
│ │ └── index.js
│ ├── children-and-slots
│ ├── children-and-slots.spec.js
│ └── src
│ │ ├── components
│ │ └── paragraph.js
│ │ └── pages
│ │ └── index.js
│ ├── constructable-stylesheet
│ ├── constructabe-stylesheet.spec.js
│ └── src
│ │ ├── components
│ │ └── header
│ │ │ └── header.js
│ │ └── pages
│ │ └── index.js
│ ├── constructor-props
│ ├── constructor-props.spec.js
│ └── src
│ │ └── index.js
│ ├── create-document-fragment
│ ├── create-document-fragment.spec.js
│ └── src
│ │ └── index.js
│ ├── custom-extension
│ ├── custom-extension.spec.js
│ └── src
│ │ ├── banner.css
│ │ ├── banner.js
│ │ └── footer.js
│ ├── element-props
│ ├── element-props.spec.js
│ └── src
│ │ ├── components
│ │ ├── prop-passer.js
│ │ └── prop-receiver.js
│ │ ├── index.js
│ │ └── renderer.js
│ ├── empty
│ ├── empty.spec.js
│ └── src
│ │ └── empty.js
│ ├── event-listener
│ ├── event-listener.spec.js
│ └── src
│ │ └── my-component.js
│ ├── full-document-component
│ ├── full-document-component.spec.js
│ └── src
│ │ └── index.js
│ ├── get-data
│ ├── get-data.spec.js
│ └── src
│ │ ├── components
│ │ └── counter.js
│ │ └── index.js
│ ├── html-web-components
│ ├── expected.html
│ ├── html-web-components.spec.js
│ └── src
│ │ ├── components
│ │ ├── caption.js
│ │ └── picture-frame.js
│ │ └── pages
│ │ └── index.js
│ ├── import-attributes
│ ├── import-attributes.spec.js
│ └── src
│ │ ├── components
│ │ └── header
│ │ │ ├── data.json
│ │ │ └── header.js
│ │ └── pages
│ │ └── index.js
│ ├── jsx-inferred-observability
│ ├── fixtures
│ │ ├── attribute-changed-callback.txt
│ │ └── get-observed-attributes.txt
│ ├── jsx-inferred-obsevability.spec.js
│ └── src
│ │ └── counter.jsx
│ ├── jsx-shadow-dom
│ ├── jsx-shadow-dom.spec.js
│ └── src
│ │ └── heading.jsx
│ ├── jsx
│ ├── jsx.spec.js
│ └── src
│ │ ├── badge.jsx
│ │ └── counter.jsx
│ ├── light-dom
│ ├── light-dom.spec.js
│ └── src
│ │ ├── components
│ │ ├── header.js
│ │ └── navigation.js
│ │ └── pages
│ │ └── index.js
│ ├── metadata
│ ├── metadata.spec.js
│ └── src
│ │ ├── components
│ │ ├── footer.js
│ │ ├── header.js
│ │ └── navigation.js
│ │ └── pages
│ │ └── index.js
│ ├── nested-elements
│ ├── nested-elements.spec.js
│ └── src
│ │ ├── assets
│ │ └── navigation.js
│ │ ├── components
│ │ ├── footer.js
│ │ └── header.js
│ │ └── pages
│ │ └── index.js
│ ├── no-define
│ ├── no-define.spec.js
│ └── src
│ │ ├── footer.js
│ │ ├── header.js
│ │ └── no-define.js
│ ├── no-export
│ ├── no-export.spec.js
│ └── src
│ │ ├── footer.js
│ │ ├── header.js
│ │ └── no-export.js
│ ├── no-wrapping-entry-tag
│ ├── no-wrapping-entry-tag.spec.js
│ └── src
│ │ └── no-wrap.js
│ ├── node-modules
│ ├── node-modules.spec.js
│ └── src
│ │ ├── components
│ │ └── events-list.js
│ │ └── index.js
│ ├── render-from-html
│ ├── render-from-html.spec.js
│ └── src
│ │ └── components
│ │ ├── header.js
│ │ └── navigation.js
│ ├── serializable-shadow-roots
│ ├── serializable-shadow-roots.spec.js
│ └── src
│ │ ├── components
│ │ ├── serializable-non-ssr-component.js
│ │ ├── serializable-ssr-component.js
│ │ ├── unserializable-non-ssr-component.js
│ │ └── unserializable-ssr-component.js
│ │ └── index.js
│ ├── set-attribute
│ ├── set-attribute.spec.js
│ └── src
│ │ └── index.js
│ ├── shadowrootmode
│ ├── shadowrootmode.spec.js
│ └── src
│ │ ├── components
│ │ ├── closed-shadow-component.js
│ │ └── open-shadow-component.js
│ │ └── index.js
│ ├── single-element
│ ├── single-element.spec.js
│ └── src
│ │ └── footer.js
│ └── ts
│ ├── src
│ ├── app.ts
│ └── greeting.ts
│ └── ts.spec.js
└── tsconfig.json
/.c8rc.json:
--------------------------------------------------------------------------------
1 | {
2 | "all": true,
3 |
4 | "include": [
5 | "src/**/**/*.js"
6 | ],
7 |
8 | "reporter": [
9 | "cobertura",
10 | "html",
11 | "text",
12 | "text-summary"
13 | ],
14 |
15 | "checkCoverage": true,
16 |
17 | "statements": 90,
18 | "branches": 85,
19 | "functions": 85,
20 | "lines": 90,
21 |
22 | "watermarks": {
23 | "statements": [75, 85],
24 | "branches": [75, 85],
25 | "functions": [75, 85],
26 | "lines": [75, 85]
27 | }
28 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = false
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 |
3 | *.bat text eol=crlf
4 | *.sh text eol=lf
5 |
6 | *.jpg binary
7 | *.jpeg binary
8 | *.png binary
9 | *.avif binary
10 | *.webp binary
11 | *.gif binary
12 | *.ico binary
13 | *.woff binary
14 | *.woff2 binary
15 | *.ttf binary
16 | *.eot binary
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/1-feature-request.yml:
--------------------------------------------------------------------------------
1 | name: 💡 Feature Request
2 | description: Submit a new feature request for WCC.
3 | title: "Support x in WCC"
4 | labels: ["feature"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for submitting a feature request to WCC!
10 | - type: textarea
11 | id: summary
12 | attributes:
13 | label: Motivation
14 | description: Please provide a summary of the feature you are requesting and use case(s).
15 | placeholder: It would be great if WCC could do...
16 | validations:
17 | required: true
18 | - type: textarea
19 | id: Motivation
20 | attributes:
21 | label: Technical Design
22 | description: Detail usage within a WCC project and the API design you have in mind.
23 | placeholder: I want to use the feature like this...
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: additional-context
28 | attributes:
29 | label: Additional Context
30 | description: Please provide any additional details, links, or resources that may be helpful for consideration of this feature.
31 | validations:
32 | required: false
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2-enhancement.yml:
--------------------------------------------------------------------------------
1 | name: ✨ Enhancement
2 | description: Improve an existing feature in WCC
3 | title: "Refactor Feature X to use..."
4 | labels: ["enhancement"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for wanting to improve WCC!
10 | - type: textarea
11 | id: current-state
12 | attributes:
13 | label: Current State
14 | description: Please provide a summary of the current state of a feature or capability of WCC.
15 | placeholder: Currently feature x works like this...
16 | validations:
17 | required: true
18 | - type: textarea
19 | id: desired-state
20 | attributes:
21 | label: Desired State
22 | description: Describe what the desired state could look like.
23 | placeholder: Feature x could work like this instead...
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: additional-context
28 | attributes:
29 | label: Additional Context
30 | description: Please provide any additional details, links, or resources that may be helpful for consideration of this enhancement.
31 | validations:
32 | required: false
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/3-bug-report.yml:
--------------------------------------------------------------------------------
1 | name: 🐛 Bug Report
2 | description: File a bug report.
3 | title: "Description of the issue"
4 | labels: ["bug"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
10 | - type: textarea
11 | id: what-happened
12 | attributes:
13 | label: What happened?
14 | description: Please provide a summary of the issue or error you are seeing.
15 | placeholder: Tell us what you see!
16 | validations:
17 | required: true
18 | - type: textarea
19 | id: reproduction-steps
20 | attributes:
21 | label: Steps to reproduce
22 | description: Please provide a set of steps to reproduce the issue and ideally a link to a minimal reproduction.
23 | placeholder: |
24 | 1. I installed x
25 | 2. I added y
26 | 3. I ran z
27 | validations:
28 | required: true
29 | - type: textarea
30 | id: environment
31 | attributes:
32 | label: Environment
33 | description: Please provide details of the environment your running in.
34 | placeholder: WCC version, operating system, runtime version, browser, etc
35 | validations:
36 | required: true
37 | - type: textarea
38 | id: additional-context
39 | attributes:
40 | label: Additional Context
41 | description: Please provide any additional relevant details, screenshots, links, or resources that would be helpful.
42 | validations:
43 | required: false
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/4-chore.yml:
--------------------------------------------------------------------------------
1 | name: ⚙️ Chore
2 | description: Technical tasks not related to WCC
3 | title: "Update repo configuration, maintaining dependencies, etc"
4 | labels: ["chore"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for helping out!
10 | - type: textarea
11 | id: task
12 | attributes:
13 | label: Task
14 | description: Description of the task at hand
15 | placeholder: Update ESLint config, change test runner, etc...
16 | validations:
17 | required: true
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
3 | contact_links:
4 | - name: GitHub Discussions
5 | url: https://github.com/ProjectEvergreen/wcc/discussions
6 | about: Please ask and answer questions here or discuss new ideas for the project.
7 | - name: Discord
8 | url: https://www.greenwoodjs.dev/discord/
9 | about: Chat about Greenwood and join the community!
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
5 |
6 | ## Related Issue
7 |
8 |
9 | ## Summary of Changes
10 |
--------------------------------------------------------------------------------
/.github/workflows/ci-jsx-win.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration Windows (Experimental)
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 |
7 | build:
8 | runs-on: windows-latest
9 |
10 | strategy:
11 | matrix:
12 | node: [22]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node }}
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node }}
20 | - name: Installing project dependencies
21 | run: |
22 | npm ci
23 | - name: Lint
24 | run: |
25 | npm run lint
26 | - name: Test
27 | run: |
28 | npm run test:jsx
29 | - name: Build
30 | run: |
31 | npm run docs:build
--------------------------------------------------------------------------------
/.github/workflows/ci-jsx.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration (Experimental)
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 |
7 | build:
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node: [22]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node }}
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node }}
20 | - name: Installing project dependencies
21 | run: |
22 | npm ci
23 | - name: Lint
24 | run: |
25 | npm run lint
26 | - name: Test
27 | run: |
28 | npm run test:jsx
29 | - name: Build
30 | run: |
31 | npm run docs:build
--------------------------------------------------------------------------------
/.github/workflows/ci-win.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration Windows
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 |
7 | build:
8 | runs-on: windows-latest
9 |
10 | strategy:
11 | matrix:
12 | node: [18, 20, 22]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node }}
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node }}
20 | - name: Installing project dependencies
21 | run: |
22 | npm ci
23 | - name: Lint
24 | run: |
25 | npm run lint
26 | - name: Test
27 | run: |
28 | npm test
29 | - name: Build
30 | run: |
31 | npm run docs:build
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 |
7 | build:
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node: [18, 20, 22]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node }}
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node }}
20 | - name: Installing project dependencies
21 | run: |
22 | npm ci
23 | - name: Lint
24 | run: |
25 | npm run lint
26 | - name: Check Types
27 | run: |
28 | npm run lint:types
29 | - name: Test
30 | run: |
31 | npm test
32 | - name: Build
33 | run: |
34 | npm run docs:build
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .vscode/
3 | coverage/
4 | dist/
5 | node_modules/
--------------------------------------------------------------------------------
/.ls-lint.yml:
--------------------------------------------------------------------------------
1 | ls:
2 | .spec.js: kebabcase
3 | .config.js: kebabcase
4 | .js: kebabcase
5 | .*.json: kebabcase
6 | .css: kebabcase
7 | .html: kebabcase
8 | .ico: kebabcase
9 | .jpg: kebabcase
10 | .png: kebabcase
11 | .svg: kebabcase
12 | .tff: kebabcase
13 | .woff: kebabcase
14 | .woff2: kebabcase
15 |
16 | ignore:
17 | - .git
18 | - coverage
19 | - node_modules
20 | - dist
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22.12.0
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Welcome!
4 |
5 | We're excited for your interest in WCC, and maybe even your contribution!
6 |
7 | ## Setup
8 |
9 | To develop for the project, you'll want to follow these steps:
10 |
11 | 1. Have [NodeJS LTS](https://nodejs.org) and / or use `nvm` (see below)
12 | 1. Clone the repository
13 | 1. Run `npm ci`
14 |
15 | ### NVM
16 | If you have **NVM (Node Version Manager)** installed, get the recommend node version:
17 |
18 | - Windows: [NVM for Windows](https://github.com/coreybutler/nvm-windows/releases)
19 | - Linux/MacOS: [Node Version Manager](https://github.com/nvm-sh/nvm)
20 |
21 | And then running `nvm use`
22 |
23 | ```sh
24 | $ nvm use
25 | ```
26 |
27 | ## Local Development
28 |
29 | The local development flow is based around building the docs website, using `wcc` in an SSG based workflow, and running tests.
30 |
31 | ### Commands
32 |
33 | There are the main tasks, but you can see them all listed in _package.json#scripts_.
34 |
35 | - `npm run docs:dev` - Builds the docs site for local development
36 | - `npm start` - Builds a production version of the docs site and serves it locally
37 | - `npm run sandbox` - Starts the sandbox app for live demos and testing
38 | - `npm test` - Run all the tests
39 | - `npm test:tdd` - Run all the tests in watch mode
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Web Components Compiler (WCC)
4 |
5 | [](https://app.netlify.com/sites/merry-caramel-524e61/deploys)
6 | [](https://github.com/ProjectEvergreen/wcc/tags)
7 | [](https://raw.githubusercontent.com/ProjectEvergreen/wcc/master/LICENSE.md)
8 | [](https://nodejs.org/en/about/previous-releases")
9 | [](https://www.greenwoodjs.dev/discord/)
10 |
11 | > _Experimental Web Components compiler. It's Web Components all the way down!_ 🐢
12 |
13 | ## How It Works
14 |
15 | 1. Write a Web Component
16 | ```js
17 | const template = document.createElement('template');
18 |
19 | template.innerHTML = `
20 |
26 |
27 |
30 | `;
31 |
32 | class Footer extends HTMLElement {
33 | connectedCallback() {
34 | if (!this.shadowRoot) {
35 | this.attachShadow({ mode: 'open' });
36 | this.shadowRoot.appendChild(template.content.cloneNode(true));
37 | }
38 | }
39 | }
40 |
41 | export default Footer;
42 |
43 | customElements.define('wcc-footer', Footer);
44 | ```
45 | 1. Run it through the compiler
46 | ```js
47 | import { renderToString } from 'wc-compiler';
48 |
49 | const { html } = await renderToString(new URL('./path/to/component.js', import.meta.url));
50 | ```
51 | 1. Get HTML!
52 | ```html
53 |
54 |
55 |
61 |
62 |
65 |
66 |
67 | ```
68 |
69 | ## Installation
70 |
71 | **WCC** runs on NodeJS and can be installed from npm.
72 |
73 | ```shell
74 | $ npm install wc-compiler --save-dev
75 | ```
76 |
77 | ## Documentation
78 |
79 | See our [website](https://merry-caramel-524e61.netlify.app/) for API docs and examples.
80 |
81 | ## Motivation
82 |
83 | **WCC** is not a static site generator, framework or bundler. It is designed with the intent of being able to produce raw HTML from standards compliant Web Components and easily integrated _into_ a site generator or framework, like [**Greenwood**](https://www.greenwoodjs.dev). The Project Evergreen team also maintains similar integrations for [**Eleventy**](https://github.com/ProjectEvergreen/eleventy-plugin-wcc/) and [Astro](https://github.com/ProjectEvergreen/astro-wcc).
84 |
85 | In addition, **WCC** hopes to provide a surface area to explore patterns around [streaming](https://github.com/ProjectEvergreen/wcc/issues/5), [serverless and edge rendering](https://github.com/thescientist13/web-components-at-the-edge), and as acting as a test bed for the [Web Components Community Groups](https://github.com/webcomponents-cg)'s discussions around community protocols, like [hydration](https://github.com/ProjectEvergreen/wcc/issues/3).
86 |
--------------------------------------------------------------------------------
/build.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import rehypeAutolinkHeadings from 'rehype-autolink-headings';
3 | import rehypePrism from '@mapbox/rehype-prism';
4 | import rehypeSlug from 'rehype-slug';
5 | import rehypeStringify from 'rehype-stringify';
6 | import rehypeRaw from 'rehype-raw';
7 | import remarkParse from 'remark-parse';
8 | import remarkRehype from 'remark-rehype';
9 | import remarkToc from 'remark-toc';
10 | import { unified } from 'unified';
11 |
12 | import { renderToString } from './src/wcc.js';
13 |
14 | async function init() {
15 | const distRoot = './dist';
16 | const pagesRoot = './docs/pages';
17 | const pages = await fs.readdir(new URL(pagesRoot, import.meta.url));
18 | const { html } = await renderToString(new URL('./docs/layout.js', import.meta.url));
19 |
20 | await fs.rm(distRoot, { recursive: true, force: true });
21 | await fs.mkdir(distRoot, { recursive: true });
22 | await fs.mkdir(`${distRoot}/assets`, { recursive: true });
23 |
24 | await fs.copyFile(new URL('./node_modules/prismjs/themes/prism.css', import.meta.url), new URL(`${distRoot}/prism.css`, import.meta.url));
25 | await fs.copyFile(new URL('./node_modules/simple.css/dist/simple.min.css', import.meta.url), new URL(`${distRoot}/simple.min.css`, import.meta.url));
26 | await fs.cp(new URL('./docs/assets', import.meta.url), new URL(`${distRoot}/assets`, import.meta.url), { recursive: true });
27 | await fs.copyFile(new URL('./docs/assets/favicon.ico', import.meta.url), new URL(`${distRoot}/favicon.ico`, import.meta.url));
28 |
29 | for (const page of pages) {
30 | const route = page.replace('.md', '');
31 | const outputPath = route === 'index' ? '' : `${route}/`;
32 | const markdown = await fs.readFile(new URL(`${pagesRoot}/${page}`, import.meta.url), 'utf-8');
33 | const content = (await unified()
34 | .use(remarkParse)
35 | .use(remarkToc, { tight: true })
36 | .use(remarkRehype, { allowDangerousHtml: true })
37 | .use(rehypeSlug)
38 | .use(rehypeRaw)
39 | .use(rehypeAutolinkHeadings)
40 | .use(rehypePrism)
41 | .use(rehypeStringify)
42 | .process(markdown)).value;
43 |
44 | await fs.mkdir(`./dist/${outputPath}`, { recursive: true });
45 | await fs.mkdir(`${distRoot}/${outputPath}`, { recursive: true });
46 |
47 | await fs.writeFile(new URL(`${distRoot}/${outputPath}/index.html`, import.meta.url), `
48 |
49 |
50 |
51 |
52 | WCC - Web Components Compiler
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | ${html.replace(' ', content)}
69 |
70 |
71 |
72 | `.trim());
73 | }
74 | }
75 |
76 | init();
--------------------------------------------------------------------------------
/docs/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ProjectEvergreen/wcc/922a0d0ab2198eb3f60ae4cf73a9d2889778a01d/docs/assets/favicon.ico
--------------------------------------------------------------------------------
/docs/assets/wcc-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ProjectEvergreen/wcc/922a0d0ab2198eb3f60ae4cf73a9d2889778a01d/docs/assets/wcc-logo.png
--------------------------------------------------------------------------------
/docs/components/footer.js:
--------------------------------------------------------------------------------
1 | class Footer extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = this.render();
4 | }
5 |
6 | render() {
7 | return `
8 |
27 |
28 |
33 | `;
34 | }
35 | }
36 |
37 | export {
38 | Footer
39 | };
40 |
41 | customElements.define('wcc-footer', Footer);
--------------------------------------------------------------------------------
/docs/components/header.js:
--------------------------------------------------------------------------------
1 | import './navigation.js';
2 |
3 | class Header extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = this.render();
6 | }
7 |
8 | render() {
9 | return `
10 |
43 |
44 |
64 | `;
65 | }
66 | }
67 |
68 | export {
69 | Header
70 | };
71 |
72 | customElements.define('wcc-header', Header);
73 |
--------------------------------------------------------------------------------
/docs/components/navigation.js:
--------------------------------------------------------------------------------
1 | class Navigation extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = this.render();
4 | }
5 |
6 | render() {
7 | return `
8 |
29 |
30 |
31 |
36 |
37 | `
38 | }
39 | }
40 |
41 | export {
42 | Navigation
43 | };
44 |
45 | customElements.define('wcc-navigation', Navigation);
--------------------------------------------------------------------------------
/docs/layout.js:
--------------------------------------------------------------------------------
1 | import './components/footer.js';
2 | import './components/header.js';
3 |
4 | class Layout extends HTMLElement {
5 | connectedCallback() {
6 | this.innerHTML = this.render();
7 | }
8 |
9 | render() {
10 | return `
11 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | `;
41 | }
42 | }
43 |
44 | export default Layout;
--------------------------------------------------------------------------------
/docs/pages/docs.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | ## Table of contents
4 |
5 | ## API
6 |
7 | ### renderToString
8 |
9 | This function takes a `URL` "entry point" to a JavaScript file that defines a custom element, and returns the static HTML output of its rendered contents.
10 |
11 |
12 | ```js
13 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
14 | ```
15 |
16 | ```js
17 | // index.js
18 | import './components/footer.js';
19 | import './components/header.js';
20 |
21 | const template = document.createElement('template');
22 |
23 | template.innerHTML = `
24 |
29 |
30 |
31 |
32 |
33 | My Blog Post
34 |
35 |
36 |
37 | `;
38 |
39 | class Home extends HTMLElement {
40 |
41 | connectedCallback() {
42 | if (!this.shadowRoot) {
43 | this.attachShadow({ mode: 'open' });
44 | this.shadowRoot.appendChild(template.content.cloneNode(true));
45 | }
46 | }
47 | }
48 |
49 | export default Home;
50 | ```
51 |
52 | You can also manually set `innerHTML` of Shadow Root if you don't want to use a template element
53 |
54 | ```js
55 | // index.js
56 | import './components/footer.js';
57 | import './components/header.js';
58 |
59 | class Home extends HTMLElement {
60 |
61 | connectedCallback() {
62 | if (!this.shadowRoot) {
63 | this.attachShadow({ mode: 'open' });
64 | this.shadowRoot.innerHTML = `
65 |
70 |
71 |
72 |
73 |
74 | My Website
75 |
76 |
77 |
78 | `;
79 | }
80 | }
81 | }
82 |
83 | export default Home;
84 | ```
85 |
86 | > _**Note**: **WCC** will wrap or not wrap your _entry point's HTML_ in a custom element tag if you do or do not, respectively, include a `customElements.define` in your entry point. **WCC** will use the tag name you define as the custom element tag name in the generated HTML._
87 | >
88 | > You can opt-out of this by passing `false` as the second parameter to `renderToString`.
89 | >
90 | > ```js
91 | > const { html } = await renderToString(new URL('...'), false);
92 | > ```
93 |
94 | ### renderFromHTML
95 |
96 | This function takes a string of HTML and an array of any top-level custom elements used in the HTML, and returns the static HTML output of the rendered content.
97 |
98 |
99 | ```js
100 | const { html } = await renderFromHTML(`
101 |
102 |
103 | WCC
104 |
105 |
106 |
107 | Home Page
108 |
109 |
110 |
111 | `,
112 | [
113 | new URL('./src/components/footer.js', import.meta.url),
114 | new URL('./src/components/header.js', import.meta.url)
115 | ]);
116 | ```
117 |
118 | For example, even if `Header` or `Footer` use `import` to pull in additional custom elements, only the `Header` and `Footer custom elements used in the "entry" HTML are needed in the array.
119 |
120 | ## Metadata
121 |
122 | `renderToString` and `renderFromHTML` return not only HTML, but also metadata about all the custom elements registered as part of rendering the entry file.
123 |
124 | So for the given HTML:
125 | ```html
126 |
127 |
128 | Hello World
129 |
130 |
131 | ```
132 |
133 | And the following conditions:
134 | 1. _index.js_ does not define a tag of its own, e.g. using `customElements.define` (e.g. it is just a ["layout" component](/examples/#static-sites-ssg))
135 | 1. `` imports ``
136 |
137 | The result would be:
138 | ```js
139 | const { metadata } = await renderToString(new URL('./src/index.js', import.meta.url));
140 |
141 | console.log({ metadata });
142 | /*
143 | * {
144 | * metadata: {
145 | * 'wcc-footer': { instanceName: 'Footer', moduleURL: [URL], isEntry: true },
146 | * 'wcc-header': { instanceName: 'Header', moduleURL: [URL], isEntry: true },
147 | * 'wcc-navigation': { instanceName: 'Navigation', moduleURL: [URL], isEntry: false }
148 | * }
149 | * }
150 | */
151 | ```
152 |
153 | ## Progressive Hydration
154 |
155 | To achieve an islands architecture implementation, if you add `hydration="true"` attribute to a custom element, e.g.
156 | ```html
157 |
158 | ```
159 |
160 | This will be reflected in the returned `metadata` object from `renderToString`.
161 | ```js
162 | /*
163 | * {
164 | * metadata: {
165 | * 'wcc-footer': { instanceName: 'Footer', moduleURL: [URL], hydrate: 'true' },
166 | * 'wcc-header': { instanceName: 'Header', moduleURL: [URL] },
167 | * 'wcc-navigation': { instanceName: 'Navigation', moduleURL: [URL] }
168 | * }
169 | * }
170 | *
171 | */
172 | ```
173 |
174 | The benefit is that this hint can be used to defer loading of these scripts by using an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) (for example), instead of eagerly loading it on page load using a `
239 |
240 |
241 | Increment
242 | Current Count: ${this.count}
243 | Decrement
244 |
245 | `;
246 | }
247 | }
248 |
249 | export async function getData() {
250 | return {
251 | count: Math.floor(Math.random() * (100 - 0 + 1) + 0)
252 | };
253 | }
254 | ```
255 |
256 | ## Conventions
257 |
258 | - Make sure to define your custom elements with `customElements.define`
259 | - Make sure to include a `export default` for your custom element base class
260 | - Avoid [touching the DOM in `constructor` methods](https://twitter.com/techytacos/status/1514029967981494280)
261 |
262 |
263 | ## TypeScript
264 |
265 | TypeScript is supported through "type stripping", which is effectively just removing all the TypeScript and leaving only valid JavaScript, before handing off to WCC to do its compiling.
266 |
267 | ```ts
268 | interface User {
269 | name: string;
270 | }
271 |
272 | export default class Greeting extends HTMLElement {
273 | connectedCallback() {
274 | const user: User = {
275 | name: this.getAttribute('name') || 'World'
276 | };
277 |
278 | this.innerHTML = `
279 | Hello ${user.name}! 👋
280 | `;
281 | }
282 | }
283 |
284 | customElements.define('wcc-greeting', Greeting);
285 | ```
286 |
287 | ### Prerequisites
288 |
289 | There are of couple things you will need to do to use WCC with TypeScript parsing:
290 | 1. NodeJS version needs to be >= `22.6.0`
291 | 1. You will need to use the _.ts_ extension
292 | 1. You'll want to enable the [`erasableSyntaxOnly`](https://devblogs.microsoft.com/typescript/announcing-typescript-5-8/#the---erasablesyntaxonly-option) flag in your _tsconfig.json_
293 |
294 | > If you're feeling adventurous, you can use NodeJS **>=23.x** and omit the `--experimental-strip-types` flag. Keep an eye on this PR for when unflagged type-stripping support may come to Node LTS 22.x. 👀
295 |
296 | ## JSX
297 |
298 | > ⚠️ _Very Experimental!_
299 |
300 | Even more experimental than WCC is the option to author a rendering function for native `HTMLElements`, that can compile down to a zero run time, web ready custom element! It handles resolving event handling and `this` references and can manage some basic re-rendering lifecycles.
301 |
302 | ### Example
303 |
304 | Below is an example of what is possible right now demonstrated through a [Counter component](https://github.com/thescientist13/greenwood-counter-jsx).
305 |
306 | ```jsx
307 | export default class Counter extends HTMLElement {
308 | constructor() {
309 | super();
310 | this.count = 0;
311 | }
312 |
313 | connectedCallback() {
314 | this.render(); // this is required
315 | }
316 |
317 | increment() {
318 | this.count += 1;
319 | this.render();
320 | }
321 |
322 | render() {
323 | const { count } = this;
324 |
325 | return (
326 |
327 | -
328 | You have clicked {count} times
329 | +
330 |
331 | );
332 | }
333 | }
334 |
335 | customElements.define('wcc-counter', Counter);
336 | ```
337 |
338 | A couple things to observe in the above example:
339 | - The `this` reference is correctly bound to the `` element's state. This works for both `this.count` and the event handler, `this.increment`.
340 | - Event handlers need to manage their own render function updates.
341 | - `this.count` will know it is a member of the ``'s state, and so will re-run `this.render` automatically in the compiled output.
342 |
343 | > There is an [active discussion tracking features](https://github.com/ProjectEvergreen/wcc/discussions/84) and [issues in progress](https://github.com/ProjectEvergreen/wcc/issues?q=is%3Aopen+is%3Aissue+label%3AJSX) to continue iterating on this, so please feel free to try it out and give us your feedback!
344 |
345 | ### Prerequisites
346 |
347 | There are of couple things you will need to do to use WCC with JSX:
348 | 1. NodeJS version needs to be >= `20.10.0`
349 | 1. You will need to use the _.jsx_ extension
350 | 1. Requires the `--import` flag when invoking NodeJS
351 | ```shell
352 | $ NODE_OPTIONS="--import wc-compiler/register" node your-script.js
353 | ```
354 |
355 | > _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀
356 |
357 | ### Declarative Shadow DOM
358 |
359 | To opt-in to Declarative Shadow DOM with JSX, you will need to signal to the WCC compiler your intentions so it can accurately mount from a `shadowRoot` on the client side. To opt-in, simply make a call to `attachShadow` in your `connectedCallback` method.
360 |
361 | Using, the Counter example from above, we would amend it like so:
362 |
363 | ```js
364 | export default class Counter extends HTMLElement {
365 | constructor() {
366 | super();
367 | this.count = 0;
368 | }
369 |
370 | connectedCallback() {
371 | if (!this.shadowRoot) {
372 | this.attachShadow({ mode: 'open' }); // this is required for DSD support
373 | this.render();
374 | }
375 | }
376 |
377 | // ...
378 | }
379 |
380 | customElements.define('wcc-counter', Counter);
381 | ```
382 |
383 | ### (Inferred) Attribute Observability
384 |
385 | An optional feature supported by JSX based compilation is a feature called `inferredObservability`. With this enabled, WCC will read any `this` member references in your component's `render` function and map each member instance to
386 | - an entry in the `observedAttributes` array
387 | - automatically handle `attributeChangedCallback` update (by calling `this.render()`)
388 |
389 | So taking the above counter example, and opting in to this feature, we just need to enable the `inferredObservability` option in the component
390 | ```jsx
391 | export const inferredObservability = true;
392 |
393 | export default class Counter extends HTMLElement {
394 | ...
395 |
396 | render() {
397 | const { count } = this;
398 |
399 | return (
400 |
401 | -
402 | You have clicked {count} times
403 | +
404 |
405 | );
406 | }
407 | }
408 | ```
409 |
410 | And so now when the attribute is set on this component, the component will re-render automatically, no need to write out `observedAttributes` or `attributeChangedCallback`!
411 | ```html
412 |
413 | ```
414 |
415 | Some notes / limitations:
416 | - Please be aware of the above linked discussion which is tracking known bugs / feature requests / open items related to all things WCC + JSX.
417 | - We consider the capability of this observability to be "coarse grained" at this time since WCC just re-runs the entire `render` function, replacing of the `innerHTML` for the host component. Thought it is still WIP, we are exploring a more ["fine grained" approach](https://github.com/ProjectEvergreen/wcc/issues/108) that will more efficient than blowing away all the HTML, a la in the style of [**lit-html**](https://lit.dev/docs/templates/overview/) or [**Solid**'s Signals](https://www.solidjs.com/tutorial/introduction_signals).
418 | - This automatically _reflects properties used in the `render` function to attributes_, so YMMV.
419 |
--------------------------------------------------------------------------------
/docs/pages/examples.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | Below are some example of how **WCC** is being used right now.
4 |
5 | ## Table of contents
6 |
7 | ## Server Rendering (SSR)
8 |
9 | For the project [**Greenwood**](https://www.greenwoodjs.dev/), **WCC** is used to provide a _Next.js_ like experience by allowing users to author [server-side routes using native custom elements](https://www.greenwoodjs.dev/docs/pages/server-rendering/#web-server-components)! ✨
10 |
11 | ```js
12 | import '../components/card/card.js';
13 |
14 | export default class ArtistsPage extends HTMLElement {
15 | async connectedCallback() {
16 | if (!this.shadowRoot) {
17 | const artists = await fetch('https://www.domain.com/api/artists')
18 | .then(resp => resp.json());
19 | const html = artists.map(artist => {
20 | return `
21 |
22 | ${artist.name}
23 |
24 |
25 | `;
26 | }).join('');
27 |
28 | this.attachShadow({ mode: 'open' });
29 | this.shadowRoot.innerHTML = html;
30 | }
31 | }
32 | }
33 | ```
34 |
35 | ## Serverless and Edge Functions
36 |
37 | In the talk [_"Web Components at the Edge"_](https://sched.co/11loQ) for OpenJS World 2022, **WCC** was leveraged for all the AWS Lambda serverless function and Netlify Edge function demos. It also shows some clever ways to use **WCC** in more constrained runtime environments, like an edge runtime where something like `fs` might not be available. See all the [code, slides and demos in GitHub](https://github.com/thescientist13/web-components-at-the-edge). 🚀
38 |
39 | ```js
40 | import '../../node_modules/wc-compiler/src/dom-shim.js';
41 |
42 | import Greeting from './components/greeting.js';
43 |
44 | export default async function (request, context) {
45 | const countryCode = context.geo.country.code || 'UNKNOWN';
46 | const countryName = context.geo.country.name || 'UNKNOWN';
47 | const greeting = new Greeting(countryCode, countryName);
48 |
49 | greeting.connectedCallback();
50 |
51 | const response = new Response(`
52 |
53 |
54 |
55 |
56 | ${greeting.getHTML({ serializableShadowRoots: true })}
57 |
58 |
59 | ${JSON.stringify(context.geo)}
60 |
61 |
62 |
63 |
64 |
65 | `);
66 |
67 | response.headers.set('content-type', 'text/html');
68 |
69 | return response;
70 | }
71 | ```
72 |
73 | ## Static Sites (SSG)
74 |
75 | Using `innerHTML`, custom elements can be authored to not use Shadow DOM, which can be useful for a `Layout` or `App` component where that top level content specifically should _not_ be rendered in a shadow root, e.g. `` tag. What's nice about **WCC** is that by using `innerHTML` or `attachShadow`, you can opt-in to either on a per component basis, like is being done for [the **WCC** website](https://github.com/ProjectEvergreen/wcc/tree/master/docs). In this case, the content is authored in markdown, but the layout, header, navigation, and footer are all custom elements rendered to static HTML. 🗒️
76 |
77 | ```js
78 | // layout.js
79 | import './components/footer.js';
80 | import './components/header.js';
81 |
82 | class Layout extends HTMLElement {
83 | connectedCallback() {
84 | this.innerHTML = `
85 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | `;
113 | }
114 | }
115 |
116 | export default Layout;
117 | ```
118 |
119 | ## HTML (Light DOM) Web Components
120 |
121 | As detailed in this excellent [blog post](https://blog.jim-nielsen.com/2023/html-web-components/), HTML Web Components are a strategy for transcluding content into the Light DOM of a custom element instead of (or in addition to) setting attributes. This can be useful for providing a set of styles to a block of content.
122 |
123 | So instead of setting attributes:
124 |
125 | ```html
126 |
127 | ```
128 |
129 | Pass HTML as children:
130 |
131 | ```html
132 |
133 | My Image
134 |
135 |
136 | ```
137 |
138 | With a custom element definition like so:
139 |
140 | ```js
141 | export default class PictureFrame extends HTMLElement {
142 | connectedCallback() {
143 | this.innerHTML = `
144 |
145 | ${this.innerHTML}
146 |
147 | `;
148 | }
149 | }
150 |
151 | customElements.define('picture-frame', PictureFrame);
152 | ```
153 |
154 | ## Progressive Hydration
155 |
156 | Using the `metadata` information from a custom element with the `hydrate=true` attribute, you can use use the metadata with an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to progressively load a custom element. In this case, _handler.js_ builds `SliderComponent` from HTML and not only uses the `hydrate` attribute and metadata for lazy hydration, but also passes in the animated color via a CSS custom property set at build time! 🤯
157 |
158 | See it in [action here](https://wc-at-the-edge.thegreenhouse.io/demo3) by scrolling to the bottom of the page and seeing the animation happen! View [the code here in GitHub](https://github.com/thescientist13/web-components-at-the-edge/blob/main/serverless/get-demo3/index.mjs).
159 |
160 | ```js
161 | // slider.js
162 | const template = document.createElement('template');
163 |
164 | template.innerHTML = `
165 |
187 |
188 | This is a slider component.
189 | `;
190 |
191 | class SliderComponent extends HTMLElement {
192 | connectedCallback() {
193 | if (!this.shadowRoot) {
194 | this.attachShadow({ mode: 'open' });
195 | this.shadowRoot.appendChild(template.content.cloneNode(true));
196 | } else {
197 | const header = this.shadowRoot.querySelector('h6');
198 |
199 | header.style.color = this.getAttribute('color');
200 | header.classList.add('hydrated');
201 | }
202 | }
203 | }
204 |
205 | export { SliderComponent };
206 | export default SliderComponent;
207 |
208 | customElements.define('wc-slider', SliderComponent);
209 | ```
210 |
211 | ```js
212 | // handler.js
213 | import { renderFromHTML } from 'wc-compiler';
214 |
215 | export async function handler() {
216 | const { html, metadata } = await renderFromHTML(`
217 |
218 |
219 | `);
220 | const lazyJs = [];
221 |
222 | for (const asset in metadata) {
223 | const a = metadata[asset];
224 |
225 | a.tagName = asset;
226 |
227 | if (a.moduleURL.href.endsWith('.js')) {
228 | if (a.hydrate === 'lazy') {
229 | lazyJs.push(a);
230 | }
231 | }
232 | }
233 |
234 | return {
235 | status: 200,
236 | headers: {
237 | 'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
238 | 'content-type': 'text/html; charset=utf8'
239 | },
240 | body: `
241 |
242 |
243 |
244 |
252 | ${
253 | lazyJs.map(script => {
254 | return `
255 |
280 | `;
281 | }).join('\n')
282 | }
283 |
284 |
285 | ${html}
286 |
287 |
288 | `
289 | };
290 | }
291 | ```
292 |
293 | ## JSX
294 |
295 | A couple examples of using WCC + JSX are available for reference and reproduction:
296 |
297 | * [Counter](https://github.com/thescientist13/greenwood-counter-jsx)
298 | * [Todo App](https://github.com/thescientist13/todo-app)
299 |
300 | Both of these examples can compile JSX for _**the client or the server**_ using [Greenwood](https://www.greenwoodjs.dev/), and can even be used with great testing tools like [**@web/test-runner**](https://modern-web.dev/docs/test-runner/overview/)! 💪
--------------------------------------------------------------------------------
/docs/pages/index.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | **Web Components Compiler (WCC)** is a NodeJS package designed to make server-side rendering (SSR) of native Web Components easier. It can render (within reason 😅) your Web Component into static HTML. This includes support for [Declarative Shadow DOM](https://web.dev/declarative-shadow-dom/).
4 |
5 | ## Installation
6 |
7 | **WCC** can be installed from npm.
8 |
9 | ```shell
10 | $ npm install wc-compiler --save-dev
11 | ```
12 |
13 | ## Key Features
14 |
15 | 1. Supports the following `HTMLElement` lifecycles and methods on the server side
16 | - `constructor`
17 | - `connectedCallback`
18 | - `attachShadow`
19 | - `innerHTML`
20 | - `[get|set|has]Attribute`
21 | 1. `` / `DocumentFragment`
22 | 1. `addEventListener` (as a no-op)
23 | 1. Supports `CSSStyleSheet` (all methods act as no-ops)
24 | 1. TypeScript
25 | 1. Custom JSX parsing
26 | 1. Recursive rendering of nested custom elements
27 | 1. Metadata and runtime hints to support various progressive hydration and lazy loading strategies
28 |
29 | > _It is recommended to reference [`globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis) instead of `window` for isomorphic Web Components_.
30 |
31 | ## Usage
32 |
33 | **WCC** exposes a few utilities to render your Web Components. See [our API docs](/docs) for all available features.
34 |
35 | 1. Given a custom element like so:
36 | ```js
37 | const template = document.createElement('template');
38 |
39 | template.innerHTML = `
40 |
46 |
47 |
50 | `;
51 |
52 | class Footer extends HTMLElement {
53 | connectedCallback() {
54 | if (!this.shadowRoot) {
55 | this.attachShadow({ mode: 'open' });
56 | this.shadowRoot.appendChild(template.content.cloneNode(true));
57 | }
58 | }
59 | }
60 |
61 | export default Footer;
62 |
63 | customElements.define('wcc-footer', Footer);
64 | ```
65 |
66 | 1. Using NodeJS, create a file that imports `renderToString` and provide it the path to your web component
67 | ```js
68 | import { renderToString } from 'wc-compiler';
69 |
70 | const { html } = await renderToString(new URL('./path/to/footer.js', import.meta.url));
71 | ```
72 |
73 | 1. You will get the following HTML output that can be used in conjunction with your preferred site framework or templating solution.
74 | ```html
75 |
76 |
77 |
83 |
84 |
87 |
88 |
89 | ```
90 |
91 |
92 | > _**Make sure to test in Chrome, or other Declarative Shadow DOM compatible browser, otherwise you will need to include the [polyfill](https://web.dev/declarative-shadow-dom/#polyfill).**_
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import babelParser from '@babel/eslint-parser';
2 | import js from '@eslint/js';
3 | import globals from 'globals';
4 | import noOnlyTests from 'eslint-plugin-no-only-tests';
5 |
6 | export default [
7 | {
8 | // https://github.com/eslint/eslint/discussions/18304#discussioncomment-9069706
9 | ignores: [
10 | 'node_modules/*',
11 | 'dist/*',
12 | 'coverage/*',
13 | ],
14 | },
15 | {
16 | languageOptions: {
17 | parser: babelParser,
18 | parserOptions: {
19 | ecmaVersion: 2022,
20 | sourceType: 'module',
21 | requireConfigFile: false,
22 | babelOptions: {
23 | plugins: ['@babel/plugin-syntax-import-assertions'],
24 | },
25 | },
26 | globals: {
27 | ...globals.browser,
28 | ...globals.mocha,
29 | ...globals.chai,
30 | ...globals.node,
31 | },
32 | },
33 | rules: {
34 | ...js.configs.recommended.rules,
35 | // turn this off for Prettier
36 | 'no-irregular-whitespace': 'off',
37 | 'no-only-tests/no-only-tests': 'error',
38 | },
39 | plugins: {
40 | 'no-only-tests': noOnlyTests,
41 | },
42 | },
43 | ];
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | publish = "dist/"
3 | command = "npm run docs:build"
4 |
5 | [build.processing]
6 | skip_processing = true
7 |
8 | [build.environment]
9 | NODE_VERSION = "18.12.1"
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wc-compiler",
3 | "version": "0.17.0",
4 | "description": "Experimental native Web Components compiler.",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/ProjectEvergreen/wcc.git"
8 | },
9 | "type": "module",
10 | "main": "src/wcc.js",
11 | "types": "./src/index.d.ts",
12 | "exports": {
13 | ".": {
14 | "import": "./src/wcc.js",
15 | "types": "./src/index.d.ts"
16 | },
17 | "./register": "./src/register.js",
18 | "./src/jsx-loader.js": "./src/jsx-loader.js"
19 | },
20 | "author": "Owen Buckley ",
21 | "keywords": [
22 | "Web Components",
23 | "SSR",
24 | "JSX",
25 | "Greenwood"
26 | ],
27 | "license": "MIT",
28 | "engines": {
29 | "node": ">=18"
30 | },
31 | "files": [
32 | "src/"
33 | ],
34 | "publishConfig": {
35 | "access": "public"
36 | },
37 | "scripts": {
38 | "clean": "rimraf ./dist",
39 | "lint": "eslint",
40 | "lint:types": "tsc --project tsconfig.json",
41 | "docs:dev": "concurrently \"nodemon --watch src --watch docs -e js,md,css,html,jsx ./build.js\" \"http-server ./dist --open\"",
42 | "docs:build": "node ./build.js",
43 | "docs:serve": "npm run clean && npm run docs:build && http-server ./dist --open",
44 | "sandbox": "npm run clean && concurrently \"nodemon --loader ./test-loader.js --watch src --watch sandbox -e js,md,css,html,jsx,ts ./sandbox.js\" \"http-server ./dist --open\" \"livereload ./dist\"",
45 | "start": "npm run docs:serve",
46 | "test": "mocha --exclude \"./test/cases/jsx*/**\" --exclude \"./test/cases/ts*/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
47 | "test:jsx": "c8 node --import ./test-register.js --experimental-strip-types ./node_modules/mocha/bin/mocha \"./test/**/**/*.spec.js\"",
48 | "test:tdd": "npm run test -- --watch",
49 | "test:tdd:jsx": "npm run test:jsx -- --watch"
50 | },
51 | "dependencies": {
52 | "@projectevergreen/acorn-jsx-esm": "~0.1.0",
53 | "acorn": "^8.14.0",
54 | "acorn-walk": "^8.3.4",
55 | "astring": "^1.9.0",
56 | "parse5": "^7.2.1",
57 | "sucrase": "^3.35.0"
58 | },
59 | "devDependencies": {
60 | "@babel/core": "^7.24.4",
61 | "@babel/eslint-parser": "^7.25.7",
62 | "@babel/plugin-syntax-import-assertions": "^7.25.7",
63 | "@eslint/js": "^9.11.1",
64 | "@ls-lint/ls-lint": "^1.10.0",
65 | "@mapbox/rehype-prism": "^0.8.0",
66 | "@types/mocha": "^10.0.10",
67 | "@types/node": "^22.13.4",
68 | "c8": "^7.11.2",
69 | "chai": "^4.3.6",
70 | "concurrently": "^7.1.0",
71 | "eslint": "^9.11.1",
72 | "eslint-plugin-no-only-tests": "^2.6.0",
73 | "globals": "^15.10.0",
74 | "http-server": "^14.1.0",
75 | "jsdom": "^19.0.0",
76 | "livereload": "^0.9.3",
77 | "mocha": "^9.2.2",
78 | "nodemon": "^2.0.15",
79 | "prismjs": "^1.28.0",
80 | "rehype-autolink-headings": "^6.1.1",
81 | "rehype-raw": "^6.1.1",
82 | "rehype-slug": "^5.0.1",
83 | "rehype-stringify": "^9.0.3",
84 | "remark-parse": "^10.0.1",
85 | "remark-rehype": "^10.1.0",
86 | "remark-toc": "^8.0.1",
87 | "rimraf": "^3.0.2",
88 | "simple.css": "^0.1.3",
89 | "typescript": "^5.8.2",
90 | "unified": "^10.1.2"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/sandbox.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import { renderFromHTML } from './src/wcc.js';
3 |
4 | const clientSideComponents = [
5 | 'card.js',
6 | 'card.jsx',
7 | 'counter.jsx',
8 | 'counter-dsd.jsx',
9 | 'greeting.ts'
10 | ];
11 |
12 | async function init() {
13 | const distRoot = new URL('./dist/', import.meta.url);
14 | const sandboxRoot = new URL('./sandbox/', import.meta.url); // './sandbox';
15 | const sandboxHtml = await fs.readFile(new URL('./index.html', sandboxRoot), 'utf-8');
16 | const components = await fs.readdir(new URL('./components/', sandboxRoot));
17 | const componentsUrls = components.map(component => new URL(`./components/${component}`, sandboxRoot));
18 | const interactiveComponents = components.filter(component => clientSideComponents.includes(component));
19 | const { html, metadata } = await renderFromHTML(sandboxHtml, componentsUrls);
20 | const scriptTags = interactiveComponents.map(component => {
21 | const ext = component.split('.').pop();
22 | const outputName = ext === 'js'
23 | ? component
24 | : component.replace('.jsx', '-jsx.js').replace('.ts', '-ts.js');
25 |
26 | return ``;
27 | }).join('\n');
28 |
29 | for (const component of interactiveComponents) {
30 | const ext = component.split('.').pop();
31 | const outputName = ext === 'js'
32 | ? component
33 | : component.replace('.jsx', '-jsx.js').replace('.ts', '-ts.js');
34 | const source = new URL(`./components/${component}`, sandboxRoot);
35 | const destination = new URL(`./components/${outputName}`, distRoot);
36 |
37 | await fs.mkdir(new URL('./components/', distRoot), { recursive: true });
38 |
39 | if (ext === 'js') {
40 | await fs.copyFile(source, destination);
41 | } else {
42 | const key = `sb-${component.replace('.', '-')}`;
43 |
44 | for (const element in metadata) {
45 | if (element === key) {
46 | await fs.writeFile(destination, metadata[element].source);
47 | }
48 | }
49 | }
50 | }
51 |
52 | await fs.mkdir(distRoot, { recursive: true });
53 | await fs.writeFile(new URL('./index.html', distRoot), html.replace('', `
54 | ${scriptTags}
55 |
56 | `.trim()));
57 | }
58 |
59 | init();
--------------------------------------------------------------------------------
/sandbox/components/card.js:
--------------------------------------------------------------------------------
1 | export default class Card extends HTMLElement {
2 |
3 | selectItem() {
4 | alert(`selected item is => ${this.title}!`);
5 | }
6 |
7 | connectedCallback() {
8 | if (!this.shadowRoot) {
9 | const thumbnail = this.getAttribute('thumbnail');
10 | const title = this.getAttribute('title');
11 | const template = document.createElement('template');
12 |
13 | template.innerHTML = `
14 |
30 |
31 |
${title}
32 |
33 |
View Item Details
34 |
35 | `;
36 | this.attachShadow({ mode: 'open' });
37 | this.shadowRoot.appendChild(template.content.cloneNode(true));
38 | } else {
39 | console.log('SUCCESS, shadowRoot detected for card.js!');
40 | }
41 | }
42 | }
43 |
44 | customElements.define('sb-card', Card);
--------------------------------------------------------------------------------
/sandbox/components/card.jsx:
--------------------------------------------------------------------------------
1 | // JSX does not support inline style tags
2 | // https://stackoverflow.com/questions/27530462/tag-error-react-jsx-style-tag-error-on-render
3 | const styles = `
4 | :host .card {
5 | width: 30%;
6 | margin: 0 auto;
7 | text-align: center;
8 | }
9 |
10 | :host button {
11 | margin: 0 auto;
12 | display: block;
13 | }
14 | `;
15 |
16 | export default class CardJsx extends HTMLElement {
17 |
18 | selectItem() {
19 | alert(`selected item is => ${this.title}!`);
20 | }
21 |
22 | connectedCallback() {
23 | if (!this.shadowRoot) {
24 | console.warn('NO shadowRoot detected for card.jsx!');
25 | this.thumbnail = this.getAttribute('thumbnail');
26 | this.title = this.getAttribute('title');
27 |
28 | this.attachShadow({ mode: 'open' });
29 | this.render();
30 | } else {
31 | console.log('SUCCESS, shadowRoot detected for card.jsx!');
32 | }
33 | }
34 |
35 | render() {
36 | const { thumbnail, title } = this;
37 |
38 | return (
39 |
40 |
43 |
{title}
44 |
45 |
View Item Details
46 |
47 | );
48 | }
49 | }
50 |
51 | customElements.define('sb-card-jsx', CardJsx);
--------------------------------------------------------------------------------
/sandbox/components/counter-dsd.jsx:
--------------------------------------------------------------------------------
1 | export const inferredObservability = true;
2 |
3 | export default class CounterDsdJsx extends HTMLElement {
4 | connectedCallback() {
5 | if (!this.shadowRoot) {
6 | console.warn('NO shadowRoot detected for counter-dsd.jsx!');
7 | this.count = this.getAttribute('count') || 0;
8 |
9 | // having an attachShadow call is required for DSD
10 | this.attachShadow({ mode: 'open' });
11 | this.render();
12 | } else {
13 | console.log('SUCCESS, shadowRoot detected for counter-dsd.jsx!');
14 | }
15 | }
16 |
17 | render() {
18 | const { count } = this;
19 |
20 | return (
21 |
22 | -
23 | You have clicked {count} times
24 | +
25 |
26 | );
27 | }
28 | }
29 |
30 | customElements.define('sb-counter-dsd-jsx', CounterDsdJsx);
--------------------------------------------------------------------------------
/sandbox/components/counter.jsx:
--------------------------------------------------------------------------------
1 | export const inferredObservability = true;
2 |
3 | export default class CounterJsx extends HTMLElement {
4 | constructor() {
5 | super();
6 | this.count = 0;
7 | }
8 |
9 | connectedCallback() {
10 | this.count = parseInt(this.getAttribute('count'), 10) || this.count;
11 | this.render();
12 | }
13 |
14 | render() {
15 | const { count } = this;
16 |
17 | return (
18 |
19 | -
20 | You have clicked {count} times
21 | +
22 |
23 | );
24 | }
25 | }
26 |
27 | customElements.define('sb-counter-jsx', CounterJsx);
--------------------------------------------------------------------------------
/sandbox/components/greeting.ts:
--------------------------------------------------------------------------------
1 | interface User {
2 | name: string;
3 | }
4 |
5 | export default class Greeting extends HTMLElement {
6 | connectedCallback() {
7 | const user: User = {
8 | name: this.getAttribute('name') || 'World'
9 | };
10 |
11 | this.innerHTML = `
12 | Hello ${user.name}! 👋
13 | `;
14 | }
15 | }
16 |
17 | customElements.define('sb-greeting-ts', Greeting);
--------------------------------------------------------------------------------
/sandbox/components/header.js:
--------------------------------------------------------------------------------
1 | export default class Header extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 | Welcome to my site
6 |
7 | `;
8 | }
9 | }
10 |
11 | customElements.define('sb-header', Header);
--------------------------------------------------------------------------------
/sandbox/components/header.jsx:
--------------------------------------------------------------------------------
1 | export default class HeaderJsx extends HTMLElement {
2 |
3 | connectedCallback() {
4 | this.render();
5 | }
6 |
7 | render() {
8 | return (
9 |
12 | );
13 | }
14 | }
15 |
16 | customElements.define('sb-header-jsx', HeaderJsx);
--------------------------------------------------------------------------------
/sandbox/components/picture-frame.js:
--------------------------------------------------------------------------------
1 | export default class PictureFrame extends HTMLElement {
2 | connectedCallback() {
3 | const title = this.getAttribute('title');
4 |
5 | this.innerHTML = `
6 |
7 |
${title}
8 | ${this.innerHTML}
9 |
10 | `;
11 | }
12 | }
13 |
14 | customElements.define('sb-picture-frame', PictureFrame);
--------------------------------------------------------------------------------
/sandbox/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | WCC Sandbox
6 |
7 |
8 |
32 |
33 |
51 |
52 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | WCC Sandbox
63 |
64 | Light DOM (no JS)
65 |
66 |
67 |
68 |
69 | <sb-header></sb-header>
70 |
71 |
72 |
73 |
74 | Declarative Shadow DOM (has JS)
75 |
76 |
80 |
81 |
82 | <sb-card
83 | title="iPhone 9"
84 | thumbnail="https://www.greenwoodjs.dev/assets/greenwood-logo-og.png"
85 | ></sb-card>
86 |
87 |
88 | HTML Web Component (Light DOM + has JS)
89 |
90 |
105 |
106 |
110 |
111 |
112 |
113 | <sb-picture-frame title="Greenwood Logo">
114 | <img
115 | src="https://www.greenwoodjs.dev/assets/greenwood-logo-og.png"
116 | alt="Greenwood logo"
117 | />
118 | </sb-picture-frame>
119 |
120 |
121 |
122 |
123 | JSX + Light DOM (no JS)
124 |
125 |
126 |
127 |
128 | <sb-header-jsx></sb-header-jsx>
129 |
130 |
131 |
132 |
133 | JSX + Declarative Shadow DOM (has JS)
134 |
135 |
139 |
140 |
141 | <sb-card-jsx
142 | title="iPhone X"
143 | thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/7e8317d3-cc5d-42a8-8350-ba6a02560477-1_d64a25e3-e1f3-4172-b7c9-0c96e82c4d3f.jpg"
144 | ></sb-card-jsx>
145 |
146 |
147 |
148 |
149 | JSX + Light DOM + inferredObservability (has JS)
150 |
151 |
154 |
155 | Random Reset
156 |
157 |
158 | <sb-counter-jsx
159 | count="5"
160 | ></sb-counter-jsx>
161 |
162 |
163 |
164 |
165 | JSX + DSD + inferredObservability (has JS)
166 |
167 |
170 |
171 | Random Reset
172 |
173 |
174 | <sb-counter-dsd-jsx
175 | count="3"
176 | ></sb-counter-dsd-jsx>
177 |
178 |
179 | TypeScript
180 |
181 |
184 |
185 |
186 | <sb-greeting-ts
187 | name="TypeScript"
188 | ></sb-greeting-ts>
189 |
190 |
191 |
192 |
--------------------------------------------------------------------------------
/src/dom-shim.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { parse, parseFragment, serialize } from 'parse5';
3 |
4 | export function getParse(html) {
5 | return html.indexOf('') >= 0 || html.indexOf('') >= 0 || html.indexOf('') >= 0
6 | ? parse
7 | : parseFragment;
8 | }
9 |
10 | function isShadowRoot(element) {
11 | return Object.getPrototypeOf(element).constructor.name === 'ShadowRoot';
12 | }
13 |
14 | function deepClone(obj, map = new WeakMap()) {
15 | if (obj === null || typeof obj !== 'object') {
16 | return obj;
17 | }
18 |
19 | if (typeof obj === 'function') {
20 | const clonedFn = obj.bind({});
21 | Object.assign(clonedFn, obj);
22 | return clonedFn;
23 | }
24 |
25 | if (map.has(obj)) {
26 | return map.get(obj);
27 | }
28 |
29 | const result = Array.isArray(obj) ? [] : {};
30 | map.set(obj, result);
31 |
32 | for (const key of Object.keys(obj)) {
33 | result[key] = deepClone(obj[key], map);
34 | }
35 |
36 | return result;
37 | }
38 |
39 | // Creates an empty parse5 element without the parse5 overhead resulting in better performance
40 | function getParse5ElementDefaults(element, tagName) {
41 | return {
42 | addEventListener: noop,
43 | attrs: [],
44 | parentNode: element.parentNode,
45 | childNodes: [],
46 | nodeName: tagName,
47 | tagName: tagName,
48 | namespaceURI: 'http://www.w3.org/1999/xhtml',
49 | ...(tagName === 'template' ? { content: { nodeName: '#document-fragment', childNodes: [] } } : {})
50 | };
51 | }
52 |
53 | function noop() { }
54 |
55 | // https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet
56 | class CSSStyleSheet {
57 | insertRule() { }
58 | deleteRule() { }
59 | replace() { }
60 | replaceSync() { }
61 | }
62 |
63 | // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
64 | class EventTarget {
65 | constructor() {
66 | this.addEventListener = noop;
67 | }
68 | }
69 |
70 | // https://developer.mozilla.org/en-US/docs/Web/API/Node
71 | // EventTarget <- Node
72 | // TODO should be an interface?
73 | class Node extends EventTarget {
74 | constructor() {
75 | super();
76 | // Parse5 properties
77 | this.attrs = [];
78 | this.parentNode = null;
79 | this.childNodes = [];
80 | this.nodeName = '';
81 | }
82 |
83 | cloneNode(deep) {
84 | return deep ? deepClone(this) : Object.assign({}, this);
85 | }
86 |
87 | appendChild(node) {
88 | const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
89 |
90 | if (node.parentNode) {
91 | node.parentNode?.removeChild?.(node);
92 | }
93 |
94 | if (node.nodeName === 'template') {
95 | if (isShadowRoot(this) && this.mode) {
96 | node.attrs = [{ name: 'shadowrootmode', value: this.mode }];
97 | childNodes.push(node);
98 | node.parentNode = this;
99 | } else {
100 | this.childNodes = [...this.childNodes, ...node.content.childNodes];
101 | }
102 | } else if (node instanceof DocumentFragment) {
103 | this.childNodes = [...this.childNodes, ...node.childNodes];
104 | } else {
105 | childNodes.push(node);
106 | node.parentNode = this;
107 | }
108 |
109 | return node;
110 | }
111 |
112 | removeChild(node) {
113 | const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
114 | if (!childNodes || !childNodes.length) {
115 | return null;
116 | }
117 |
118 | const index = childNodes.indexOf(node);
119 | if (index === -1) {
120 | return null;
121 | }
122 |
123 | childNodes.splice(index, 1);
124 | node.parentNode = null;
125 |
126 | return node;
127 | }
128 |
129 | get textContent() {
130 | if (this.nodeName === '#text') {
131 | return this.value || '';
132 | }
133 |
134 | return this.childNodes
135 | .map((child) => child.nodeName === '#text' ? child.value : child.textContent)
136 | .join('');
137 | }
138 |
139 | set textContent(value) {
140 | this.childNodes = [];
141 |
142 | if (value) {
143 | const textNode = new Node();
144 | textNode.nodeName = '#text';
145 | textNode.value = value;
146 | textNode.parentNode = this;
147 | this.childNodes.push(textNode);
148 | }
149 | }
150 | }
151 |
152 | // https://developer.mozilla.org/en-US/docs/Web/API/Element
153 | // EventTarget <- Node <- Element
154 | class Element extends Node {
155 | constructor() {
156 | super();
157 | }
158 |
159 | attachShadow(options) {
160 | this.shadowRoot = new ShadowRoot(options);
161 | this.shadowRoot.parentNode = this;
162 | return this.shadowRoot;
163 | }
164 |
165 | getHTML({ serializableShadowRoots = false }) {
166 | return this.shadowRoot && serializableShadowRoots && this.shadowRoot.serializable ? this.shadowRoot.innerHTML : '';
167 | }
168 |
169 | get innerHTML() {
170 | const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
171 | return childNodes ? serialize({ childNodes }) : '';
172 | }
173 |
174 | set innerHTML(html) {
175 | (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes;
176 | }
177 |
178 | hasAttribute(name) {
179 | return this.attrs.some((attr) => attr.name === name);
180 | }
181 |
182 | getAttribute(name) {
183 | const attr = this.attrs.find((attr) => attr.name === name);
184 | return attr ? attr.value : null;
185 | }
186 |
187 | setAttribute(name, value) {
188 | const attr = this.attrs?.find((attr) => attr.name === name);
189 |
190 | if (attr) {
191 | attr.value = value;
192 | } else {
193 | this.attrs?.push({ name, value });
194 | }
195 | }
196 | }
197 |
198 | // https://developer.mozilla.org/en-US/docs/Web/API/Document
199 | // EventTarget <- Node <- Document
200 | class Document extends Node {
201 |
202 | createElement(tagName) {
203 | switch (tagName) {
204 |
205 | case 'template':
206 | return new HTMLTemplateElement();
207 |
208 | default:
209 | return new HTMLElement(tagName);
210 |
211 | }
212 | }
213 |
214 | createDocumentFragment(html) {
215 | return new DocumentFragment(html);
216 | }
217 | }
218 |
219 | // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
220 | // EventTarget <- Node <- Element <- HTMLElement
221 | class HTMLElement extends Element {
222 | constructor(tagName) {
223 | super();
224 | Object.assign(this, getParse5ElementDefaults(this, tagName));
225 | }
226 | connectedCallback() { }
227 | }
228 |
229 | // https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
230 | // EventTarget <- Node <- DocumentFragment
231 | class DocumentFragment extends Node { }
232 |
233 | // https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
234 | // EventTarget <- Node <- DocumentFragment <- ShadowRoot
235 | class ShadowRoot extends DocumentFragment {
236 | constructor(options) {
237 | super();
238 | this.mode = options.mode ?? 'closed';
239 | this.serializable = options.serializable ?? false;
240 | this.adoptedStyleSheets = [];
241 | }
242 |
243 | get innerHTML() {
244 | return this.childNodes?.[0]?.content?.childNodes ? serialize({ childNodes: this.childNodes[0].content.childNodes }) : '';
245 | }
246 |
247 | set innerHTML(html) {
248 | this.childNodes = getParse(html)(`${html} `).childNodes;
249 | }
250 | }
251 |
252 | // https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement
253 | // EventTarget <- Node <- Element <- HTMLElement <- HTMLTemplateElement
254 | class HTMLTemplateElement extends HTMLElement {
255 | constructor() {
256 | super();
257 | // Gets element defaults for template element instead of parsing a
258 | // with parse5. Results in better performance
259 | // when creating templates
260 | Object.assign(this, getParse5ElementDefaults(this, 'template'));
261 | this.content.cloneNode = this.cloneNode.bind(this);
262 | }
263 | }
264 |
265 | // https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry
266 | class CustomElementsRegistry {
267 | constructor() {
268 | // TODO this should probably be a set or otherwise follow the spec?
269 | // https://github.com/ProjectEvergreen/wcc/discussions/145
270 | this.customElementsRegistry = new Map();
271 | }
272 |
273 | define(tagName, BaseClass) {
274 | // TODO this should probably fail as per the spec...
275 | // e.g. if(this.customElementsRegistry.get(tagName))
276 | // https://github.com/ProjectEvergreen/wcc/discussions/145
277 | this.customElementsRegistry.set(tagName, BaseClass);
278 | }
279 |
280 | get(tagName) {
281 | return this.customElementsRegistry.get(tagName);
282 | }
283 | }
284 |
285 | // mock top level aliases (globalThis === window)
286 | // https://developer.mozilla.org/en-US/docs/Web/API/Window
287 | // make this "idempotent" for now until a better idea comes along - https://github.com/ProjectEvergreen/wcc/discussions/145
288 | globalThis.addEventListener = globalThis.addEventListener ?? noop;
289 | globalThis.document = globalThis.document ?? new Document();
290 | globalThis.customElements = globalThis.customElements ?? new CustomElementsRegistry();
291 | globalThis.HTMLElement = globalThis.HTMLElement ?? HTMLElement;
292 | globalThis.DocumentFragment = globalThis.DocumentFragment ?? DocumentFragment;
293 | globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet;
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | export type Metadata = {
2 | [key: string]: {
3 | instanceName: string;
4 | moduleURL: URL;
5 | isEntry: boolean
6 | }
7 | }
8 |
9 | export type renderToString = (elementURL: URL, wrappingEntryTag?: boolean, props?: any) => Promise<{
10 | html: string;
11 | metadata: Metadata
12 | }>
13 |
14 | export type renderFromHTML = (html: string, elementURLs: URL[]) => Promise<{
15 | html: string;
16 | metadata: Metadata
17 | }>
18 |
19 | declare module "wc-compiler" { }
--------------------------------------------------------------------------------
/src/jsx-loader.js:
--------------------------------------------------------------------------------
1 | // https://nodejs.org/api/esm.html#esm_loaders
2 | import * as acorn from 'acorn';
3 | import * as walk from 'acorn-walk';
4 | import { generate } from 'astring';
5 | import fs from 'fs';
6 | // ideally we can eventually adopt an ESM compatible version of this plugin
7 | // https://github.com/acornjs/acorn-jsx/issues/112
8 | // @ts-ignore
9 | // but it does have a default export???
10 | import jsx from '@projectevergreen/acorn-jsx-esm';
11 | import { parse, parseFragment, serialize } from 'parse5';
12 | import { transform } from 'sucrase';
13 |
14 | const jsxRegex = /\.(jsx)$/;
15 |
16 | // TODO same hack as definitions
17 | // https://github.com/ProjectEvergreen/wcc/discussions/74
18 | let string;
19 |
20 | // TODO move to a util
21 | // https://github.com/ProjectEvergreen/wcc/discussions/74
22 | function getParse(html) {
23 | return html.indexOf('') >= 0 || html.indexOf('') >= 0 || html.indexOf('') >= 0
24 | ? parse
25 | : parseFragment;
26 | }
27 |
28 | export function getParser(moduleURL) {
29 | const isJSX = moduleURL.pathname.split('.').pop() === 'jsx';
30 |
31 | if (!isJSX) {
32 | return;
33 | }
34 |
35 | return {
36 | parser: acorn.Parser.extend(jsx()),
37 | config: {
38 | // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
39 | ...walk.base,
40 | JSXElement: () => {}
41 | }
42 | };
43 | }
44 |
45 | // replace all instances of __this__ marker with relative reference to the custom element parent node
46 | function applyDomDepthSubstitutions(tree, currentDepth = 1, hasShadowRoot = false) {
47 | try {
48 | for (const node of tree.childNodes) {
49 | const attrs = node.attrs;
50 |
51 | // check for attributes
52 | // and swap out __this__ with depthful parentElement chain
53 | if (attrs && attrs.length > 0) {
54 | for (const attr in attrs) {
55 | const { value } = attrs[attr];
56 |
57 | if (value.indexOf('__this__.') >= 0) {
58 | const root = hasShadowRoot ? '.getRootNode().host' : `${'.parentElement'.repeat(currentDepth)}`;
59 |
60 | node.attrs[attr].value = value.replace(/__this__/g, `this${root}`);
61 | }
62 | }
63 | }
64 |
65 | if (node.childNodes && node.childNodes.length > 0) {
66 | applyDomDepthSubstitutions(node, currentDepth + 1, hasShadowRoot);
67 | }
68 | }
69 | } catch (e) {
70 | console.error(e);
71 | }
72 |
73 | return tree;
74 | }
75 |
76 | function parseJsxElement(element, moduleContents = '') {
77 | try {
78 | const { type } = element;
79 |
80 | if (type === 'JSXElement') {
81 | const { openingElement } = element;
82 | const { attributes } = openingElement;
83 | const tagName = openingElement.name.name;
84 |
85 | string += `<${tagName}`;
86 |
87 | for (const attribute of attributes) {
88 | const { name } = attribute.name;
89 |
90 | // handle events
91 | if (name.startsWith('on')) {
92 | const { value } = attribute;
93 | const { expression } = value;
94 |
95 | // onclick={this.increment}
96 | if (value.type === 'JSXExpressionContainer') {
97 | if (expression.type === 'MemberExpression') {
98 | if (expression.object.type === 'ThisExpression') {
99 | if (expression.property.type === 'Identifier') {
100 | // we leave markers for `this` so we can replace it later while also NOT accidentally replacing
101 | // legitimate uses of this that might be actual content / markup of the custom element
102 | string += ` ${name}="__this__.${expression.property.name}()"`;
103 | }
104 | }
105 | }
106 |
107 | // onclick={() => this.deleteUser(user.id)}
108 | // TODO onclick={(e) => { this.deleteUser(user.id) }}
109 | // TODO onclick={(e) => { this.deleteUser(user.id) && this.logAction(user.id) }}
110 | // https://github.com/ProjectEvergreen/wcc/issues/88
111 | if (expression.type === 'ArrowFunctionExpression') {
112 | if (expression.body && expression.body.type === 'CallExpression') {
113 | const { start, end } = expression;
114 | string += ` ${name}="${moduleContents.slice(start, end).replace(/this./g, '__this__.').replace('() => ', '')}"`;
115 | }
116 | }
117 |
118 | if (expression.type === 'AssignmentExpression') {
119 | const { left, right } = expression;
120 |
121 | if (left.object.type === 'ThisExpression') {
122 | if (left.property.type === 'Identifier') {
123 | // very naive (fine grained?) reactivity
124 | string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.render();"`;
125 | }
126 | }
127 | }
128 | }
129 | } else if (attribute.name.type === 'JSXIdentifier') {
130 | // TODO is there any difference between an attribute for an event handler vs a normal attribute?
131 | // Can all these be parsed using one function>
132 | if (attribute.value) {
133 | if (attribute.value.type === 'Literal') {
134 | // xxx="yyy" >
135 | string += ` ${name}="${attribute.value.value}"`;
136 | } else if (attribute.value.type === 'JSXExpressionContainer') {
137 | // xxx={allTodos.length} >
138 | const { value } = attribute;
139 | const { expression } = value;
140 |
141 | if (expression.type === 'Identifier') {
142 | string += ` ${name}=$\{${expression.name}}`;
143 | }
144 |
145 | if (expression.type === 'MemberExpression') {
146 | if (expression.object.type === 'Identifier') {
147 | if (expression.property.type === 'Identifier') {
148 | string += ` ${name}=$\{${expression.object.name}.${expression.property.name}}`;
149 | }
150 | }
151 | }
152 | }
153 | } else {
154 | // xxx >
155 | string += ` ${name}`;
156 | }
157 | }
158 | }
159 |
160 | string += openingElement.selfClosing ? '/>' : '>';
161 |
162 | if (element.children.length > 0) {
163 | element.children.forEach(child => parseJsxElement(child, moduleContents));
164 | }
165 |
166 | string += `${tagName}>`;
167 | }
168 |
169 | if (type === 'JSXText') {
170 | string += element.raw;
171 | }
172 |
173 | if (type === 'JSXExpressionContainer') {
174 | const { type } = element.expression;
175 |
176 | if (type === 'Identifier') {
177 | // You have {count} TODOs left to complete
178 | string += `$\{${element.expression.name}}`;
179 | } else if (type === 'MemberExpression') {
180 | const { object } = element.expression.object;
181 |
182 | // You have {this.todos.length} Todos left to complete
183 | // https://github.com/ProjectEvergreen/wcc/issues/88
184 | if (object && object.type === 'ThisExpression') {
185 | // TODO ReferenceError: __this__ is not defined
186 | // string += `\$\{__this__.${element.expression.object.property.name}.${element.expression.property.name}\}`;
187 | } else {
188 | // const { todos } = this;
189 | // ....
190 | // You have {todos.length} Todos left to complete
191 | string += `$\{${element.expression.object.name}.${element.expression.property.name}}`;
192 | }
193 | }
194 | }
195 | } catch (e) {
196 | console.error(e);
197 | }
198 |
199 | return string;
200 | }
201 |
202 | // TODO handle if / else statements
203 | // https://github.com/ProjectEvergreen/wcc/issues/88
204 | function findThisReferences(context, statement) {
205 | const references = [];
206 | const isRenderFunctionContext = context === 'render';
207 | const { expression, type } = statement;
208 | const isConstructorThisAssignment = context === 'constructor'
209 | && type === 'ExpressionStatement'
210 | && expression.type === 'AssignmentExpression'
211 | && expression.left.object.type === 'ThisExpression';
212 |
213 | if (isConstructorThisAssignment) {
214 | // this.name = 'something'; // constructor
215 | references.push(expression.left.property.name);
216 | } else if (isRenderFunctionContext && type === 'VariableDeclaration') {
217 | statement.declarations.forEach(declaration => {
218 | const { init, id } = declaration;
219 |
220 | if (init.object && init.object.type === 'ThisExpression') {
221 | // const { description } = this.todo;
222 | references.push(init.property.name);
223 | } else if (init.type === 'ThisExpression' && id && id.properties) {
224 | // const { description } = this.todo;
225 | id.properties.forEach((property) => {
226 | references.push(property.key.name);
227 | });
228 | }
229 | });
230 | }
231 |
232 | return references;
233 | }
234 |
235 | export function parseJsx(moduleURL) {
236 | const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
237 | const result = transform(moduleContents, {
238 | transforms: ['typescript', 'jsx'],
239 | jsxRuntime: 'preserve'
240 | });
241 | // would be nice if we could do this instead, so we could know ahead of time
242 | // const { inferredObservability } = await import(moduleURL);
243 | // however, this requires making parseJsx async, but WCC acorn walking is done sync
244 | const hasOwnObservedAttributes = undefined;
245 | let inferredObservability = false;
246 | let observedAttributes = [];
247 | let tree = acorn.Parser.extend(jsx()).parse(result.code, {
248 | ecmaVersion: 'latest',
249 | sourceType: 'module'
250 | });
251 | string = '';
252 |
253 | walk.simple(tree, {
254 | ClassDeclaration(node) {
255 | // @ts-ignore
256 | if (node.superClass.name === 'HTMLElement') {
257 | const hasShadowRoot = moduleContents.slice(node.body.start, node.body.end).indexOf('this.attachShadow(') > 0;
258 |
259 | for (const n1 of node.body.body) {
260 | if (n1.type === 'MethodDefinition') {
261 | // @ts-ignore
262 | const nodeName = n1.key.name;
263 | if (nodeName === 'render') {
264 | for (const n2 in n1.value.body.body) {
265 | const n = n1.value.body.body[n2];
266 |
267 | if (n.type === 'VariableDeclaration') {
268 | observedAttributes = [
269 | ...observedAttributes,
270 | ...findThisReferences('render', n)
271 | ];
272 | // @ts-ignore
273 | } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
274 | const html = parseJsxElement(n.argument, moduleContents);
275 | const elementTree = getParse(html)(html);
276 | const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';
277 |
278 | applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);
279 |
280 | const serializedHtml = serialize(elementTree);
281 | // we have to Shadow DOM use cases here
282 | // 1. No shadowRoot, so we attachShadow and append the template
283 | // 2. If there is root from the attachShadow signal, so we just need to inject innerHTML, say in an htmx
284 | // could / should we do something else instead of .innerHTML
285 | // https://github.com/ProjectEvergreen/wcc/issues/138
286 | const renderHandler = hasShadowRoot
287 | ? `
288 | const template = document.createElement('template');
289 | template.innerHTML = \`${serializedHtml}\`;
290 |
291 | if(!${elementRoot}) {
292 | this.attachShadow({ mode: 'open' });
293 | this.shadowRoot.appendChild(template.content.cloneNode(true));
294 | } else {
295 | this.shadowRoot.innerHTML = template.innerHTML;
296 | }
297 | `
298 | : `${elementRoot}.innerHTML = \`${serializedHtml}\`;`;
299 | const transformed = acorn.parse(renderHandler, {
300 | ecmaVersion: 'latest',
301 | sourceType: 'module'
302 | });
303 |
304 | // @ts-ignore
305 | n1.value.body.body[n2] = transformed;
306 | }
307 | }
308 | }
309 | }
310 | }
311 | }
312 | },
313 | ExportNamedDeclaration(node) {
314 | const { declaration } = node;
315 |
316 | if (declaration && declaration.type === 'VariableDeclaration' && declaration.kind === 'const' && declaration.declarations.length === 1) {
317 | // @ts-ignore
318 | if (declaration.declarations[0].id.name === 'inferredObservability') {
319 | // @ts-ignore
320 | inferredObservability = Boolean(node.declaration.declarations[0].init.raw);
321 | }
322 | }
323 | }
324 | }, {
325 | // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
326 | ...walk.base,
327 | // @ts-ignore
328 | JSXElement: () => {}
329 | });
330 |
331 | // TODO - signals: use constructor, render, HTML attributes? some, none, or all?
332 | if (inferredObservability && observedAttributes.length > 0 && !hasOwnObservedAttributes) {
333 | let insertPoint;
334 | for (const line of tree.body) {
335 | // test for class MyComponent vs export default class MyComponent
336 | // @ts-ignore
337 | if (line.type === 'ClassDeclaration' || (line.declaration && line.declaration.type) === 'ClassDeclaration') {
338 | // @ts-ignore
339 | insertPoint = line.declaration.body.start + 1;
340 | }
341 | }
342 |
343 | let newModuleContents = generate(tree);
344 |
345 | // TODO better way to determine value type?
346 | newModuleContents = `${newModuleContents.slice(0, insertPoint)}
347 | static get observedAttributes() {
348 | return [${[...observedAttributes].map(attr => `'${attr}'`).join(',')}]
349 | }
350 |
351 | attributeChangedCallback(name, oldValue, newValue) {
352 | function getValue(value) {
353 | return value.charAt(0) === '{' || value.charAt(0) === '['
354 | ? JSON.parse(value)
355 | : !isNaN(value)
356 | ? parseInt(value, 10)
357 | : value === 'true' || value === 'false'
358 | ? value === 'true' ? true : false
359 | : value;
360 | }
361 | if (newValue !== oldValue) {
362 | switch(name) {
363 | ${observedAttributes.map((attr) => {
364 | return `
365 | case '${attr}':
366 | this.${attr} = getValue(newValue);
367 | break;
368 | `;
369 | }).join('\n')}
370 | }
371 |
372 | this.render();
373 | }
374 | }
375 |
376 | ${newModuleContents.slice(insertPoint)}
377 | `;
378 |
379 | tree = acorn.Parser.extend(jsx()).parse(newModuleContents, {
380 | ecmaVersion: 'latest',
381 | sourceType: 'module'
382 | });
383 | }
384 |
385 | return tree;
386 | }
387 |
388 | // --------------
389 |
390 | export function resolve(specifier, context, defaultResolve) {
391 | const { parentURL } = context;
392 |
393 | if (jsxRegex.test(specifier)) {
394 | return {
395 | url: new URL(specifier, parentURL).href,
396 | shortCircuit: true
397 | };
398 | }
399 |
400 | return defaultResolve(specifier, context, defaultResolve);
401 | }
402 |
403 | export async function load(url, context, defaultLoad) {
404 | if (jsxRegex.test(url)) {
405 | const jsFromJsx = parseJsx(new URL(url));
406 |
407 | return {
408 | format: 'module',
409 | source: generate(jsFromJsx),
410 | shortCircuit: true
411 | };
412 | }
413 |
414 | return defaultLoad(url, context, defaultLoad);
415 | }
--------------------------------------------------------------------------------
/src/register.js:
--------------------------------------------------------------------------------
1 | import { register } from 'node:module';
2 |
3 | register('./jsx-loader.js', import.meta.url);
--------------------------------------------------------------------------------
/src/wcc.js:
--------------------------------------------------------------------------------
1 | // this must come first
2 | import { getParse } from './dom-shim.js';
3 |
4 | import * as acorn from 'acorn';
5 | import * as walk from 'acorn-walk';
6 | import { generate } from 'astring';
7 | import { getParser, parseJsx } from './jsx-loader.js';
8 | import { serialize } from 'parse5';
9 | import { transform } from 'sucrase';
10 | import fs from 'fs';
11 |
12 | function isCustomElementDefinitionNode(node) {
13 | const { expression } = node;
14 |
15 | return expression.type === 'CallExpression' && expression.callee && expression.callee.object
16 | && expression.callee.property && expression.callee.object.name === 'customElements'
17 | && expression.callee.property.name === 'define';
18 | }
19 |
20 | async function renderComponentRoots(tree, definitions) {
21 | for (const node of tree.childNodes) {
22 | if (node.tagName && node.tagName.indexOf('-') > 0) {
23 | const { attrs, tagName } = node;
24 |
25 | if (definitions[tagName]) {
26 | const { moduleURL } = definitions[tagName];
27 | const elementInstance = await initializeCustomElement(moduleURL, tagName, node, definitions);
28 |
29 | if (elementInstance) {
30 | const hasShadow = elementInstance.shadowRoot;
31 |
32 | node.childNodes = hasShadow
33 | ? [...elementInstance.shadowRoot.childNodes, ...node.childNodes]
34 | : elementInstance.childNodes;
35 | } else {
36 | console.warn(`WARNING: customElement <${tagName}> detected but not serialized. You may not have exported it.`);
37 | }
38 | } else {
39 | console.warn(`WARNING: customElement <${tagName}> is not defined. You may not have imported it.`);
40 | }
41 |
42 | attrs.forEach((attr) => {
43 | if (attr.name === 'hydrate') {
44 | definitions[tagName].hydrate = attr.value;
45 | }
46 | });
47 |
48 | }
49 |
50 | if (node.childNodes && node.childNodes.length > 0) {
51 | await renderComponentRoots(node, definitions);
52 | }
53 |
54 | if (node.shadowRoot && node.shadowRoot.childNodes?.length > 0) {
55 | await renderComponentRoots(node.shadowRoot, definitions);
56 | }
57 |
58 | // does this only apply to `` tags?
59 | if (node.content && node.content.childNodes?.length > 0) {
60 | await renderComponentRoots(node.content, definitions);
61 | }
62 | }
63 |
64 | return tree;
65 | }
66 |
67 | function registerDependencies(moduleURL, definitions, depth = 0) {
68 | const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
69 | const result = transform(moduleContents, {
70 | transforms: ['typescript', 'jsx'],
71 | jsxRuntime: 'preserve'
72 | });
73 | const nextDepth = depth += 1;
74 | const customParser = getParser(moduleURL);
75 | const parser = customParser ? customParser.parser : acorn.Parser;
76 | const config = customParser ? customParser.config : {
77 | ...walk.base
78 | };
79 |
80 | walk.simple(parser.parse(result.code, {
81 | ecmaVersion: 'latest',
82 | sourceType: 'module'
83 | }), {
84 | ImportDeclaration(node) {
85 | const specifier = node.source.value;
86 |
87 | if (typeof specifier === 'string') {
88 | const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0;
89 | const extension = typeof specifier === "string" ? specifier.split('.').pop() : "";
90 |
91 | // would like to decouple .jsx from the core, ideally
92 | // https://github.com/ProjectEvergreen/wcc/issues/122
93 | if (!isBareSpecifier && ['js', 'jsx', 'ts'].includes(extension)) {
94 | const dependencyModuleURL = new URL(specifier, moduleURL);
95 |
96 | registerDependencies(dependencyModuleURL, definitions, nextDepth);
97 | }
98 | }
99 | },
100 | ExpressionStatement(node) {
101 | if (isCustomElementDefinitionNode(node)) {
102 | // @ts-ignore
103 | const { arguments: args } = node.expression;
104 | const tagName = args[0].type === 'Literal'
105 | ? args[0].value // single and double quotes
106 | : args[0].quasis[0].value.raw; // template literal
107 | const tree = parseJsx(moduleURL);
108 | const isEntry = nextDepth - 1 === 1;
109 |
110 | definitions[tagName] = {
111 | instanceName: args[1].name,
112 | moduleURL,
113 | source: generate(tree),
114 | url: moduleURL,
115 | isEntry
116 | };
117 | }
118 | }
119 | }, config);
120 | }
121 |
122 | async function getTagName(moduleURL) {
123 | const moduleContents = await fs.promises.readFile(moduleURL, 'utf-8');
124 | const result = transform(moduleContents, {
125 | transforms: ['typescript', 'jsx'],
126 | jsxRuntime: 'preserve'
127 | });
128 | const customParser = getParser(moduleURL);
129 | const parser = customParser ? customParser.parser : acorn.Parser;
130 | const config = customParser ? customParser.config : {
131 | ...walk.base
132 | };
133 | let tagName;
134 |
135 | walk.simple(parser.parse(result.code, {
136 | ecmaVersion: 'latest',
137 | sourceType: 'module'
138 | }), {
139 | ExpressionStatement(node) {
140 | if (isCustomElementDefinitionNode(node)) {
141 | // @ts-ignore
142 | tagName = node.expression.arguments[0].value;
143 | }
144 | }
145 | }, config);
146 |
147 | return tagName;
148 | }
149 |
150 | async function initializeCustomElement(elementURL, tagName, node = {}, definitions = {}, isEntry, props = {}) {
151 |
152 | if (!tagName) {
153 | const depth = isEntry ? 1 : 0;
154 | registerDependencies(elementURL, definitions, depth);
155 | }
156 |
157 | const element = customElements.get(tagName) ?? (await import(elementURL)).default;
158 | const dataLoader = (await import(elementURL)).getData;
159 | const data = props ? props : dataLoader ? await dataLoader(props) : {};
160 |
161 | if (element) {
162 | const elementInstance = new element(data);
163 |
164 | Object.assign(elementInstance, node);
165 |
166 | await elementInstance.connectedCallback();
167 |
168 | return elementInstance;
169 | }
170 | }
171 |
172 | /** @type {import('./index.d.ts').renderToString} */
173 | async function renderToString(elementURL, wrappingEntryTag = true, props = {}) {
174 | /** @type {import('./index.d.ts').Metadata} */
175 | const definitions = {};
176 | const elementTagName = wrappingEntryTag && await getTagName(elementURL);
177 | const isEntry = !!elementTagName;
178 | const elementInstance = await initializeCustomElement(elementURL, undefined, undefined, definitions, isEntry, props);
179 |
180 | let html;
181 |
182 | // in case the entry point isn't valid
183 | if (elementInstance) {
184 | elementInstance.nodeName = elementTagName ?? '';
185 | elementInstance.tagName = elementTagName ?? '';
186 |
187 | await renderComponentRoots(
188 | elementInstance.shadowRoot
189 | ?
190 | {
191 | nodeName: '#document-fragment',
192 | childNodes: [elementInstance]
193 | }
194 | : elementInstance,
195 | definitions
196 | );
197 |
198 | html = wrappingEntryTag && elementTagName ? `
199 | <${elementTagName}>
200 | ${serialize(elementInstance)}
201 | ${elementTagName}>
202 | `
203 | : serialize(elementInstance);
204 | } else {
205 | console.warn('WARNING: No custom element class found for this entry point.');
206 | }
207 |
208 | return {
209 | html,
210 | metadata: definitions
211 | };
212 | }
213 |
214 | /** @type {import('./index.d.ts').renderFromHTML} */
215 | async function renderFromHTML(html, elements = []) {
216 | /** @type {import('./index.d.ts').Metadata} */
217 | const definitions = {};
218 |
219 | for (const url of elements) {
220 | registerDependencies(url, definitions, 1);
221 | }
222 |
223 | const elementTree = getParse(html)(html);
224 | const finalTree = await renderComponentRoots(elementTree, definitions);
225 |
226 | return {
227 | html: serialize(finalTree),
228 | metadata: definitions
229 | };
230 | }
231 |
232 | export {
233 | renderToString,
234 | renderFromHTML
235 | };
--------------------------------------------------------------------------------
/test-loader.js:
--------------------------------------------------------------------------------
1 | // https://jestjs.io/docs/ecmascript-modules
2 | // https://github.com/nodejs/node/discussions/41711
3 | import fs from 'fs';
4 | import path from 'path';
5 | import { load as experimentalLoadJsx, resolve as experimentalResolveJsx } from './src/jsx-loader.js';
6 |
7 | export async function load(url, context, defaultLoad) {
8 | const ext = path.extname(url);
9 |
10 | if (ext === '') {
11 | return loadBin(url, context, defaultLoad);
12 | } else if (ext === '.jsx') {
13 | return experimentalLoadJsx(url, context, defaultLoad);
14 | } else if (ext === '.css') {
15 | return {
16 | format: 'module',
17 | shortCircuit: true,
18 | source: `const css = \`${(await fs.promises.readFile(new URL(url), 'utf-8')).replace(/\r?\n|\r/g, ' ').replace(/\\/g, '\\\\')}\`;\nexport default css;`
19 | };
20 | }
21 |
22 | return defaultLoad(url, context, defaultLoad);
23 | }
24 |
25 | export function resolve(specifier, context, defaultResolve) {
26 | const ext = path.extname(specifier);
27 |
28 | if (ext === '.jsx') {
29 | return experimentalResolveJsx(specifier, context, defaultResolve);
30 | } else {
31 | return defaultResolve(specifier, context, defaultResolve);
32 | }
33 | }
34 |
35 | async function loadBin(url, context, defaultLoad) {
36 | const dirs = path.dirname(url.replace(/[A-Z]:\//g, '')).split('/');
37 | const parentDir = dirs.at(-1);
38 | const grandparentDir = dirs.at(-3);
39 |
40 | let format;
41 |
42 | if (parentDir === 'bin' && grandparentDir === 'node_modules') {
43 | const libPkgUrl = new URL('../package.json', url);
44 | const { type } = await fs.promises.readFile(libPkgUrl).then(JSON.parse);
45 |
46 | format = type === 'module' ? 'module' : 'commonjs';
47 | }
48 |
49 | return defaultLoad(url, {
50 | ...context,
51 | format
52 | });
53 | }
--------------------------------------------------------------------------------
/test-register.js:
--------------------------------------------------------------------------------
1 | import { register } from 'node:module';
2 |
3 | register('./test-loader.js', import.meta.url);
--------------------------------------------------------------------------------
/test/cases/attributes/attributes.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element with attributes and declarative shadow dom
4 | *
5 | * User Result
6 | * Should return the expected HTML output based on the attribute values.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * counter.js
12 | * index.js
13 | */
14 |
15 | import chai from 'chai';
16 | import { JSDOM } from 'jsdom';
17 | import { renderToString } from '../../../src/wcc.js';
18 |
19 | const expect = chai.expect;
20 |
21 | describe('Run WCC For ', function() {
22 | const LABEL = 'Custom Element w/ Attributes and Shadow DOM';
23 | let dom;
24 |
25 | before(async function() {
26 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
27 |
28 | dom = new JSDOM(html);
29 | });
30 |
31 | describe(LABEL, function() {
32 | it('should have one top level custom element with a with an open shadowroot', function() {
33 | expect(dom.window.document.querySelectorAll('wcc-counter template[shadowrootmode="open"]').length).to.equal(1);
34 | expect(dom.window.document.querySelectorAll('wcc-counter template').length).to.equal(1);
35 | });
36 |
37 | describe('static page content', function() {
38 | it('should have the expected static content for the page', function() {
39 | expect(dom.window.document.querySelector('h1').textContent).to.equal('Counter');
40 | });
41 | });
42 |
43 | describe('custom element', function() {
44 | let counterContentsDom;
45 |
46 | before(function() {
47 | counterContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-counter template[shadowrootmode="open"]')[0].innerHTML);
48 | });
49 |
50 | it('should have two tags within the shadowroot', function() {
51 | expect(counterContentsDom.window.document.querySelectorAll('button').length).to.equal(2);
52 | });
53 |
54 | it('should have a with the value of the attribute as its text content', function() {
55 | const count = counterContentsDom.window.document.querySelector('span#count').textContent;
56 |
57 | expect(count).to.equal('5');
58 | });
59 | });
60 | });
61 | });
--------------------------------------------------------------------------------
/test/cases/attributes/src/components/counter.js:
--------------------------------------------------------------------------------
1 | class Counter extends HTMLElement {
2 | #count;
3 |
4 | constructor(props = {}) {
5 | super();
6 |
7 | this.props = props;
8 | this.#count = 0;
9 |
10 | if (this.shadowRoot) {
11 | this.hydrate();
12 | }
13 | }
14 |
15 | connectedCallback() {
16 | if (!this.shadowRoot) {
17 | this.setCount();
18 | this.attachShadow({ mode: 'open' });
19 | this.shadowRoot.innerHTML = this.render();
20 | }
21 | }
22 |
23 | setCount() {
24 | this.#count = this.hasAttribute('count')
25 | ? parseInt(this.getAttribute('count'), 10)
26 | : this.props.count
27 | ? this.props.count
28 | : this.#count;
29 | }
30 |
31 | inc() {
32 | this.#count += 1;
33 | this.update();
34 | }
35 |
36 | dec() {
37 | this.#count -= 1;
38 | this.update();
39 | }
40 |
41 | hydrate() {
42 | this.count = parseInt(JSON.parse(this.shadowRoot.querySelector('script[type="application/json"]').text).count, 10);
43 |
44 | const buttonDec = this.shadowRoot.querySelector('button#dec');
45 | const buttonInc = this.shadowRoot.querySelector('button#inc');
46 |
47 | buttonDec.addEventListener('click', this.dec.bind(this));
48 | buttonInc.addEventListener('click', this.inc.bind(this));
49 | }
50 |
51 | update() {
52 | this.shadowRoot.querySelector('span#count').textContent = this.#count;
53 | }
54 |
55 | render() {
56 | return `
57 |
58 | Increment
59 | Current Count: ${this.#count}
60 | Decrement
61 |
62 | `;
63 | }
64 | }
65 |
66 | export {
67 | Counter
68 | };
69 |
70 | customElements.define('wcc-counter', Counter);
--------------------------------------------------------------------------------
/test/cases/attributes/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/counter.js';
2 |
3 | const template = document.createElement('template');
4 |
5 | template.innerHTML = `
6 | Counter
7 |
8 |
9 | `;
10 |
11 | export default class HomePage extends HTMLElement {
12 | connectedCallback() {
13 | this.innerHTML = `
14 | Counter
15 |
16 |
17 | `;
18 | }
19 | }
--------------------------------------------------------------------------------
/test/cases/children-and-slots/children-and-slots.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against custom elements with declarative shadow dom that use `` and other custom content.
4 | *
5 | * User Result
6 | * Should return the expected slotted HTML output for all custom elements.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * my-paragraph.js
12 | * pages/
13 | * index.js
14 | */
15 |
16 | import chai from 'chai';
17 | import { JSDOM } from 'jsdom';
18 | import { renderToString } from '../../../src/wcc.js';
19 |
20 | const expect = chai.expect;
21 |
22 | describe('Run WCC For ', function() {
23 | const LABEL = 'Custom Element w/ Declarative Shadow DOM and using children and content';
24 | let dom;
25 |
26 | before(async function() {
27 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
28 |
29 | dom = new JSDOM(html);
30 | });
31 |
32 | describe(LABEL, function() {
33 | it('should have one two top level custom elements with a with an open shadowroot', function() {
34 | expect(dom.window.document.querySelectorAll('wcc-paragraph template[shadowrootmode="open"]').length).to.equal(2);
35 | expect(dom.window.document.querySelectorAll('wcc-paragraph template').length).to.equal(2);
36 | });
37 |
38 | describe(' with default content', function() {
39 | let paragraphContentsDom;
40 |
41 | before(function() {
42 | paragraphContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-paragraph.default template[shadowrootmode="open"]')[0].innerHTML);
43 | });
44 |
45 | it('should have one tag for the default content', function() {
46 | expect(paragraphContentsDom.window.document.querySelectorAll('p').length).to.equal(1);
47 | });
48 |
49 | it('should have one tag with the default content', function() {
50 | expect(paragraphContentsDom.window.document.querySelector('p').textContent).to.equal('My default text');
51 | });
52 | });
53 |
54 | describe(' with custom content', function() {
55 | let paragraphContentsDom;
56 | let paragraphContentsLightDom;
57 |
58 | before(function() {
59 | paragraphContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-paragraph.custom template[shadowrootmode="open"]')[0].innerHTML);
60 | paragraphContentsLightDom = new JSDOM(dom.window.document.querySelectorAll('wcc-paragraph.custom')[0].innerHTML);
61 | });
62 |
63 | it('should have one tag for the default content', function() {
64 | expect(paragraphContentsDom.window.document.querySelectorAll('p').length).to.equal(1);
65 | });
66 |
67 | it('should have one tag with the custom content in the light DOM', function() {
68 | expect(paragraphContentsLightDom.window.document.querySelector('span').textContent).to.equal('Let\'s have some different text!');
69 | });
70 | });
71 | });
72 | });
--------------------------------------------------------------------------------
/test/cases/children-and-slots/src/components/paragraph.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 |
11 | My default text
12 | `;
13 |
14 | class MyParagraph extends HTMLElement {
15 | connectedCallback() {
16 | if (!this.shadowRoot) {
17 | this.attachShadow({ mode: 'open' });
18 | this.shadowRoot.appendChild(template.content.cloneNode(true));
19 | }
20 | }
21 | }
22 |
23 | export default MyParagraph;
24 |
25 | customElements.define('wcc-paragraph', MyParagraph);
--------------------------------------------------------------------------------
/test/cases/children-and-slots/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/paragraph.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = this.getTemplate();
6 | }
7 |
8 | getTemplate() {
9 | return `
10 | Home Page
11 |
12 |
13 |
14 |
15 | Let's have some different text!
16 |
17 | `;
18 | }
19 | }
--------------------------------------------------------------------------------
/test/cases/constructable-stylesheet/constructabe-stylesheet.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element using constructible stylesheets.
4 | *
5 | * User Result
6 | * Should return the expected HTML and no error instatiating a CSSStyleSheet..
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * header/
12 | * header.js
13 | * pages/
14 | * index.js
15 | */
16 | import chai from 'chai';
17 | import { JSDOM } from 'jsdom';
18 | import { renderToString } from '../../../src/wcc.js';
19 |
20 | const expect = chai.expect;
21 |
22 | describe('Run WCC For ', function() {
23 | const LABEL = 'Constructible Stylesheets usage';
24 | let dom;
25 |
26 | before(async function() {
27 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
28 |
29 | dom = new JSDOM(html);
30 | });
31 |
32 | describe(LABEL, function() {
33 | it('should have one top level element with a with an open shadowroot', function() {
34 | expect(dom.window.document.querySelectorAll('wcc-header template[shadowrootmode="open"]').length).to.equal(1);
35 | expect(dom.window.document.querySelectorAll('template').length).to.equal(1);
36 | });
37 | });
38 | });
--------------------------------------------------------------------------------
/test/cases/constructable-stylesheet/src/components/header/header.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 |
5 | Welcome to my website!
6 |
7 | `;
8 |
9 | const sheet = new CSSStyleSheet();
10 | sheet.replaceSync('li{color:red;}');
11 |
12 | export default class Header extends HTMLElement {
13 | connectedCallback() {
14 | if (!this.shadowRoot) {
15 | this.attachShadow({ mode: 'open' });
16 | this.shadowRoot.appendChild(template.content.cloneNode(true));
17 | }
18 |
19 | this.shadowRoot.adoptedStyleSheets = [sheet];
20 | }
21 | }
22 |
23 | customElements.define('wcc-header', Header);
--------------------------------------------------------------------------------
/test/cases/constructable-stylesheet/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/header/header.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = `
6 |
7 | Home Page
8 | `;
9 | }
10 | }
--------------------------------------------------------------------------------
/test/cases/constructor-props/constructor-props.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element passing in constructor props.
4 | *
5 | * User Result
6 | * Should return the expected HTML output based on the fetched content from constructor props.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function() {
20 | const LABEL = 'Custom Element w/ constructor props';
21 | const postId = 1;
22 | let dom;
23 |
24 | before(async function() {
25 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url), false, postId);
26 |
27 | dom = new JSDOM(html);
28 | });
29 |
30 | describe(LABEL, function() {
31 | it('should have a heading tag with the postId', function() {
32 | expect(dom.window.document.querySelectorAll('h1')[0].textContent).to.equal(`Fetched Post ID: ${postId}`);
33 | });
34 |
35 | it('should have a second heading tag with the title', function() {
36 | expect(dom.window.document.querySelectorAll('h2')[0].textContent).to.equal('sunt aut facere repellat provident occaecati excepturi optio reprehenderit');
37 | });
38 |
39 | it('should have a heading tag with the body', function() {
40 | expect(dom.window.document.querySelectorAll('p')[0].textContent.startsWith('quia et suscipit')).to.equal(true);
41 | });
42 | });
43 | });
--------------------------------------------------------------------------------
/test/cases/constructor-props/src/index.js:
--------------------------------------------------------------------------------
1 | export default class PostPage extends HTMLElement {
2 | constructor(postId) {
3 | super();
4 |
5 | this.postId = postId;
6 | }
7 |
8 | async connectedCallback() {
9 | const { postId } = this;
10 | const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then(resp => resp.json());
11 | const { id, title, body } = post;
12 |
13 | this.innerHTML = `
14 | Fetched Post ID: ${id}
15 | ${title}
16 | ${body}
17 | `;
18 | }
19 | }
--------------------------------------------------------------------------------
/test/cases/create-document-fragment/create-document-fragment.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a component which creates two document fragments and appends them with appendChild.
4 | *
5 | * User Result
6 | * Should return the expected HTML output based on the content of the appended fragments.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function () {
20 | const LABEL = 'Custom Element w/ Document Fragments';
21 | let dom;
22 |
23 | before(async function () {
24 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
25 | dom = new JSDOM(html);
26 | });
27 |
28 | describe(LABEL, function () {
29 | it('should have a heading tag with text content equal to "document.createDocumentFragment()"', function () {
30 | expect(dom.window.document.querySelectorAll('h2')[0].textContent).to.equal('document.createDocumentFragment()');
31 | });
32 | });
33 |
34 | describe(LABEL, function () {
35 | it('should have a heading tag with text content equal to new "DocumentFragment()"', function () {
36 | expect(dom.window.document.querySelectorAll('h2')[1].textContent).to.equal('new DocumentFragment()');
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/test/cases/create-document-fragment/src/index.js:
--------------------------------------------------------------------------------
1 | export default class DocumentFragmentComponent extends HTMLElement {
2 |
3 | connectedCallback() {
4 | const fragment1 = document.createDocumentFragment();
5 | const fragment2 = new DocumentFragment();
6 |
7 | const h1 = document.createElement('h2');
8 | h1.textContent = 'document.createDocumentFragment()';
9 | fragment1.appendChild(h1);
10 |
11 | const h2 = document.createElement('h2');
12 | h2.textContent = 'new DocumentFragment()';
13 | fragment2.appendChild(h2);
14 |
15 | this.appendChild(fragment1);
16 | this.appendChild(fragment2);
17 | }
18 | }
19 |
20 | customElements.define('document-fragment-component', DocumentFragmentComponent);
--------------------------------------------------------------------------------
/test/cases/custom-extension/custom-extension.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element that uses a custom extension for a CSS file.
4 | *
5 | * User Result
6 | * Should return the expected HTML output.
7 | *
8 | * User Workspace
9 | * src/
10 | * banner.css
11 | * banner.js
12 | * footer.js
13 | */
14 | import chai from 'chai';
15 | import { JSDOM } from 'jsdom';
16 | import { renderFromHTML } from '../../../src/wcc.js';
17 |
18 | const expect = chai.expect;
19 |
20 | describe('Run WCC For ', function() {
21 | const LABEL = 'Single Custom Element w/ a custom extension reference (CSS)';
22 | let dom;
23 | let rawHtml;
24 |
25 | before(async function() {
26 | const { html } = await renderFromHTML(' ', [
27 | new URL('./src/footer.js', import.meta.url)
28 | ]);
29 |
30 | rawHtml = html;
31 | dom = new JSDOM(html);
32 | });
33 |
34 | describe(LABEL, function() {
35 |
36 | it('should NOT have a tag in the content of the page', function() {
37 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
38 | });
39 |
40 | it('should NOT have a tag in the content of the page', function() {
41 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
42 | });
43 |
44 | it('should NOT have a tag in the content of the page', function() {
45 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
46 | });
47 |
48 | it('should have one top level element with a with an open shadowroot', function() {
49 | expect(dom.window.document.querySelectorAll('wcc-footer template[shadowrootmode="open"]').length).to.equal(1);
50 | expect(dom.window.document.querySelectorAll('template').length).to.equal(1);
51 | });
52 |
53 | describe(' component and content', function() {
54 | let footer;
55 |
56 | before(async function() {
57 | footer = new JSDOM(dom.window.document.querySelectorAll('wcc-footer template[shadowrootmode="open"]')[0].innerHTML);
58 | });
59 |
60 | it('should have one tag within the shadowroot', function() {
61 | expect(footer.window.document.querySelectorAll('footer').length).to.equal(1);
62 | });
63 |
64 | it('should have the expected content for the tag', function() {
65 | expect(footer.window.document.querySelectorAll('h4 a').textContent).to.contain(/My Blog/);
66 | });
67 | });
68 |
69 | });
70 | });
--------------------------------------------------------------------------------
/test/cases/custom-extension/src/banner.css:
--------------------------------------------------------------------------------
1 | :host h4 {
2 | color: red;
3 | }
--------------------------------------------------------------------------------
/test/cases/custom-extension/src/banner.js:
--------------------------------------------------------------------------------
1 | import css from './banner.css';
2 |
3 | const template = document.createElement('template');
4 |
5 | template.innerHTML = `
6 |
9 |
10 |
13 | `;
14 |
15 | class Banner extends HTMLElement {
16 | constructor() {
17 | super();
18 |
19 | if (this.shadowRoot) {
20 | console.debug('Banner => shadowRoot detected!');
21 | }
22 | }
23 |
24 | connectedCallback() {
25 | if (!this.shadowRoot) {
26 | this.attachShadow({ mode: 'open' });
27 | this.shadowRoot.appendChild(template.content.cloneNode(true));
28 | }
29 | }
30 | }
31 |
32 | export default Banner;
33 |
34 | customElements.define('wcc-banner', Banner);
--------------------------------------------------------------------------------
/test/cases/custom-extension/src/footer.js:
--------------------------------------------------------------------------------
1 | import './banner.js';
2 |
3 | const template = document.createElement('template');
4 |
5 | template.innerHTML = `
6 |
9 | `;
10 |
11 | class Footer extends HTMLElement {
12 | constructor() {
13 | super();
14 |
15 | if (this.shadowRoot) {
16 | console.debug('Footer => shadowRoot detected!');
17 | }
18 | }
19 |
20 | connectedCallback() {
21 | if (!this.shadowRoot) {
22 | this.attachShadow({ mode: 'open' });
23 | this.shadowRoot.appendChild(template.content.cloneNode(true));
24 | }
25 | }
26 | }
27 |
28 | export default Footer;
29 |
30 | customElements.define('wcc-footer', Footer);
--------------------------------------------------------------------------------
/test/cases/element-props/element-props.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a component that passes properties to a child component.
4 | *
5 | * User Result
6 | * Should return the expected HTML output based on the content of the passed property.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | * renderer.js
12 | * components/
13 | * prop-passer.js
14 | * prop-receiver.js
15 | */
16 |
17 | import chai from 'chai';
18 | import { JSDOM } from 'jsdom';
19 | import { renderToString } from '../../../src/wcc.js';
20 |
21 | const expect = chai.expect;
22 |
23 | describe('Run WCC For ', function () {
24 | const LABEL = 'Custom Element w/ Element Properties';
25 | let dom;
26 |
27 | before(async function () {
28 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
29 | dom = new JSDOM(html);
30 | });
31 |
32 | describe(LABEL, function () {
33 | it('should have a prop-receiver component with a heading tag with text content equal to "bar"', function () {
34 | expect(dom.window.document.querySelector('prop-receiver h2').textContent).to.equal('bar');
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/test/cases/element-props/src/components/prop-passer.js:
--------------------------------------------------------------------------------
1 | import { html, render } from '../renderer.js';
2 |
3 | export default class PropPasser extends HTMLElement {
4 | connectedCallback() {
5 | const data = { foo: 'bar' };
6 | render(html` `, this);
7 | }
8 | }
9 |
10 | customElements.define('prop-passer', PropPasser);
--------------------------------------------------------------------------------
/test/cases/element-props/src/components/prop-receiver.js:
--------------------------------------------------------------------------------
1 | import { html, render } from '../renderer.js';
2 |
3 | export default class ProperReceiver extends HTMLElement {
4 | connectedCallback() {
5 | render(html`${this.data.foo} `, this);
6 | }
7 | }
8 |
9 | customElements.define('prop-receiver', ProperReceiver);
--------------------------------------------------------------------------------
/test/cases/element-props/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/prop-passer.js';
2 | import './components/prop-receiver.js';
3 |
4 | export default class ElementProps extends HTMLElement {
5 | connectedCallback() {
6 | this.innerHTML = ' ';
7 | }
8 | }
9 |
10 | customElements.define('element-props', ElementProps);
--------------------------------------------------------------------------------
/test/cases/element-props/src/renderer.js:
--------------------------------------------------------------------------------
1 | import { parseFragment } from 'parse5';
2 |
3 | function generateUUID() {
4 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
5 | const r = Math.floor(Math.random() * 16);
6 | const v = c === 'x' ? r : (r % 4) + 8;
7 | return v.toString(16);
8 | });
9 | }
10 |
11 | function removeAttribute(element, attribute) {
12 | element.attrs = element.attrs.filter((attr) => attr.name !== attribute);
13 | }
14 |
15 | function handlePropertyAttribute(element, attribute, value, deps) {
16 | const propName = attribute.substring(1);
17 | removeAttribute(element, attribute);
18 | if (!element.props) { element.props = {}; }
19 | element[propName] = deps[value] ?? value;
20 | }
21 |
22 | function buildStringFromTemplate(template) {
23 | const { strings, values } = template;
24 |
25 | if (!strings || !values) {
26 | return { string: '', deps: {} };
27 | }
28 |
29 | const stringParts = [];
30 | const deps = {};
31 | let isElement = false;
32 |
33 | strings.reduce((acc, stringAtIndex, index) => {
34 | acc.push(stringAtIndex);
35 |
36 | isElement =
37 | stringAtIndex.includes('<') || stringAtIndex.includes('>')
38 | ? stringAtIndex.lastIndexOf('<') > stringAtIndex.lastIndexOf('>')
39 | : isElement;
40 |
41 | const valueAtIndex = values[index];
42 |
43 | if (valueAtIndex != null) {
44 | const isPrimitive = typeof valueAtIndex === 'string' || typeof valueAtIndex === 'number';
45 | const valueKey = isPrimitive ? null : generateUUID() + index;
46 | const lastPart = acc[acc.length - 1];
47 | const needsQuotes = isElement && !lastPart.endsWith('"');
48 | acc.push(`${needsQuotes ? '"' : ''}${valueKey !== null ? valueKey : valueAtIndex}${needsQuotes ? '"' : ''}`);
49 |
50 | if (valueKey) {
51 | deps[valueKey] = valueAtIndex;
52 | }
53 | }
54 | return acc;
55 | }, stringParts);
56 |
57 | return { string: stringParts.join(''), deps };
58 | }
59 |
60 | function setAttributes(childNodes, deps) {
61 | childNodes.forEach((element, index)=>{
62 | const { attrs, nodeName } = element;
63 | if (nodeName === '#comment') { return; }
64 | attrs?.forEach(({ name, value }) => {
65 | if (name.startsWith('.')) {
66 | handlePropertyAttribute(childNodes[index], name, value, deps);
67 | }
68 | });
69 | if (element.childNodes) {
70 | setAttributes(element.childNodes, deps);
71 | }
72 | });
73 | }
74 |
75 | export function render(content, container) {
76 | const { string, deps } = buildStringFromTemplate(content);
77 | const parsedContent = parseFragment(string);
78 |
79 | setAttributes(parsedContent.childNodes, deps);
80 | const template = document.createElement('template');
81 | template.content.childNodes = parsedContent.childNodes;
82 | container.appendChild(template.content.cloneNode(true));
83 | }
84 |
85 | export const html = (strings, ...values) => {
86 | return {
87 | strings,
88 | values
89 | };
90 | };
--------------------------------------------------------------------------------
/test/cases/empty/empty.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element using with no internal definitions
4 | *
5 | * User Result
6 | * Should run without any errors from the DOM shim.
7 | *
8 | * User Workspace
9 | * src/
10 | * empty.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { renderToString } from '../../../src/wcc.js';
15 |
16 | const expect = chai.expect;
17 |
18 | describe('Run WCC For ', function() {
19 | const LABEL = 'Single Custom Element with an empty class definition';
20 | let rawHtml;
21 |
22 | before(async function() {
23 | const { html } = await renderToString(new URL('./src/empty.js', import.meta.url));
24 |
25 | rawHtml = html;
26 | });
27 |
28 | describe(LABEL, function() {
29 | it('should be an empty string', function() {
30 | expect(rawHtml).to.equal('');
31 | });
32 | });
33 |
34 | });
--------------------------------------------------------------------------------
/test/cases/empty/src/empty.js:
--------------------------------------------------------------------------------
1 | export default class EmptyComponent extends HTMLElement {
2 |
3 | }
--------------------------------------------------------------------------------
/test/cases/event-listener/event-listener.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element using addEventListener
4 | *
5 | * User Result
6 | * Should run without any errors from the DOM shim.
7 | *
8 | * User Workspace
9 | * src/
10 | * my-component.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { renderToString } from '../../../src/wcc.js';
15 |
16 | const expect = chai.expect;
17 |
18 | describe('Run WCC For ', function() {
19 | const LABEL = 'Single Custom Element using addEventListener';
20 | let rawHtml;
21 |
22 | before(async function() {
23 | const { html } = await renderToString(new URL('./src/my-component.js', import.meta.url));
24 |
25 | rawHtml = html;
26 | });
27 |
28 | describe(LABEL, function() {
29 |
30 | it('should do something', function() {
31 | expect(rawHtml).to.contain('It worked! ');
32 | });
33 | });
34 | });
--------------------------------------------------------------------------------
/test/cases/event-listener/src/my-component.js:
--------------------------------------------------------------------------------
1 | class MyComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 |
5 | this.addEventListener('someCustomEvent', () => { console.log('it worked!'); });
6 | globalThis.addEventListener('someCustomEvent2', () => { console.log('it also worked!'); });
7 | }
8 |
9 | connectedCallback() {
10 | this.innerHTML = 'It worked! ';
11 | }
12 | }
13 |
14 | export default MyComponent;
15 |
16 | customElements.define('my-component', MyComponent);
--------------------------------------------------------------------------------
/test/cases/full-document-component/full-document-component.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a component which sets innerHTML to a full HTML document.
4 | *
5 | * User Result
6 | * Should return the expected HTML output with a component containing a full HTML document.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { renderToString } from '../../../src/wcc.js';
15 |
16 | const expect = chai.expect;
17 |
18 | describe('Run WCC For ', function () {
19 | const LABEL = 'Custom Element w/ Document Fragments';
20 | let renderedContent;
21 |
22 | before(async function () {
23 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
24 | renderedContent = html.replace(/\s+/g, '');
25 | });
26 |
27 | describe(LABEL, function () {
28 | it('should have a heading tag with text renderedContent equal to "document.createDocumentFragment()"', function () {
29 | expect(renderedContent).to.equal(
30 | 'App Layout App Layout '.replace(
31 | /\s+/g,
32 | ''
33 | )
34 | );
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/test/cases/full-document-component/src/index.js:
--------------------------------------------------------------------------------
1 | export default class FullDocumentComponent extends HTMLElement {
2 |
3 | connectedCallback() {
4 | this.innerHTML = `
5 |
6 |
7 |
8 | App Layout
9 |
10 |
11 |
12 | App Layout
13 |
14 |
15 | `;
16 | }
17 | }
18 |
19 | customElements.define('full-document-component', FullDocumentComponent);
--------------------------------------------------------------------------------
/test/cases/get-data/get-data.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element with getData function and declarative shadow dom
4 | *
5 | * User Result
6 | * Should return the expected HTML output based on the attribute values.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * counter.js
12 | * index.js
13 | */
14 |
15 | import chai from 'chai';
16 | import { JSDOM } from 'jsdom';
17 | import { renderToString } from '../../../src/wcc.js';
18 |
19 | const expect = chai.expect;
20 |
21 | describe('Run WCC For ', function() {
22 | const LABEL = 'Custom Element w/ getData and Shadow DOM';
23 | let dom;
24 |
25 | before(async function() {
26 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
27 |
28 | dom = new JSDOM(html);
29 | });
30 |
31 | describe(LABEL, function() {
32 | it('should have one top level custom element with a with an open shadowroot', function() {
33 | expect(dom.window.document.querySelectorAll('wcc-counter template[shadowrootmode="open"]').length).to.equal(1);
34 | expect(dom.window.document.querySelectorAll('wcc-counter template').length).to.equal(1);
35 | });
36 |
37 | describe('static page content', function() {
38 | it('should have the expected static content for the page', function() {
39 | expect(dom.window.document.querySelector('h1').textContent).to.equal('Counter');
40 | });
41 | });
42 |
43 | describe('custom element', function() {
44 | let counterContentsDom;
45 | let count;
46 |
47 | before(function() {
48 | counterContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-counter template[shadowrootmode="open"]')[0].innerHTML);
49 | count = JSON.parse(counterContentsDom.window.document.querySelector('script[type="application/json"]').textContent).count;
50 | });
51 |
52 | it('should have two tags within the shadowroot', function() {
53 | expect(counterContentsDom.window.document.querySelectorAll('button').length).to.equal(2);
54 | });
55 |
56 | it('should have a with the value of the attribute as its text content', function() {
57 | const innerCount = counterContentsDom.window.document.querySelector('span#count').textContent;
58 |
59 | expect(count).to.equal(parseInt(innerCount, 10));
60 | });
61 | });
62 | });
63 | });
--------------------------------------------------------------------------------
/test/cases/get-data/src/components/counter.js:
--------------------------------------------------------------------------------
1 | class Counter extends HTMLElement {
2 | constructor(props = {}) {
3 | super();
4 |
5 | this.props = props;
6 |
7 | if (this.shadowRoot) {
8 | this.hydrate();
9 | }
10 | }
11 |
12 | connectedCallback() {
13 | if (!this.shadowRoot) {
14 | this.setCount();
15 | this.attachShadow({ mode: 'open' });
16 | this.shadowRoot.innerHTML = this.render();
17 | }
18 | }
19 |
20 | setCount() {
21 | this.count = this.hasAttribute('count')
22 | ? parseInt(this.getAttribute('count'), 10)
23 | : this.props.count
24 | ? this.props.count
25 | : 0;
26 | }
27 |
28 | inc() {
29 | this.count += 1;
30 | this.update();
31 | }
32 |
33 | dec() {
34 | this.count -= 1;
35 | this.update();
36 | }
37 |
38 | hydrate() {
39 | console.debug('COUNTER => hydrate');
40 | this.count = parseInt(JSON.parse(this.shadowRoot.querySelector('script[type="application/json"]').text).count, 10);
41 |
42 | const buttonDec = this.shadowRoot.querySelector('button#dec');
43 | const buttonInc = this.shadowRoot.querySelector('button#inc');
44 |
45 | buttonDec.addEventListener('click', this.dec.bind(this));
46 | buttonInc.addEventListener('click', this.inc.bind(this));
47 | }
48 |
49 | update() {
50 | this.shadowRoot.querySelector('span#count').textContent = this.count;
51 | }
52 |
53 | render() {
54 | return `
55 |
58 |
59 |
60 | Increment
61 | Current Count: ${this.count}
62 | Decrement
63 |
64 | `;
65 | }
66 | }
67 |
68 | export {
69 | Counter
70 | };
71 |
72 | export async function getData() {
73 | return {
74 | count: Math.floor(Math.random() * (100 - 0 + 1) + 0)
75 | };
76 | }
77 |
78 | customElements.define('wcc-counter', Counter);
--------------------------------------------------------------------------------
/test/cases/get-data/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/counter.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 |
5 | connectedCallback() {
6 | this.innerHTML = this.getTemplate();
7 | }
8 |
9 | getTemplate() {
10 | return `
11 | Counter
12 |
13 |
14 | `;
15 | }
16 | }
--------------------------------------------------------------------------------
/test/cases/html-web-components/expected.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Author: WCC
6 |
7 |
8 |
Greenwood
9 | © 2024
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/test/cases/html-web-components/html-web-components.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against an "HTML" Web Component.
4 | * https://blog.jim-nielsen.com/2023/html-web-components/
5 | *
6 | * User Result
7 | * Should return the expected HTML with no template tags or Shadow Roots.
8 | *
9 | * User Workspace
10 | * src/
11 | * components/
12 | * caption.js
13 | * picture-frame.js
14 | * pages/
15 | * index.js
16 | */
17 | import chai from 'chai';
18 | import { JSDOM } from 'jsdom';
19 | import fs from 'fs/promises';
20 | import { renderToString } from '../../../src/wcc.js';
21 |
22 | const expect = chai.expect;
23 |
24 | describe('Run WCC For ', function() {
25 | const LABEL = 'HTML (Light DOM) Web Components';
26 | let dom;
27 | let pictureFrame;
28 | let expectedHtml;
29 | let actualHtml;
30 |
31 | before(async function() {
32 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
33 |
34 | actualHtml = html;
35 | dom = new JSDOM(actualHtml);
36 | pictureFrame = dom.window.document.querySelectorAll('wcc-picture-frame');
37 | expectedHtml = await fs.readFile(new URL('./expected.html', import.meta.url), 'utf-8');
38 | });
39 |
40 | describe(LABEL, function() {
41 | it('should not have any tags within the document', function() {
42 | expect(dom.window.document.querySelectorAll('template').length).to.equal(0);
43 | });
44 |
45 | it('should only have one tag', function() {
46 | expect(pictureFrame.length).to.equal(1);
47 | });
48 |
49 | it('should have the expected image from userland in the HTML', () => {
50 | const img = pictureFrame[0].querySelectorAll('.picture-frame img');
51 |
52 | expect(img.length).to.equal(1);
53 | expect(img[0].getAttribute('alt')).to.equal('Greenwood logo');
54 | expect(img[0].getAttribute('src')).to.equal('https://www.greenwoodjs.io/assets/greenwood-logo-og.png');
55 | });
56 |
57 | it('should have the expected Author name from userland in the HTML', () => {
58 | const img = pictureFrame[0].querySelectorAll('.picture-frame img + br + span');
59 |
60 | expect(img.length).to.equal(1);
61 | expect(img[0].textContent).to.equal('Author: WCC');
62 | });
63 |
64 | it('should have the expected title attribute content in the nested tag', () => {
65 | const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
66 | const heading = caption[0].querySelectorAll('.heading');
67 |
68 | expect(caption.length).to.equal(1);
69 | expect(heading.length).to.equal(1);
70 | expect(heading[0].textContent).to.equal('Greenwood');
71 | });
72 |
73 | it('should have the expected copyright content in the nested tag', () => {
74 | const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
75 | const span = caption[0].querySelectorAll('span');
76 |
77 | expect(span.length).to.equal(1);
78 | expect(span[0].textContent).to.equal('© 2024');
79 | });
80 |
81 | it('should have the expected recursively generated HTML', () => {
82 | expect(expectedHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(actualHtml.replace(/ /g, '').replace(/\n/g, ''));
83 | });
84 | });
85 | });
--------------------------------------------------------------------------------
/test/cases/html-web-components/src/components/caption.js:
--------------------------------------------------------------------------------
1 | export default class Caption extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 | ${this.innerHTML}
6 |
7 | `;
8 | }
9 | }
10 |
11 | customElements.define('wcc-caption', Caption);
--------------------------------------------------------------------------------
/test/cases/html-web-components/src/components/picture-frame.js:
--------------------------------------------------------------------------------
1 | import './caption.js';
2 |
3 | export default class PictureFrame extends HTMLElement {
4 | connectedCallback() {
5 | const title = this.getAttribute('title');
6 |
7 | this.innerHTML = `
8 |
9 | ${this.innerHTML}
10 |
11 | ${title}
12 | © 2024
13 |
14 |
15 | `;
16 | }
17 | }
18 |
19 | customElements.define('wcc-picture-frame', PictureFrame);
--------------------------------------------------------------------------------
/test/cases/html-web-components/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/picture-frame.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = `
6 |
7 |
11 |
12 | Author: WCC
13 |
14 | `;
15 | }
16 | }
--------------------------------------------------------------------------------
/test/cases/import-attributes/import-attributes.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element using import attributes.
4 | *
5 | * User Result
6 | * Should return the expected HTML and no error parsing an import attribute.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * header/
12 | * data.json
13 | * header.js
14 | * pages/
15 | * index.js
16 | */
17 | import chai from 'chai';
18 | import { JSDOM } from 'jsdom';
19 | import { renderToString } from '../../../src/wcc.js';
20 |
21 | const expect = chai.expect;
22 |
23 | describe('Run WCC For ', function() {
24 | const LABEL = 'Import Attributes usage';
25 | let dom;
26 |
27 | before(async function() {
28 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
29 |
30 | dom = new JSDOM(html);
31 | });
32 |
33 | describe(LABEL, function() {
34 | it('should have the expected static content for the page', function() {
35 | expect(dom.window.document.querySelector('h1').textContent).to.equal('Home Page');
36 | });
37 |
38 | it('should have a tag within the document', function() {
39 | expect(dom.window.document.querySelectorAll('header').length).to.equal(1);
40 | });
41 |
42 | it('should have two links within the element sourced from a JSON file', function() {
43 | const links = dom.window.document.querySelectorAll('header nav ul li');
44 |
45 | expect(links.length).to.equal(2);
46 | });
47 | });
48 | });
--------------------------------------------------------------------------------
/test/cases/import-attributes/src/components/header/data.json:
--------------------------------------------------------------------------------
1 | {
2 | "links": ["Home", "About"]
3 | }
--------------------------------------------------------------------------------
/test/cases/import-attributes/src/components/header/header.js:
--------------------------------------------------------------------------------
1 | import data from './data.json' with { type: 'json' };
2 |
3 | export default class Header extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = `
6 |
7 |
8 |
9 | ${data.links.map((item) => `${item} `).join('\n')}
10 |
11 |
12 |
13 | `;
14 | }
15 | }
16 |
17 | customElements.define('wcc-header', Header);
--------------------------------------------------------------------------------
/test/cases/import-attributes/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/header/header.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = `
6 |
7 | Home Page
8 | `;
9 | }
10 | }
--------------------------------------------------------------------------------
/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt:
--------------------------------------------------------------------------------
1 | attributeChangedCallback(name, oldValue, newValue) {
2 | function getValue(value) {
3 | return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) : !isNaN(value) ? parseInt(value, 10) : value === 'true' || value === 'false' ? value === 'true' ? true : false : value;
4 | }
5 | if (newValue !== oldValue) {
6 | switch (name) {
7 | case 'count':
8 | this.count = getValue(newValue);
9 | break;
10 | }
11 | this.render();
12 | }
13 | }
--------------------------------------------------------------------------------
/test/cases/jsx-inferred-observability/fixtures/get-observed-attributes.txt:
--------------------------------------------------------------------------------
1 | static get observedAttributes() {
2 | return['count'];
3 | }
--------------------------------------------------------------------------------
/test/cases/jsx-inferred-observability/jsx-inferred-obsevability.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element using JSX render function with inferredObservability enabled
4 | *
5 | * User Result
6 | * Should return the expected JavaScript output.
7 | *
8 | * User Workspace
9 | * src/
10 | * counter.jsx
11 | */
12 | import chai from 'chai';
13 | import fs from 'fs/promises';
14 | import { renderToString } from '../../../src/wcc.js';
15 |
16 | const expect = chai.expect;
17 |
18 | describe('Run WCC For ', function() {
19 | const LABEL = 'Single Custom Element using JSX and Inferred Observability';
20 | let fixtureAttributeChangedCallback;
21 | let fixtureGetObservedAttributes;
22 | let meta;
23 |
24 | before(async function() {
25 | const { metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url));
26 |
27 | meta = metadata;
28 |
29 | fixtureAttributeChangedCallback = await fs.readFile(new URL('./fixtures/attribute-changed-callback.txt', import.meta.url), 'utf-8');
30 | fixtureGetObservedAttributes = await fs.readFile(new URL('./fixtures/get-observed-attributes.txt', import.meta.url), 'utf-8');
31 | });
32 |
33 | describe(LABEL, function() {
34 |
35 | describe(' component w/ and Inferred Observability', function() {
36 |
37 | it('should infer observability by generating a get observedAttributes method', () => {
38 | const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
39 | const expected = fixtureGetObservedAttributes.replace(/ /g, '').replace(/\n/g, '');
40 |
41 | expect(actual).to.contain(expected);
42 | });
43 |
44 | it('should infer observability by generating an attributeChangedCallback method', () => {
45 | const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
46 | const expected = fixtureAttributeChangedCallback.replace(/ /g, '').replace(/\n/g, '');
47 |
48 | expect(actual).to.contain(expected);
49 | });
50 | });
51 | });
52 | });
--------------------------------------------------------------------------------
/test/cases/jsx-inferred-observability/src/counter.jsx:
--------------------------------------------------------------------------------
1 | export const inferredObservability = true;
2 |
3 | export default class Counter extends HTMLElement {
4 | constructor() {
5 | super();
6 | this.count = 0;
7 | }
8 |
9 | increment() {
10 | this.count += 1;
11 | this.render();
12 | }
13 |
14 | decrement() {
15 | this.count -= 1;
16 | this.render();
17 | }
18 |
19 | connectedCallback() {
20 | this.render();
21 | }
22 |
23 | render() {
24 | const { count } = this;
25 |
26 | return (
27 |
28 |
29 |
Counter JSX
30 | - (function reference)
31 | - (inline state update)
32 | You have clicked {count} times
33 | + (inline state update)
34 | + (function reference)
35 |
36 | );
37 | }
38 | }
39 |
40 | customElements.define('wcc-counter-jsx', Counter);
--------------------------------------------------------------------------------
/test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a nested custom elements using JSX render function and Declarative Shadow DOM.
4 | *
5 | * User Result
6 | * Should return the expected HTML and JavaScript output.
7 | *
8 | * User Workspace
9 | * src/
10 | * heading.jsx
11 | */
12 | import chai from 'chai';
13 | import { JSDOM } from 'jsdom';
14 | import { renderToString } from '../../../src/wcc.js';
15 |
16 | const expect = chai.expect;
17 |
18 | describe('Run WCC For ', function() {
19 | const LABEL = 'Single Custom Element using JSX and Declarative Shadow DOM';
20 | let dom;
21 | let meta;
22 |
23 | before(async function() {
24 | const { html, metadata } = await renderToString(new URL('./src/heading.jsx', import.meta.url));
25 |
26 | meta = metadata;
27 | dom = new JSDOM(html);
28 | });
29 |
30 | describe(LABEL, function() {
31 |
32 | describe(' component', function() {
33 | let heading;
34 |
35 | before(async function() {
36 | heading = dom.window.document.querySelector('wcc-heading template[shadowrootmode="open"]');
37 | });
38 |
39 | describe('Metadata', () => {
40 | it('should return a JSX definition in metadata', () => {
41 | expect(Object.keys(meta).length).to.equal(1);
42 | expect(meta['wcc-heading'].source).to.not.be.undefined;
43 | });
44 | });
45 |
46 | describe('Declarative Shadow DOM ( tag)', () => {
47 | it('should handle a this expression', () => {
48 | expect(heading).to.not.be.undefined;
49 | });
50 | });
51 |
52 | describe('Event Handling', () => {
53 | it('should handle a this expression', () => {
54 | const wrapper = new JSDOM(heading.innerHTML);
55 | const button = wrapper.window.document.querySelector('button');
56 |
57 | expect(button.getAttribute('onclick')).to.be.equal('this.getRootNode().host.sayHello()');
58 | });
59 | });
60 |
61 | describe('Attribute Contents', () => {
62 | it('should handle a this expression', () => {
63 | const wrapper = new JSDOM(heading.innerHTML);
64 | const header = wrapper.window.document.querySelector('h1');
65 |
66 | expect(header.textContent).to.be.equal('Hello, World!');
67 | });
68 | });
69 | });
70 | });
71 | });
--------------------------------------------------------------------------------
/test/cases/jsx-shadow-dom/src/heading.jsx:
--------------------------------------------------------------------------------
1 | export default class HeadingComponent extends HTMLElement {
2 | sayHello() {
3 | alert(`Hello, ${this.greeting}!`);
4 | }
5 |
6 | connectedCallback() {
7 | if (!this.shadowRoot) {
8 | this.greeting = this.getAttribute('greeting') || 'World';
9 |
10 | this.attachShadow({ mode: 'open' });
11 | this.render();
12 | }
13 | }
14 |
15 | render() {
16 | const { greeting } = this;
17 |
18 | return (
19 |
20 |
Hello, {greeting}!
21 | Get a greeting!
22 |
23 | );
24 | }
25 | }
26 |
27 | customElements.define('wcc-heading', HeadingComponent);
--------------------------------------------------------------------------------
/test/cases/jsx/jsx.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a nested custom elements using JSX render function
4 | *
5 | * User Result
6 | * Should return the expected HTML and JavaScript output.
7 | *
8 | * User Workspace
9 | * src/
10 | * badge.jsx
11 | * counter.jsx
12 | */
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function() {
20 | const LABEL = 'Single Custom Element using JSX';
21 | let dom;
22 | let meta;
23 |
24 | before(async function() {
25 | const { html, metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url));
26 |
27 | meta = metadata;
28 | dom = new JSDOM(html);
29 | });
30 |
31 | describe(LABEL, function() {
32 |
33 | describe(' component w/ ', function() {
34 | let buttons;
35 |
36 | before(async function() {
37 | buttons = dom.window.document.querySelectorAll('button');
38 | });
39 |
40 | describe('Metadata', () => {
41 | it('should return a JSX definition in metadata', () => {
42 | expect(Object.keys(meta).length).to.equal(2);
43 | expect(meta['wcc-counter-jsx'].source).to.not.be.undefined;
44 | expect(meta['wcc-badge'].source).to.not.be.undefined;
45 | });
46 | });
47 |
48 | describe('Attributes', () => {
49 | //
50 | it('should handle a member expression', () => {
51 | const badge = dom.window.document.querySelectorAll('wcc-badge')[0];
52 | const span = badge.querySelectorAll('span')[0];
53 |
54 | expect(badge.getAttribute('count')).to.be.equal('0');
55 | expect(span.getAttribute('class')).to.be.equal('unmet');
56 | expect(span.textContent).to.be.equal('0');
57 | });
58 | });
59 |
60 | describe('Event Handling', () => {
61 | // -
62 | it('should handle a this expression', () => {
63 | const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-this');
64 |
65 | expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.decrement()');
66 | });
67 |
68 | // -
69 | it('should handle an assignment expression with implicit reactivity using this.render', () => {
70 | const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-assignment');
71 |
72 | expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.count-=1; this.parentElement.parentElement.render();');
73 | });
74 | });
75 |
76 | describe('Expressions', () => {
77 | // You have clicked {count} times
78 | it('should handle an expression', () => {
79 | const element = dom.window.document.querySelectorAll('span#expression')[0];
80 |
81 | expect(element.textContent).to.be.equal('0');
82 | });
83 | });
84 |
85 | describe('Inferred Observability', () => {
86 | it('should not infer observability by default', () => {
87 | const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
88 |
89 | expect(actual).to.not.contain('staticgetobservedAttributes()');
90 | expect(actual).to.not.contain('attributeChangedCallback');
91 | });
92 | });
93 | });
94 | });
95 | });
--------------------------------------------------------------------------------
/test/cases/jsx/src/badge.jsx:
--------------------------------------------------------------------------------
1 | export default class BadgeComponent extends HTMLElement {
2 |
3 | constructor() {
4 | super();
5 |
6 | this.count = 0;
7 | this.predicate = false;
8 | }
9 |
10 | connectedCallback() {
11 | this.render();
12 | }
13 |
14 | static get observedAttributes () {
15 | return ['count', 'predicate'];
16 | }
17 |
18 | attributeChangedCallback(name, oldValue, newValue) {
19 | if (newValue !== oldValue) {
20 | if (name === 'count') {
21 | this.count = parseInt(newValue, 10);
22 | } else if (name === 'predicate') {
23 | this.predicate = newValue === 'true';
24 | }
25 |
26 | this.render();
27 | }
28 | }
29 |
30 | render() {
31 | const { count, predicate } = this;
32 | const conditionalClass = predicate ? 'met' : 'unmet';
33 | const conditionalText = predicate ? ' 🥳' : '';
34 |
35 | return (
36 | {count}{conditionalText}
37 | );
38 | }
39 | }
40 |
41 | customElements.define('wcc-badge', BadgeComponent);
--------------------------------------------------------------------------------
/test/cases/jsx/src/counter.jsx:
--------------------------------------------------------------------------------
1 | import './badge.jsx';
2 |
3 | export default class Counter extends HTMLElement {
4 | #count;
5 |
6 | constructor() {
7 | super();
8 | this.count = 0;
9 | }
10 |
11 | increment() {
12 | this.count += 1;
13 | this.render();
14 | }
15 |
16 | decrement() {
17 | this.count -= 1;
18 | this.render();
19 | }
20 |
21 | connectedCallback() {
22 | this.render();
23 | }
24 |
25 | render() {
26 | const { count } = this;
27 |
28 | return (
29 |
30 |
31 |
Counter JSX
32 | - (function reference)
33 | - (inline state update)
34 | You have clicked {count} times
35 | + (inline state update)
36 | + (function reference)
37 |
38 | );
39 | }
40 | }
41 |
42 | customElements.define('wcc-counter-jsx', Counter);
--------------------------------------------------------------------------------
/test/cases/light-dom/light-dom.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against nested custom elements using just innerHTML to intentionally NOT render Shadow DOM.
4 | *
5 | * User Result
6 | * Should return the expected HTML with no template tags or Shadow Roots.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * navigation.js
12 | * header.js
13 | * pages/
14 | * index.js
15 | */
16 | import chai from 'chai';
17 | import { JSDOM } from 'jsdom';
18 | import { renderToString } from '../../../src/wcc.js';
19 |
20 | const expect = chai.expect;
21 |
22 | describe('Run WCC For ', function() {
23 | const LABEL = 'Nested Custom Element using only innerHTML (no Shadow DOM)';
24 | let dom;
25 |
26 | before(async function() {
27 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
28 |
29 | dom = new JSDOM(html);
30 | });
31 |
32 | describe(LABEL, function() {
33 | it('should not have any tags within the document', function() {
34 | expect(dom.window.document.querySelectorAll('template').length).to.equal(0);
35 | });
36 |
37 | describe('static page content', function() {
38 | it('should have the expected static content for the page', function() {
39 | expect(dom.window.document.querySelector('h1').textContent).to.equal('Home Page');
40 | });
41 | });
42 |
43 | describe('custom header element with nested navigation element', function() {
44 | let headerContentsDom;
45 |
46 | before(function() {
47 | headerContentsDom = new JSDOM(dom.window.document.querySelectorAll('header')[0].innerHTML);
48 | });
49 |
50 | it('should have a tag within the document', function() {
51 | expect(dom.window.document.querySelectorAll('header').length).to.equal(1);
52 | });
53 |
54 | it('should have expected content within the tag', function() {
55 | const content = headerContentsDom.window.document.querySelector('a h4').textContent;
56 |
57 | expect(content).to.contain('My Personal Blog');
58 | });
59 |
60 | describe('nested navigation element', function() {
61 | let navigationContentsDom;
62 |
63 | before(function() {
64 | navigationContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-navigation')[0].innerHTML);
65 | });
66 |
67 | it('should have a tag within the shadowroot', function() {
68 | expect(navigationContentsDom.window.document.querySelectorAll('nav').length).to.equal(1);
69 | });
70 |
71 | it('should have three links within the element', function() {
72 | const links = navigationContentsDom.window.document.querySelectorAll('nav ul li a');
73 |
74 | expect(links.length).to.equal(3);
75 | });
76 | });
77 | });
78 | });
79 | });
--------------------------------------------------------------------------------
/test/cases/light-dom/src/components/header.js:
--------------------------------------------------------------------------------
1 | import './navigation.js';
2 |
3 | class Header extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = this.render();
6 | }
7 |
8 | render() {
9 | return `
10 |
11 |
17 |
18 |
19 |
20 |
28 |
29 | `;
30 | }
31 | }
32 |
33 | export {
34 | Header
35 | };
36 |
37 | customElements.define('wcc-header', Header);
--------------------------------------------------------------------------------
/test/cases/light-dom/src/components/navigation.js:
--------------------------------------------------------------------------------
1 | class Navigation extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 |
11 | `;
12 | }
13 | }
14 |
15 | export {
16 | Navigation
17 | };
18 |
19 | customElements.define('wcc-navigation', Navigation);
--------------------------------------------------------------------------------
/test/cases/light-dom/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/header.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 |
5 | connectedCallback() {
6 | this.innerHTML = this.getTemplate();
7 | }
8 |
9 | getTemplate() {
10 | return `
11 |
12 |
13 | Home Page
14 | `;
15 | }
16 | }
--------------------------------------------------------------------------------
/test/cases/metadata/metadata.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against custom elements with declarative shadow dom and get the metadata graph.
4 | *
5 | * User Result
6 | * Should return the expected asset graph and entries.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * footer.js
12 | * header.js
13 | * navigation.js
14 | * pages/
15 | * index.js
16 | */
17 | import chai from 'chai';
18 | import { renderToString } from '../../../src/wcc.js';
19 |
20 | const expect = chai.expect;
21 |
22 | describe('Run WCC For ', function() {
23 | const LABEL = 'Metadata graph';
24 | let assetMetadata;
25 |
26 | before(async function() {
27 | const { metadata } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
28 |
29 | assetMetadata = metadata;
30 | });
31 |
32 | describe(LABEL, function() {
33 | it('should have three custom elements in the asset graph', function() {
34 | expect(Object.keys(assetMetadata).length).to.equal(3);
35 | });
36 |
37 | it('should have the correct attributes for each asset', function() {
38 | Object.entries(assetMetadata).forEach((asset) => {
39 | expect(asset[0]).to.not.be.undefined;
40 | expect(asset[1].instanceName).to.not.be.undefined;
41 | expect(asset[1].moduleURL).to.not.be.undefined;
42 | });
43 | });
44 |
45 | it('should return the footer module with a hydrate hint', function() {
46 | const hydrateScripts = Object.entries(assetMetadata)
47 | .filter(asset => asset[1].hydrate === 'lazy');
48 |
49 | expect(hydrateScripts[0][0]).to.equal('wcc-footer');
50 | });
51 |
52 | describe('Entry Points', () => {
53 | it('should mark the footer module as an entry point', function() {
54 | expect(assetMetadata['wcc-footer'].isEntry).to.equal(true);
55 | });
56 |
57 | it('should mark the header module as an entry point', function() {
58 | expect(assetMetadata['wcc-header'].isEntry).to.equal(true);
59 | });
60 |
61 | it('should mark the navigation module as NOT entry point', function() {
62 | expect(assetMetadata['wcc-navigation'].isEntry).to.equal(false);
63 | });
64 | });
65 | });
66 | });
--------------------------------------------------------------------------------
/test/cases/metadata/src/components/footer.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 |
9 | `;
10 |
11 | class Footer extends HTMLElement {
12 | connectedCallback() {
13 | if (!this.shadowRoot) {
14 | this.attachShadow({ mode: 'open' });
15 | this.shadowRoot.appendChild(template.content.cloneNode(true));
16 | }
17 | }
18 | }
19 |
20 | export default Footer;
21 |
22 | customElements.define('wcc-footer', Footer);
--------------------------------------------------------------------------------
/test/cases/metadata/src/components/header.js:
--------------------------------------------------------------------------------
1 | // intentionally nested to test wcc nested dependency resolution logic
2 | import './navigation.js';
3 |
4 | class Header extends HTMLElement {
5 | connectedCallback() {
6 | if (!this.shadowRoot) {
7 | this.attachShadow({ mode: 'open' });
8 | this.shadowRoot.innerHTML = this.render();
9 | }
10 | }
11 |
12 | render() {
13 | return `
14 |
35 | `;
36 | }
37 | }
38 |
39 | export {
40 | Header
41 | };
42 |
43 | customElements.define('wcc-header', Header);
--------------------------------------------------------------------------------
/test/cases/metadata/src/components/navigation.js:
--------------------------------------------------------------------------------
1 | // intentionally nested in the assets/ directory to test wcc nested dependency resolution logic
2 | const template = document.createElement('template');
3 |
4 | template.innerHTML = `
5 |
6 |
12 | `;
13 |
14 | class Navigation extends HTMLElement {
15 | connectedCallback() {
16 | if (!this.shadowRoot) {
17 | this.attachShadow({ mode: 'open' });
18 | this.shadowRoot.appendChild(template.content.cloneNode(true));
19 | }
20 | }
21 | }
22 |
23 | export {
24 | Navigation
25 | };
26 |
27 | customElements.define('wcc-navigation', Navigation);
--------------------------------------------------------------------------------
/test/cases/metadata/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/footer.js';
2 | import '../components/header.js';
3 |
4 | export default class HomePage extends HTMLElement {
5 | constructor() {
6 | super();
7 |
8 | if (this.shadowRoot) {
9 | // console.debug('HomePage => shadowRoot detected!');
10 | } else {
11 | this.attachShadow({ mode: 'open' });
12 | }
13 | }
14 |
15 | connectedCallback() {
16 | this.shadowRoot.innerHTML = this.getTemplate();
17 | }
18 |
19 | getTemplate() {
20 | return `
21 |
22 |
23 | Home Page
24 |
25 |
26 | `;
27 | }
28 | }
--------------------------------------------------------------------------------
/test/cases/nested-elements/nested-elements.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against nested custom elements with declarative shadow dom
4 | *
5 | * User Result
6 | * Should return the expected HTML output for all levels of element nesting.
7 | *
8 | * User Workspace
9 | * src/
10 | * assets/
11 | * navigation.js
12 | * components/
13 | * footer.js
14 | * header.js
15 | * pages/
16 | * index.js
17 | */
18 |
19 | import chai from 'chai';
20 | import { JSDOM } from 'jsdom';
21 | import { renderToString } from '../../../src/wcc.js';
22 |
23 | const expect = chai.expect;
24 |
25 | describe('Run WCC For ', function() {
26 | const LABEL = 'Nested Custom Element w/ Declarative Shadow DOM';
27 | let dom;
28 | let pageContentsDom;
29 |
30 | before(async function() {
31 | const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
32 |
33 | dom = new JSDOM(html);
34 | pageContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-home template[shadowrootmode="open"]')[0].innerHTML);
35 | });
36 |
37 | describe(LABEL, function() {
38 | it('should have one top level with an open shadowroot', function() {
39 | expect(dom.window.document.querySelectorAll('template[shadowrootmode="open"]').length).to.equal(1);
40 | expect(dom.window.document.querySelectorAll('template').length).to.equal(1);
41 | });
42 |
43 | describe('custom footer element', function() {
44 | let footerContentsDom;
45 |
46 | before(function() {
47 | footerContentsDom = new JSDOM(pageContentsDom.window.document.querySelectorAll('wcc-footer template[shadowrootmode="open"]')[0].innerHTML);
48 | });
49 |
50 | it('should have a tag within the shadowroot', function() {
51 | expect(footerContentsDom.window.document.querySelectorAll('footer').length).to.equal(1);
52 | });
53 |
54 | it('should have expected content within the tag', function() {
55 | const content = footerContentsDom.window.document.querySelector('footer h4 a').textContent;
56 |
57 | expect(content).to.contain('My Blog');
58 | });
59 | });
60 |
61 | describe('static page content', function() {
62 | it('should have the expected static content for the page', function() {
63 | expect(pageContentsDom.window.document.querySelector('h1').textContent).to.equal('Home Page');
64 | });
65 | });
66 |
67 | describe('custom header element with nested navigation element', function() {
68 | let headerContentsDom;
69 |
70 | before(function() {
71 | headerContentsDom = new JSDOM(pageContentsDom.window.document.querySelectorAll('wcc-header template[shadowrootmode="open"]')[0].innerHTML);
72 | });
73 |
74 | it('should have a tag within the shadowroot', function() {
75 | expect(headerContentsDom.window.document.querySelectorAll('header').length).to.equal(1);
76 | });
77 |
78 | it('should have expected content within the tag', function() {
79 | const content = headerContentsDom.window.document.querySelector('header a h4').textContent;
80 |
81 | expect(content).to.contain('My Personal Blog');
82 | });
83 |
84 | describe('nested navigation element', function() {
85 | let navigationContentsDom;
86 |
87 | before(function() {
88 | navigationContentsDom = new JSDOM(headerContentsDom.window.document.querySelectorAll('wcc-navigation template[shadowrootmode="open"]')[0].innerHTML);
89 | });
90 |
91 | it('should have a tag within the shadowroot', function() {
92 | expect(navigationContentsDom.window.document.querySelectorAll('nav').length).to.equal(1);
93 | });
94 |
95 | it('should have three links within the element', function() {
96 | const links = navigationContentsDom.window.document.querySelectorAll('nav ul li a');
97 |
98 | expect(links.length).to.equal(3);
99 | });
100 | });
101 | });
102 | });
103 | });
--------------------------------------------------------------------------------
/test/cases/nested-elements/src/assets/navigation.js:
--------------------------------------------------------------------------------
1 | // intentionally nested in the assets/ directory to test wcc nested dependency resolution logic
2 | const template = document.createElement('template');
3 |
4 | template.innerHTML = `
5 |
6 |
12 | `;
13 |
14 | class Navigation extends HTMLElement {
15 | connectedCallback() {
16 | if (!this.shadowRoot) {
17 | this.attachShadow({ mode: 'open' });
18 | this.shadowRoot.appendChild(template.content.cloneNode(true));
19 | }
20 | }
21 | }
22 |
23 | export {
24 | Navigation
25 | };
26 |
27 | customElements.define('wcc-navigation', Navigation);
--------------------------------------------------------------------------------
/test/cases/nested-elements/src/components/footer.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 |
9 | `;
10 |
11 | class Footer extends HTMLElement {
12 | connectedCallback() {
13 | if (!this.shadowRoot) {
14 | this.attachShadow({ mode: 'open' });
15 | this.shadowRoot.appendChild(template.content.cloneNode(true));
16 | }
17 | }
18 | }
19 |
20 | export default Footer;
21 |
22 | customElements.define(`wcc-footer`, Footer);
--------------------------------------------------------------------------------
/test/cases/nested-elements/src/components/header.js:
--------------------------------------------------------------------------------
1 | // intentionally nested to test wcc nested dependency resolution logic
2 | import '../assets/navigation.js';
3 |
4 | class Header extends HTMLElement {
5 | connectedCallback() {
6 | if (!this.shadowRoot) {
7 | this.attachShadow({ mode: 'open' });
8 | this.shadowRoot.innerHTML = this.render();
9 | }
10 | }
11 |
12 | render() {
13 | return `
14 |
35 | `;
36 | }
37 | }
38 |
39 | export {
40 | Header
41 | };
42 |
43 | customElements.define('wcc-header', Header);
--------------------------------------------------------------------------------
/test/cases/nested-elements/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import '../components/footer.js';
2 | import '../components/header.js';
3 |
4 | const template = document.createElement('template');
5 |
6 | template.innerHTML = `
7 |
8 |
9 | Home Page
10 |
11 |
12 |
13 |
14 |
15 | `;
16 |
17 | export default class HomePage extends HTMLElement {
18 | constructor() {
19 | super();
20 |
21 | if (this.shadowRoot) {
22 | // console.debug('HomePage => shadowRoot detected!');
23 | } else {
24 | this.attachShadow({ mode: 'open' });
25 | }
26 | }
27 |
28 | connectedCallback() {
29 | this.shadowRoot.innerHTML = template.innerHTML;
30 | }
31 | }
32 |
33 | customElements.define('wcc-home', HomePage);
--------------------------------------------------------------------------------
/test/cases/no-define/no-define.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element using with no customElements.define
4 | *
5 | * User Result
6 | * Should run without any errors from the DOM shim.
7 | *
8 | * User Workspace
9 | * src/
10 | * no-define.js
11 | * footer.js
12 | * header.js
13 | */
14 |
15 | import chai from 'chai';
16 | import { renderToString, renderFromHTML } from '../../../src/wcc.js';
17 |
18 | const expect = chai.expect;
19 |
20 | describe('Run WCC For ', function() {
21 | const LABEL = 'Single Custom Element with no customElements.define';
22 |
23 | describe(LABEL, function() {
24 | describe('renderToString', () => {
25 | let rawHtml;
26 | let meta;
27 |
28 | before(async function() {
29 | const { html, metadata } = await renderToString(new URL('./src/no-define.js', import.meta.url));
30 |
31 | rawHtml = html;
32 | meta = metadata;
33 | });
34 |
35 | it('should not throw an error', function() {
36 | expect(rawHtml).to.equal(undefined);
37 | });
38 |
39 | it('should not have any definition', function() {
40 | expect(Object.keys(meta).length).to.equal(0);
41 | });
42 | });
43 |
44 | describe('renderFromHTML', () => {
45 | let rawHtml;
46 | let meta;
47 | const contents = `
48 |
49 |
50 | No Export Test
51 |
52 |
53 |
54 | Hello World
55 |
56 |
57 |
58 | `;
59 |
60 | before(async function() {
61 | const { html, metadata } = await renderFromHTML(contents, [
62 | new URL('./src/footer.js', import.meta.url),
63 | new URL('./src/header.js', import.meta.url)
64 | ]);
65 |
66 | rawHtml = html;
67 | meta = metadata;
68 | });
69 |
70 | it('should not throw an error and return the expected contents', function() {
71 | expect(rawHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(contents.replace(/ /g, '').replace(/\n/g, ''));
72 | });
73 |
74 | it('should not have any definition', function() {
75 | expect(Object.keys(meta).length).to.equal(2);
76 | });
77 | });
78 | });
79 | });
--------------------------------------------------------------------------------
/test/cases/no-define/src/footer.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = 'This is the footer component. ';
4 |
5 | class FooterComponent extends HTMLElement {
6 | constructor() {
7 | super();
8 | this.attachShadow({ mode: 'open' });
9 | }
10 |
11 | connectedCallback() {
12 | this.shadowRoot.appendChild(template.content.cloneNode(true));
13 | }
14 | }
15 |
16 | customElements.define('app-no-define-footer', FooterComponent);
--------------------------------------------------------------------------------
/test/cases/no-define/src/header.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 | This is the header component.
5 | `;
6 |
7 | class HeaderComponent extends HTMLElement {
8 | constructor() {
9 | super();
10 | this.attachShadow({ mode: 'open' });
11 | }
12 |
13 | connectedCallback() {
14 | this.shadowRoot.appendChild(template.content.cloneNode(true));
15 | }
16 | }
17 |
18 | customElements.define('app-no-define-header', HeaderComponent);
--------------------------------------------------------------------------------
/test/cases/no-define/src/no-define.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = 'This is the NoDefineComponent component.
';
4 |
5 | // eslint-disable-next-line no-unused-vars
6 | class NoDefineComponent extends HTMLElement {
7 | constructor() {
8 | super();
9 | this.attachShadow({ mode: 'open' });
10 | }
11 |
12 | connectedCallback() {
13 | this.shadowRoot.appendChild(template.content.cloneNode(true));
14 | }
15 | }
--------------------------------------------------------------------------------
/test/cases/no-export/no-export.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element using with no default export
4 | *
5 | * User Result
6 | * Should run without any errors from the DOM shim.
7 | *
8 | * User Workspace
9 | * src/
10 | * no-export.js
11 | * header.js
12 | * footer.js
13 | */
14 |
15 | import chai from 'chai';
16 | import { renderToString, renderFromHTML } from '../../../src/wcc.js';
17 |
18 | const expect = chai.expect;
19 |
20 | describe('Run WCC For ', function() {
21 | const LABEL = 'Single Custom Element with no default export';
22 |
23 | describe(LABEL, function() {
24 | describe('renderToString', () => {
25 | let rawHtml;
26 | let meta;
27 |
28 | before(async function() {
29 | const { html, metadata } = await renderToString(new URL('./src/no-export.js', import.meta.url));
30 |
31 | rawHtml = html;
32 | meta = metadata;
33 | });
34 |
35 | it('should not throw an error', function() {
36 | expect(rawHtml).to.equal(undefined);
37 | });
38 |
39 | it('should have one definition', function() {
40 | expect(Object.keys(meta).length).to.equal(1);
41 | });
42 | });
43 |
44 | describe('renderFromHTML', () => {
45 | let rawHtml;
46 | let meta;
47 | const contents = `
48 |
49 |
50 | No Export Test
51 |
52 |
53 |
54 | Hello World
55 |
56 |
57 |
58 | `;
59 |
60 | before(async function() {
61 | const { html, metadata } = await renderFromHTML(contents, [
62 | new URL('./src/footer.js', import.meta.url),
63 | new URL('./src/header.js', import.meta.url)
64 | ]);
65 |
66 | rawHtml = html;
67 | meta = metadata;
68 | });
69 |
70 | it('should not throw an error and return the expected contents', function() {
71 | expect(rawHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(contents.replace(/ /g, '').replace(/\n/g, ''));
72 | });
73 |
74 | it('should have two definitions', function() {
75 | expect(Object.keys(meta).length).to.equal(2);
76 | });
77 | });
78 | });
79 | });
--------------------------------------------------------------------------------
/test/cases/no-export/src/footer.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = 'This is the footer component. ';
4 |
5 | class FooterComponent extends HTMLElement {
6 | constructor() {
7 | super();
8 | this.attachShadow({ mode: 'open' });
9 | }
10 |
11 | connectedCallback() {
12 | this.shadowRoot.appendChild(template.content.cloneNode(true));
13 | }
14 | }
15 |
16 | customElements.define('app-no-export-footer', FooterComponent);
--------------------------------------------------------------------------------
/test/cases/no-export/src/header.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 | This is the header component.
5 | `;
6 |
7 | class HeaderComponent extends HTMLElement {
8 | constructor() {
9 | super();
10 | this.attachShadow({ mode: 'open' });
11 | }
12 |
13 | connectedCallback() {
14 | this.shadowRoot.appendChild(template.content.cloneNode(true));
15 | }
16 | }
17 |
18 | customElements.define('app-no-export-header', HeaderComponent);
--------------------------------------------------------------------------------
/test/cases/no-export/src/no-export.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = 'This is the NoExportComponent component.
';
4 |
5 | class NoExportComponent extends HTMLElement {
6 | constructor() {
7 | super();
8 | this.attachShadow({ mode: 'open' });
9 | }
10 |
11 | connectedCallback() {
12 | this.shadowRoot.appendChild(template.content.cloneNode(true));
13 | }
14 | }
15 |
16 | customElements.define('app-no-export-example', NoExportComponent);
--------------------------------------------------------------------------------
/test/cases/no-wrapping-entry-tag/no-wrapping-entry-tag.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against bundled custom elements with no wrapping enabled.
4 | *
5 | * User Result
6 | * Should return the expected HTML with no top level wrapping tag.
7 | *
8 | * User Workspace
9 | * src/
10 | * pages/
11 | * no-wrap.js
12 | */
13 |
14 | import chai from 'chai';
15 | import { JSDOM } from 'jsdom';
16 | import { renderToString } from '../../../src/wcc.js';
17 |
18 | const expect = chai.expect;
19 |
20 | describe('Run WCC For ', function() {
21 | const LABEL = 'Bundled Components w/ No Wrapping Entry TAag';
22 | let dom;
23 |
24 | before(async function() {
25 | const { html } = await renderToString(new URL('./src/no-wrap.js', import.meta.url), false);
26 |
27 | dom = new JSDOM(html);
28 | });
29 |
30 | describe(LABEL, function() {
31 | describe('no top level wrapping by ', function() {
32 | it('should have a tag within the shadowroot', function() {
33 | expect(dom.window.document.querySelectorAll('wcc-navigation').length).to.equal(0);
34 | });
35 | });
36 | });
37 | });
--------------------------------------------------------------------------------
/test/cases/no-wrapping-entry-tag/src/no-wrap.js:
--------------------------------------------------------------------------------
1 | class Navigation extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 |
11 | `;
12 | }
13 | }
14 |
15 | customElements.define('wcc-navigation', Navigation);
16 |
17 | class Header extends HTMLElement {
18 | connectedCallback() {
19 | this.innerHTML = `
20 |
23 | `;
24 | }
25 | }
26 |
27 | export default Header;
--------------------------------------------------------------------------------
/test/cases/node-modules/node-modules.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom element using a dependency from node-modules
4 | *
5 | * User Result
6 | * Should run without error.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * events-list.js
12 | * index.js
13 | */
14 | import { expect } from 'chai';
15 | import { JSDOM } from 'jsdom';
16 | import { renderToString } from '../../../src/wcc.js';
17 |
18 | describe('Run WCC For ', function() {
19 | const LABEL = 'Custom Element w/ a node modules dependency';
20 | let dom;
21 |
22 | before(async function() {
23 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
24 |
25 | dom = new JSDOM(html);
26 | });
27 |
28 | describe(LABEL, function() {
29 | it('should not fail when a node module is imported in a custom element', function() {
30 | expect(dom).to.not.be.undefined;
31 | });
32 | });
33 | });
--------------------------------------------------------------------------------
/test/cases/node-modules/src/components/events-list.js:
--------------------------------------------------------------------------------
1 | class EventsList extends HTMLElement {
2 | async connectedCallback() {
3 | if (!this.shadowRoot) {
4 | const events = await fetch('http://www.analogstudios.net/api/v2/events').then(resp => resp.json());
5 |
6 | this.attachShadow({ mode: 'open' });
7 | this.shadowRoot.innerHTML = `Events List (${events.length}) `;
8 | }
9 | }
10 | }
11 |
12 | export {
13 | EventsList
14 | };
15 |
16 | customElements.define('wc-events-list', EventsList);
--------------------------------------------------------------------------------
/test/cases/node-modules/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/events-list.js';
2 |
3 | export default class HomePage extends HTMLElement {
4 | constructor() {
5 | super();
6 |
7 | if (this.shadowRoot) {
8 | // console.debug('HomePage => shadowRoot detected!');
9 | } else {
10 | this.attachShadow({ mode: 'open' });
11 | }
12 | }
13 |
14 | connectedCallback() {
15 | this.shadowRoot.innerHTML = this.getTemplate();
16 | }
17 |
18 | getTemplate() {
19 | return `
20 |
21 | `;
22 | }
23 | }
--------------------------------------------------------------------------------
/test/cases/render-from-html/render-from-html.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against nested custom elements with declarative shadow dom from an HTML string.
4 | *
5 | * User Result
6 | * Should return the expected HTML output for all levels of element nesting.
7 | *
8 | * User Workspace
9 | * src/
10 | * components/
11 | * navigation.js
12 | * header.js
13 | */
14 | import chai from 'chai';
15 | import { JSDOM } from 'jsdom';
16 | import { renderFromHTML } from '../../../src/wcc.js';
17 |
18 | const expect = chai.expect;
19 |
20 | describe('Run WCC ', function() {
21 | const LABEL = 'Using renderFromHTML';
22 | const TITLE = 'Welcome to my site';
23 | let rawHtml;
24 | let dom;
25 | let assetMetadata;
26 |
27 | before(async function() {
28 | const { html, metadata } = await renderFromHTML(`
29 |
30 |
31 | WCC
32 |
33 |
34 |
35 | Home Page
36 |
37 |
38 | `, [
39 | new URL('./src/components/header.js', import.meta.url)
40 | ]);
41 |
42 | rawHtml = html;
43 | dom = new JSDOM(html);
44 | assetMetadata = metadata;
45 | });
46 |
47 | describe(LABEL, function() {
48 | describe('static page content', function() {
49 | it('should have the expected tag the page', function() {
50 | expect(rawHtml.indexOf('') >= 0).to.equal(true);
51 | });
52 |
53 | it('should have the expected tag the page', function() {
54 | expect(dom.window.document.querySelectorAll('html').length).to.equal(1);
55 | expect(dom.window.document.querySelector('title').textContent).to.equal('WCC');
56 | });
57 |
58 | it('should have the expected tag the page', function() {
59 | expect(rawHtml.indexOf('') >= 0).to.equal(true);
60 | });
61 |
62 | it('should have the expected tag the page', function() {
63 | expect(rawHtml.indexOf('') >= 0).to.equal(true);
64 | });
65 |
66 | it('should have the expected static content for the page', function() {
67 | expect(dom.window.document.querySelector('h1').textContent).to.equal('Home Page');
68 | });
69 | });
70 |
71 | describe('custom header element with nested navigation element', function() {
72 | let headerContentsDom;
73 |
74 | before(function() {
75 | headerContentsDom = new JSDOM(dom.window.document.querySelectorAll('wcc-html-header template[shadowrootmode="open"]')[0].innerHTML);
76 | });
77 |
78 | it('should have a tag within the shadowroot', function() {
79 | expect(headerContentsDom.window.document.querySelectorAll('header').length).to.equal(1);
80 | });
81 |
82 | it('should have expected content within the tag', function() {
83 | const content = headerContentsDom.window.document.querySelector('header a h4').textContent;
84 |
85 | expect(content).to.contain(TITLE);
86 | });
87 |
88 | describe('nested navigation element', function() {
89 | let navigationContentsDom;
90 |
91 | before(function() {
92 | navigationContentsDom = new JSDOM(headerContentsDom.window.document.querySelectorAll('wcc-navigation template[shadowrootmode="open"]')[0].innerHTML);
93 | });
94 |
95 | it('should have a tag within the shadowroot', function() {
96 | expect(navigationContentsDom.window.document.querySelectorAll('nav').length).to.equal(1);
97 | });
98 |
99 | it('should have three links within the element', function() {
100 | const links = navigationContentsDom.window.document.querySelectorAll('nav ul li a');
101 |
102 | expect(links.length).to.equal(3);
103 | });
104 | });
105 | });
106 |
107 | describe(LABEL, function() {
108 | it('should have two custom elements in the asset graph', function() {
109 | expect(Object.keys(assetMetadata).length).to.equal(2);
110 | });
111 |
112 | it('should have the correct attributes for each asset', function() {
113 | Object.entries(assetMetadata).forEach((asset) => {
114 | const isEntry = asset[0] === 'wcc-html-header';
115 |
116 | expect(asset[0]).to.not.be.undefined;
117 | expect(asset[1].instanceName).to.not.be.undefined;
118 | expect(asset[1].moduleURL).to.not.be.undefined;
119 | expect(asset[1].isEntry).to.equal(isEntry);
120 | });
121 | });
122 | });
123 | });
124 | });
--------------------------------------------------------------------------------
/test/cases/render-from-html/src/components/header.js:
--------------------------------------------------------------------------------
1 | import './navigation.js';
2 |
3 | const template = document.createElement('template');
4 |
5 | class Header extends HTMLElement {
6 | connectedCallback() {
7 | const title = this.getAttribute('title');
8 |
9 | if (!this.shadowRoot) {
10 | template.innerHTML = `
11 |
32 | `;
33 |
34 | this.attachShadow({ mode: 'open' });
35 | this.shadowRoot.appendChild(template.content.cloneNode(true));
36 | }
37 | }
38 | }
39 |
40 | export default Header;
41 |
42 | customElements.define('wcc-html-header', Header);
--------------------------------------------------------------------------------
/test/cases/render-from-html/src/components/navigation.js:
--------------------------------------------------------------------------------
1 | // intentionally nested in the assets/ directory to test wcc nested dependency resolution logic
2 | const template = document.createElement('template');
3 |
4 | template.innerHTML = `
5 |
6 |
12 | `;
13 |
14 | class Navigation extends HTMLElement {
15 | connectedCallback() {
16 | if (!this.shadowRoot) {
17 | this.attachShadow({ mode: 'open' });
18 | this.shadowRoot.appendChild(template.content.cloneNode(true));
19 | }
20 | }
21 | }
22 |
23 | export {
24 | Navigation
25 | };
26 |
27 | customElements.define('wcc-navigation', Navigation);
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/serializable-shadow-roots.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc using getHTML in a component with a variety of serializable and serializableShadowRoots values.
4 | *
5 | * User Result
6 | * Should return an element with no content and an element with content.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | * components/
12 | * serializable-non-ssr-component.js
13 | * serializable-ssr-component.js
14 | * unserializable-non-ssr-component.js
15 | * unserializable-ssr-component.js
16 | */
17 |
18 | import chai from 'chai';
19 | import { JSDOM } from 'jsdom';
20 | import { renderToString } from '../../../src/wcc.js';
21 |
22 | const expect = chai.expect;
23 |
24 | describe('Run WCC For ', function () {
25 | const LABEL = 'Custom Elements w/ Serializable Shadow Roots';
26 | let dom;
27 |
28 | before(async function () {
29 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
30 | dom = new JSDOM(html);
31 | });
32 |
33 | describe(LABEL, function () {
34 | it('should have a serializable-ssr-component with an h2 tag with textContent equal to "Serializable Component with serializableShadowRoots"', function () {
35 | expect(
36 | dom.window.document.querySelector('serializable-ssr-component template[shadowrootmode="open"]').innerHTML.trim()
37 | ).to.equal('Serializable Component with serializableShadowRoots ');
38 | });
39 |
40 | it('should have a serializable-non-ssr-component with no innerHTML', function () {
41 | expect(
42 | dom.window.document
43 | .querySelector('serializable-non-ssr-component template[shadowrootmode="open"]')
44 | .innerHTML.trim()
45 | ).to.equal('');
46 | });
47 |
48 | it('should have an unserializable-ssr-component with no innerHTML', function () {
49 | expect(
50 | dom.window.document
51 | .querySelector('unserializable-ssr-component template[shadowrootmode="open"]')
52 | .innerHTML.trim()
53 | ).to.equal('');
54 | });
55 |
56 | it('should have an unserializable-non-ssr-component with no innerHTML', function () {
57 | expect(
58 | dom.window.document
59 | .querySelector('unserializable-non-ssr-component template[shadowrootmode="open"]')
60 | .innerHTML.trim()
61 | ).to.equal('');
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/src/components/serializable-non-ssr-component.js:
--------------------------------------------------------------------------------
1 | export default class SerializableNonSSRComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 | if (!this.shadowRoot) {
8 | this.attachShadow({ mode: 'open', serializable: true });
9 | const template = document.createElement('template');
10 | template.innerHTML = `
11 | Serializable Component w/o serializableShadowRoots
12 | `;
13 | this.shadowRoot.appendChild(template.content.cloneNode(true));
14 |
15 | this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: false });
16 | }
17 | }
18 | }
19 |
20 | customElements.define('serializable-non-ssr-component', SerializableNonSSRComponent);
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/src/components/serializable-ssr-component.js:
--------------------------------------------------------------------------------
1 | export default class SerializableSSRComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 | if (!this.shadowRoot) {
8 | this.attachShadow({ mode: 'open', serializable: true });
9 | const template = document.createElement('template');
10 | template.innerHTML = `
11 | Serializable Component with serializableShadowRoots
12 | `;
13 | this.shadowRoot.appendChild(template.content.cloneNode(true));
14 |
15 | this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: true });
16 | }
17 | }
18 | }
19 |
20 | customElements.define('serializable-ssr-component', SerializableSSRComponent);
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/src/components/unserializable-non-ssr-component.js:
--------------------------------------------------------------------------------
1 | export default class UnserializableNonSSRComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 | if (!this.shadowRoot) {
8 | this.attachShadow({ mode: 'open', serializable: false });
9 | const template = document.createElement('template');
10 | template.innerHTML = `
11 | Unserializable Component w/o serializableShadowRoots
12 | `;
13 | this.shadowRoot.appendChild(template.content.cloneNode(true));
14 |
15 | this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: false });
16 | }
17 | }
18 | }
19 |
20 | customElements.define('unserializable-non-ssr-component', UnserializableNonSSRComponent);
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/src/components/unserializable-ssr-component.js:
--------------------------------------------------------------------------------
1 | export default class UnserializableSSRComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 | if (!this.shadowRoot) {
8 | this.attachShadow({ mode: 'open', serializable: false });
9 | const template = document.createElement('template');
10 | template.innerHTML = `
11 | Unserializable Component with serializableShadowRoots
12 | `;
13 | this.shadowRoot.appendChild(template.content.cloneNode(true));
14 |
15 | this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: true });
16 | }
17 | }
18 | }
19 |
20 | customElements.define('unserializable-ssr-component', UnserializableSSRComponent);
--------------------------------------------------------------------------------
/test/cases/serializable-shadow-roots/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/serializable-ssr-component.js';
2 | import './components/serializable-non-ssr-component.js';
3 | import './components/unserializable-ssr-component.js';
4 | import './components/unserializable-non-ssr-component.js';
5 |
6 | export default class HomePage extends HTMLElement {
7 | connectedCallback() {
8 | this.innerHTML = `
9 |
10 |
11 |
12 |
13 | `;
14 | }
15 | }
16 |
17 | customElements.define('wcc-home', HomePage);
--------------------------------------------------------------------------------
/test/cases/set-attribute/set-attribute.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a component which sets an attribute of a child heading.
4 | *
5 | * User Result
6 | * Should return a component with a child h2 with the expected attribute and attribute value.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function () {
20 | const LABEL = 'Custom Element using setAttribute';
21 | let dom;
22 |
23 | before(async function () {
24 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
25 | dom = new JSDOM(html);
26 | });
27 |
28 | describe(LABEL, function () {
29 | it('should have a heading tag with the "foo" attribute equal to "bar"', function () {
30 | expect(dom.window.document.querySelector('set-attribute-element h2').getAttribute('foo')).to.equal('bar');
31 | });
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/test/cases/set-attribute/src/index.js:
--------------------------------------------------------------------------------
1 | export default class SetAttributeElement extends HTMLElement {
2 |
3 | connectedCallback() {
4 | const heading = document.createElement('h2');
5 | heading.setAttribute('foo', 'bar');
6 | this.appendChild(heading);
7 | }
8 | }
9 |
10 | customElements.define('set-attribute-element', SetAttributeElement);
--------------------------------------------------------------------------------
/test/cases/shadowrootmode/shadowrootmode.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against two components, one with an open shadow root and one with a closed shadow root.
4 | *
5 | * User Result
6 | * Should return the expected attribute value for shadowrootmode on the template tag based on the shadow root mode of each component.
7 | *
8 | * User Workspace
9 | * src/
10 | * index.js
11 | * components/
12 | * closed-shadow-component.js
13 | * open-shadow-component.js
14 | */
15 |
16 | import chai from 'chai';
17 | import { JSDOM } from 'jsdom';
18 | import { renderToString } from '../../../src/wcc.js';
19 |
20 | const expect = chai.expect;
21 |
22 | describe('Run WCC For ', function () {
23 | const LABEL = 'Custom Elements w/ Closed and Open Shadowrootmode';
24 | let dom;
25 |
26 | before(async function () {
27 | const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
28 | dom = new JSDOM(html);
29 | });
30 |
31 | describe(LABEL, function () {
32 | it('should have exactly one open shadowrootmode template', function () {
33 | expect(
34 | dom.window.document.querySelectorAll('open-shadow-component template[shadowrootmode="open"]').length
35 | ).to.equal(1);
36 | });
37 |
38 | it('should have exactly one closed shadowrootmode template', function () {
39 | expect(
40 | dom.window.document.querySelectorAll('closed-shadow-component template[shadowrootmode="closed"]').length
41 | ).to.equal(1);
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/test/cases/shadowrootmode/src/components/closed-shadow-component.js:
--------------------------------------------------------------------------------
1 | export default class ClosedShadowComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 |
8 | if (!this.shadowRoot) {
9 | this.attachShadow({ mode: 'closed' });
10 | const template = document.createElement('template');
11 | template.innerHTML = `
12 | Shadow Root Closed
13 | `;
14 | this.shadowRoot.appendChild(template.content.cloneNode(true));
15 | }
16 | }
17 | }
18 |
19 | customElements.define('closed-shadow-component', ClosedShadowComponent);
--------------------------------------------------------------------------------
/test/cases/shadowrootmode/src/components/open-shadow-component.js:
--------------------------------------------------------------------------------
1 | export default class OpenShadowComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 |
8 | if (!this.shadowRoot) {
9 | this.attachShadow({ mode: 'open' });
10 | const template = document.createElement('template');
11 | template.innerHTML = `
12 | Shadow Root Open
13 | `;
14 | this.shadowRoot.appendChild(template.content.cloneNode(true));
15 | }
16 | }
17 | }
18 |
19 | customElements.define('open-shadow-component', OpenShadowComponent);
20 |
--------------------------------------------------------------------------------
/test/cases/shadowrootmode/src/index.js:
--------------------------------------------------------------------------------
1 | import './components/open-shadow-component.js';
2 | import './components/closed-shadow-component.js';
3 |
4 | export default class HomePage extends HTMLElement {
5 | constructor() {
6 | super();
7 | }
8 |
9 | connectedCallback() {
10 | this.innerHTML = `
11 |
12 |
13 | `;
14 | }
15 | }
16 |
17 | customElements.define('wcc-home', HomePage);
--------------------------------------------------------------------------------
/test/cases/single-element/single-element.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a single custom element with declarative shadow dom
4 | *
5 | * User Result
6 | * Should return the expected HTML output.
7 | *
8 | * User Workspace
9 | * src/
10 | * footer.js
11 | */
12 |
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function() {
20 | const LABEL = 'Single Custom Element w/ Declarative Shadow DOM';
21 | let dom;
22 | let rawHtml;
23 |
24 | before(async function() {
25 | const { html } = await renderToString(new URL('./src/footer.js', import.meta.url));
26 |
27 | rawHtml = html;
28 | dom = new JSDOM(html);
29 | });
30 |
31 | describe(LABEL, function() {
32 |
33 | it('should NOT have a tag in the content of the page', function() {
34 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
35 | });
36 |
37 | it('should NOT have a tag in the content of the page', function() {
38 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
39 | });
40 |
41 | it('should NOT have a tag in the content of the page', function() {
42 | expect(rawHtml.indexOf('') >= 0).to.equal(false);
43 | });
44 |
45 | it('should have one top level element with a with an open shadowroot', function() {
46 | expect(dom.window.document.querySelectorAll('wcc-footer template[shadowrootmode="open"]').length).to.equal(1);
47 | expect(dom.window.document.querySelectorAll('template').length).to.equal(1);
48 | });
49 |
50 | describe(' component and content', function() {
51 | let footer;
52 |
53 | before(async function() {
54 | footer = new JSDOM(dom.window.document.querySelectorAll('wcc-footer template[shadowrootmode="open"]')[0].innerHTML);
55 | });
56 |
57 | it('should have one tag within the shadowroot', function() {
58 | expect(footer.window.document.querySelectorAll('footer').length).to.equal(1);
59 | });
60 |
61 | it('should have the expected content for the tag', function() {
62 | expect(footer.window.document.querySelectorAll('h4 a').textContent).to.contain(/My Blog/);
63 | });
64 | });
65 |
66 | });
67 | });
--------------------------------------------------------------------------------
/test/cases/single-element/src/footer.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 |
3 | template.innerHTML = `
4 |
24 |
25 |
30 | `;
31 |
32 | class Footer extends HTMLElement {
33 | constructor() {
34 | super();
35 |
36 | if (this.shadowRoot) {
37 | console.debug('Footer => shadowRoot detected!');
38 | }
39 | }
40 |
41 | connectedCallback() {
42 | if (!this.shadowRoot) {
43 | this.attachShadow({ mode: 'open' });
44 | this.shadowRoot.appendChild(template.content.cloneNode(true));
45 | }
46 | }
47 | }
48 |
49 | export default Footer;
50 |
51 | customElements.define('wcc-footer', Footer);
--------------------------------------------------------------------------------
/test/cases/ts/src/app.ts:
--------------------------------------------------------------------------------
1 | import './greeting.ts';
2 |
3 | export default class App extends HTMLElement {
4 | connectedCallback() {
5 | this.innerHTML = `
6 |
7 | `;
8 | }
9 | }
10 |
11 | customElements.define('wcc-app', App);
--------------------------------------------------------------------------------
/test/cases/ts/src/greeting.ts:
--------------------------------------------------------------------------------
1 | interface User {
2 | name: string;
3 | }
4 |
5 | export default class Greeting extends HTMLElement {
6 | connectedCallback() {
7 | const user: User = {
8 | name: this.getAttribute('name') || 'World'
9 | };
10 |
11 | this.innerHTML = `
12 | Hello ${user.name}!
13 | `;
14 | }
15 | }
16 |
17 | customElements.define('wcc-greeting', Greeting);
--------------------------------------------------------------------------------
/test/cases/ts/ts.spec.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Use Case
3 | * Run wcc against a custom elements using TypeScript
4 | *
5 | * User Result
6 | * Should return the expected HTML and JavaScript output.
7 | *
8 | * User Workspace
9 | * src/
10 | * greeting.ts
11 | * app.ts
12 | */
13 | import chai from 'chai';
14 | import { JSDOM } from 'jsdom';
15 | import { renderToString } from '../../../src/wcc.js';
16 |
17 | const expect = chai.expect;
18 |
19 | describe('Run WCC For ', function() {
20 | const LABEL = 'Single Custom Element using TypeScript';
21 | let dom;
22 |
23 | before(async function() {
24 | const { html } = await renderToString(new URL('./src/app.ts', import.meta.url));
25 |
26 | dom = new JSDOM(html);
27 | });
28 |
29 | describe(LABEL, function() {
30 |
31 | describe('Greeting component in TypeScript', function() {
32 | let headings;
33 |
34 | before(async function() {
35 | headings = dom.window.document.querySelectorAll('h3');
36 | });
37 |
38 | it('should server render the expected greeting', () => {
39 | expect(headings.length).to.equal(1);
40 | expect(headings[0].textContent).to.equal('Hello TypeScript!');
41 | });
42 | });
43 | });
44 | });
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "nodenext",
5 | "allowImportingTsExtensions": true,
6 | "rewriteRelativeImportExtensions": true,
7 | "verbatimModuleSyntax": true,
8 | "erasableSyntaxOnly": true,
9 | "checkJs": true,
10 | "allowJs": true,
11 | "noEmit": true,
12 | "skipLibCheck": true,
13 | "types": [
14 | "mocha",
15 | "node"
16 | ]
17 | },
18 | "include": [
19 | "./src/**/**/*.js",
20 | "./test/**/*.spec.js"
21 | ]
22 | }
--------------------------------------------------------------------------------