├── .DS_Store ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── deploy-demo.yml │ ├── lint.yml │ ├── test.yml │ └── version-module.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── configurator ├── index.html ├── playground-service-worker-proxy.html ├── playground-service-worker.js └── project │ ├── index.html │ ├── my-element.ts │ ├── package.json │ └── project.json ├── demo ├── index.html ├── mwc-button │ ├── index.html │ └── project.json ├── project-1 │ ├── index.html │ ├── my-element.js │ └── project.json └── typescript │ ├── index.html │ ├── my-element.ts │ ├── my-second-element.ts │ ├── project.json │ └── tsconfig.json ├── examples ├── rollup │ ├── .gitignore │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── rollup.config.js └── webpack │ ├── .babelrc │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── src │ └── index.js │ └── webpack.config.js ├── images ├── architecture.svg ├── check-green.png ├── chrome.png ├── colors │ ├── 000000.png │ ├── 0000CC.png │ ├── 0000FF.png │ ├── 0055AA.png │ ├── 008855.png │ ├── 116644.png │ ├── 117700.png │ ├── 221199.png │ ├── 3300AA.png │ ├── 444444.png │ ├── 555555.png │ ├── 6200EE.png │ ├── 767676.png │ ├── 770088.png │ ├── AA1111.png │ ├── AA5500.png │ ├── D7D4F0.png │ ├── DDDDDD.png │ ├── EAEAEA.png │ ├── FF5500.png │ └── FFFFFF.png ├── custom-layout.png ├── edge.png ├── firefox.png ├── fold-example.png ├── hide-example.png ├── ie.png ├── preview.png ├── red-cross.png ├── safari.png └── typescript.png ├── package-lock.json ├── package.json ├── playground-styles.css ├── playwright.config.ts ├── release-process.md ├── rollup.config.codemirror.js ├── rollup.config.common.js ├── rollup.config.configurator.js ├── rollup.config.service-worker.js ├── rollup.config.styles.js ├── rollup.config.typescript.js ├── rollup.config.web-worker.js ├── scripts ├── theme-generator.html ├── theme-generator.js └── update-version-module.js ├── src ├── _codemirror │ └── codemirror-bundle.js ├── configurator │ ├── highlight-tokens.ts │ ├── knobs.ts │ ├── playground-configurator.ts │ └── playground-theme-detector.ts ├── index.ts ├── internal │ ├── build.ts │ ├── codemirror.ts │ ├── overlay.ts │ ├── tab-bar.ts │ ├── tab.ts │ └── typescript.d.ts ├── playground-code-editor.ts ├── playground-connected-element.ts ├── playground-file-editor.ts ├── playground-file-system-controls.ts ├── playground-ide.ts ├── playground-preview.ts ├── playground-project.ts ├── playground-service-worker-proxy.ts ├── playground-tab-bar.ts ├── playground-typescript-worker-entrypoint.ts ├── service-worker │ ├── playground-service-worker.ts │ └── tsconfig.json ├── shared │ ├── completion-utils.ts │ ├── deferred.ts │ ├── tsconfig.json │ ├── util.ts │ ├── version.ts │ └── worker-api.ts ├── test │ ├── bare-module-worker_test.ts │ ├── cdn-base-url_test.ts │ ├── completions_test.ts │ ├── fake-cdn-plugin.ts │ ├── node-module-resolver_test.ts │ ├── node-modules-layout-maker_test.ts │ ├── playground-code-editor_test.ts │ ├── playground-ide_test.ts │ ├── playwright │ │ └── service-worker.spec.ts │ ├── types-fetcher_test.ts │ ├── typescript-worker_test.ts │ ├── util_test.ts │ └── worker-test-util.ts └── typescript-worker │ ├── bare-module-transformer.ts │ ├── build.ts │ ├── caching-cdn.ts │ ├── completions.ts │ ├── diagnostic.ts │ ├── import-map-resolver.ts │ ├── language-service-context.ts │ ├── node-module-resolver.ts │ ├── node-modules-layout-maker.ts │ ├── node │ ├── errors.ts │ ├── resolve.ts │ └── url.ts │ ├── types-fetcher.ts │ ├── typescript-builder.ts │ ├── util.ts │ └── worker-context.ts ├── test-results └── .last-run.json ├── tsconfig-typescript-worker.json ├── tsconfig.json ├── web-dev-server.config.js └── web-test-runner.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/.DS_Store -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /_codemirror/ 2 | /.wireit 3 | /*.d.ts 4 | /*.js 5 | /configurator/ 6 | /demo/ 7 | /examples/ 8 | /internal/ 9 | /service-worker/ 10 | /shared/ 11 | /src/themes/ 12 | /test/ 13 | /themes/ 14 | /typescript-worker/ 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": 2020, 10 | "sourceType": "module", 11 | "project": "./tsconfig.json" 12 | }, 13 | "plugins": ["@typescript-eslint", "no-only-tests", "import"], 14 | "rules": { 15 | "no-only-tests/no-only-tests": "error", 16 | "import/extensions": ["error", "always"], 17 | "@typescript-eslint/explicit-module-boundary-types": "off", 18 | "@typescript-eslint/no-explicit-any": "error", 19 | "@typescript-eslint/no-non-null-assertion": "off", 20 | "@typescript-eslint/no-floating-promises": "error" 21 | }, 22 | "overrides": [ 23 | { 24 | "files": ["src/playground-styles.ts"], 25 | "rules": { 26 | "no-var": "off" 27 | } 28 | }, 29 | { 30 | "files": [ 31 | "src/typescript-worker/**", 32 | "src/playground-typescript-worker-entrypoint.ts" 33 | ], 34 | "parserOptions": { 35 | "project": "./tsconfig-typescript-worker.json" 36 | } 37 | }, 38 | { 39 | "files": ["src/service-worker/**"], 40 | "parserOptions": { 41 | "project": "./src/service-worker/tsconfig.json" 42 | } 43 | }, 44 | { 45 | "files": ["src/shared/**"], 46 | "parserOptions": { 47 | "project": "./src/shared/tsconfig.json" 48 | } 49 | }, 50 | { 51 | // These files aren't imported into Google so we don't care about floating 52 | // promises. 53 | "files": [ 54 | "scripts/**", 55 | "playwright.config.ts", 56 | "src/configurator/**", 57 | "src/test/**" 58 | ], 59 | "parserOptions": { 60 | "project": null 61 | }, 62 | "rules": { 63 | "@typescript-eslint/no-floating-promises": "off" 64 | } 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | name: deploy-demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy-demo: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 20 17 | cache: npm 18 | - uses: google/wireit@setup-github-actions-caching/v1 19 | 20 | - run: npm ci 21 | - run: npx playwright install-deps 22 | - run: npx playwright install 23 | - run: npm run build 24 | 25 | - name: Deploy to gh-pages 26 | run: | 27 | cp -r configurator/deploy .. 28 | git fetch origin gh-pages --depth=1 29 | git checkout --track origin/gh-pages 30 | ls -A1 | grep -vx .git | xargs rm -rf 31 | mv ../deploy/* . 32 | if [[ -n $(git status -s) ]] 33 | then 34 | git config user.name "$GITHUB_ACTOR (bot)" 35 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 36 | git add -A 37 | git commit -am "Deploy $GITHUB_SHA" 38 | git push 39 | fi 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 20 14 | cache: npm 15 | - uses: google/wireit@setup-github-actions-caching/v1 16 | 17 | - run: npm ci 18 | - run: npm run lint 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 20 14 | cache: npm 15 | - uses: google/wireit@setup-github-actions-caching/v1 16 | 17 | - run: npm ci 18 | - run: npx playwright install-deps 19 | - run: npx playwright install 20 | - run: npm test 21 | env: 22 | WIREIT_FAILURES: continue 23 | -------------------------------------------------------------------------------- /.github/workflows/version-module.yml: -------------------------------------------------------------------------------- 1 | name: version-module 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | version-module: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 20 14 | cache: npm 15 | - uses: google/wireit@setup-github-actions-caching/v1 16 | 17 | - run: npm ci 18 | - run: npx playwright install-deps 19 | - run: npx playwright install 20 | # This script automatically fails if the version module is not up-to-date. 21 | - run: npm run update-version-module 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /packages/*/node_modules 2 | /node_modules 3 | /internal/ 4 | /service-worker/ 5 | /shared/ 6 | /configurator/*.js 7 | !/configurator/playground-service-worker.js 8 | /configurator/*.d.ts 9 | /configurator/*.d.ts.map 10 | /configurator/deploy/ 11 | /typescript-worker/ 12 | /playground-typescript-worker.js 13 | /index.js 14 | /index.d.ts 15 | /index.d.ts.map 16 | /playground-*.js 17 | /playground-*.d.ts 18 | /playground-*.d.ts.map 19 | /test/ 20 | /_codemirror/ 21 | /themes/ 22 | /src/themes/ 23 | /src/_codemirror/codemirror-styles.ts 24 | /src/configurator/themes.ts 25 | .tsbuildinfo 26 | /playground-service-worker.* 27 | /playground-service-worker-proxy.* 28 | /src/playground-styles.ts 29 | /*.tgz 30 | /.wireit 31 | /.eslintcache 32 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | /_codemirror/ 3 | /.wireit 4 | /*.d.ts 5 | /*.js 6 | /configurator/ 7 | /demo/ 8 | /examples/ 9 | /internal/ 10 | /service-worker/ 11 | /shared/ 12 | /src/configurator/themes.ts 13 | /src/playground-styles.ts 14 | /src/themes/ 15 | /test/ 16 | /themes/ 17 | /typescript-worker/ 18 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/bundle": true 4 | }, 5 | "editor.formatOnSave": true, 6 | "files.insertFinalNewline": true, 7 | "typescript.tsdk": "node_modules/typescript/lib" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019 Google LLC. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /configurator/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Playground Elements Configurator 5 | 6 | 7 | 8 | 9 | 10 | 11 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /configurator/playground-service-worker-proxy.html: -------------------------------------------------------------------------------- 1 | ../playground-service-worker-proxy.html -------------------------------------------------------------------------------- /configurator/playground-service-worker.js: -------------------------------------------------------------------------------- 1 | ../playground-service-worker.js -------------------------------------------------------------------------------- /configurator/project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /configurator/project/my-element.ts: -------------------------------------------------------------------------------- 1 | import {LitElement, html} from 'lit'; 2 | import {property, customElement} from 'lit/decorators.js'; 3 | 4 | @customElement('my-element') 5 | export class MyElement extends LitElement { 6 | @property() 7 | greet = 'nobody'; 8 | 9 | override render() { 10 | return html`

Hello ${this.greet}!

`; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /configurator/project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "lit": "^3.0.0", 4 | "@lit/reactive-element": "^2.0.0", 5 | "lit-element": "^4.0.0", 6 | "lit-html": "^3.0.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /configurator/project/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "my-element.ts": {}, 4 | "index.html": {}, 5 | "package.json": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 37 | 38 | 39 | 40 |

Inline Sample Files

41 |

42 | This editor has no configuration file, just <script> tags 43 | with type attributes with a "sample/" prefix. 44 |

45 | 46 | 60 | 79 | 92 | 93 | 94 |

TypeScript

95 |
96 | 104 |

105 | Note that imports to other TypeScript files use the .js extension like 106 | you would with tsc. 107 |

108 | 110 |
111 | 112 |

Basic Project

113 |

114 | An index.html and .js file, with custom display labels, importing an npm 115 | package with bare module specifiers. 116 |

117 | 118 | 119 |

MWC Demo

120 |

121 | A demo for <mwc-button>. 122 |

123 | 124 | 125 |

Custom layout

126 |
127 | 136 | 137 | By using <playground-project>, 138 | <playground-file-editor>, and 139 | <playground-preview> separately, you can create any kind of 140 | layout: 141 | 142 | 143 | 149 | 152 | 153 | 154 |

index.html

155 | 159 | 160 | 161 |

index.ts

162 | 166 | 167 | 168 |

preview

169 | 171 | 172 |
173 | 174 |

Hidden parts

175 |

Using playground-hide and playground-fold, you can hide some parts of a file's contents.

176 | 177 | 191 | 197 | 198 | 199 |

Pre-rendered editor

200 |
201 | 202 | 208 | 209 | 210 | 211 | 220 | 221 | 222 | 239 | 240 |
241 | 242 |
243 | 244 |
245 |
246 | 247 | 248 |

Hello World!

249 |
250 |
251 |
252 | 253 | 254 | 255 | -------------------------------------------------------------------------------- /demo/mwc-button/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | Hello World 15 | 16 | 17 | -------------------------------------------------------------------------------- /demo/mwc-button/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "index.html": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/project-1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Here are the contents:
7 | 8 |
Element is not upgraded
9 |
10 | 11 | -------------------------------------------------------------------------------- /demo/project-1/my-element.js: -------------------------------------------------------------------------------- 1 | import {LitElement, html} from 'https://unpkg.com/lit?module'; 2 | import {customElement} from 'https://unpkg.com/lit/decorators.js?module'; 3 | 4 | class MyElement extends LitElement { 5 | static get properties() { 6 | return { 7 | myNumber: { 8 | type: Number, 9 | }, 10 | }; 11 | } 12 | 13 | render() { 14 | return html`
Here is ${this.myNumber}
`; 15 | } 16 | } 17 | 18 | customElements.define('my-element', MyElement); 19 | -------------------------------------------------------------------------------- /demo/project-1/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "index.html": { 4 | "label": "HTML" 5 | }, 6 | "my-element.js": { 7 | "label": "JS" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /demo/typescript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Here are the contents:
7 | 8 |
Element is not upgraded
9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /demo/typescript/my-element.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import './my-second-element.js'; 4 | 5 | @customElement('my-element') 6 | export class MyElement extends LitElement { 7 | @property({ type: Number }) 8 | myNumber?: number; 9 | 10 | render() { 11 | return html` 12 |
Here is ${this.myNumber}
13 |
And here is my second element:
14 | 15 | `; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demo/typescript/my-second-element.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { customElement } from 'lit/decorators.js'; 3 | 4 | @customElement('my-second-element') 5 | export class MySecondElement extends LitElement { 6 | render() { 7 | return html` 8 |
This is inside the shadow dom of my-second-element
9 | `; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /demo/typescript/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "index.html": {}, 4 | "my-element.ts": {}, 5 | "my-second-element.ts": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esNext", 5 | "lib": [ 6 | "ES2017", 7 | "DOM" 8 | ], 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "outDir": "./out", 13 | "rootDir": ".", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitAny": true, 20 | "noImplicitThis": true, 21 | "moduleResolution": "node", 22 | "forceConsistentCasingInFileNames": true, 23 | "experimentalDecorators": true 24 | }, 25 | "include": [ 26 | "*.ts", 27 | ], 28 | "exclude": [], 29 | } 30 | -------------------------------------------------------------------------------- /examples/rollup/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /examples/rollup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/rollup/index.js: -------------------------------------------------------------------------------- 1 | import 'playground-elements'; 2 | -------------------------------------------------------------------------------- /examples/rollup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground-element-examples-rollup", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "build": "rm -rf dist && rollup -c", 7 | "serve": "web-dev-server --root-dir dist/" 8 | }, 9 | "devDependencies": { 10 | "@rollup/plugin-node-resolve": "^11.2.1", 11 | "@web/dev-server": "^0.1.16", 12 | "@web/rollup-plugin-import-meta-assets": "^1.0.6", 13 | "rollup": "^2.45.2", 14 | "rollup-plugin-copy": "^3.4.0" 15 | }, 16 | "dependencies": { 17 | "playground-elements": "file:../.." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/rollup/rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import copy from 'rollup-plugin-copy'; 9 | import {importMetaAssets} from '@web/rollup-plugin-import-meta-assets'; 10 | 11 | export default [ 12 | { 13 | input: './index.js', 14 | output: { 15 | file: 'dist/index.js', 16 | format: 'esm', 17 | }, 18 | plugins: [ 19 | resolve(), 20 | importMetaAssets(), 21 | copy({ 22 | targets: [ 23 | { 24 | src: './index.html', 25 | dest: './dist/', 26 | }, 27 | ], 28 | }), 29 | ], 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /examples/webpack/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/webpack/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /examples/webpack/README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Welcome to your new awesome project! 2 | 3 | This project has been created using **webpack-cli**, you can now run 4 | 5 | ``` 6 | npm run build 7 | ``` 8 | 9 | or 10 | 11 | ``` 12 | yarn build 13 | ``` 14 | 15 | to bundle your application 16 | -------------------------------------------------------------------------------- /examples/webpack/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-webpack-project", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "webpack --mode=production", 6 | "serve": "webpack serve", 7 | "build:dev": "webpack --mode=development", 8 | "build:prod": "webpack --mode=production", 9 | "watch": "webpack --watch" 10 | }, 11 | "devDependencies": { 12 | "@webpack-cli/generators": "^2.0.0", 13 | "html-webpack-plugin": "^5.3.1", 14 | "webpack-cli": "^4.6.0", 15 | "webpack-dev-server": "^3.11.2" 16 | }, 17 | "dependencies": { 18 | "playground-elements": "file:../.." 19 | }, 20 | "description": "My webpack project" 21 | } 22 | -------------------------------------------------------------------------------- /examples/webpack/src/index.js: -------------------------------------------------------------------------------- 1 | import 'playground-elements'; 2 | -------------------------------------------------------------------------------- /examples/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | // Generated using webpack-cli http://github.com/webpack-cli 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | entry: './src/index.js', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | }, 11 | devServer: { 12 | open: true, 13 | host: 'localhost', 14 | }, 15 | plugins: [ 16 | new HtmlWebpackPlugin({ 17 | template: 'index.html', 18 | }), 19 | 20 | // Add your plugins here 21 | // Learn more about plugins from https://webpack.js.org/configuration/plugins/ 22 | ], 23 | module: { 24 | rules: [ 25 | { 26 | test: /\\.(js|jsx)$/, 27 | loader: 'babel-loader', 28 | }, 29 | { 30 | test: /\.css$/i, 31 | use: ['style-loader', 'css-loader'], 32 | }, 33 | { 34 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/, 35 | type: 'asset', 36 | }, 37 | 38 | // Add your rules for custom modules here 39 | // Learn more about loaders from https://webpack.js.org/loaders/ 40 | ], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /images/check-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/check-green.png -------------------------------------------------------------------------------- /images/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/chrome.png -------------------------------------------------------------------------------- /images/colors/000000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/000000.png -------------------------------------------------------------------------------- /images/colors/0000CC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/0000CC.png -------------------------------------------------------------------------------- /images/colors/0000FF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/0000FF.png -------------------------------------------------------------------------------- /images/colors/0055AA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/0055AA.png -------------------------------------------------------------------------------- /images/colors/008855.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/008855.png -------------------------------------------------------------------------------- /images/colors/116644.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/116644.png -------------------------------------------------------------------------------- /images/colors/117700.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/117700.png -------------------------------------------------------------------------------- /images/colors/221199.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/221199.png -------------------------------------------------------------------------------- /images/colors/3300AA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/3300AA.png -------------------------------------------------------------------------------- /images/colors/444444.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/444444.png -------------------------------------------------------------------------------- /images/colors/555555.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/555555.png -------------------------------------------------------------------------------- /images/colors/6200EE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/6200EE.png -------------------------------------------------------------------------------- /images/colors/767676.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/767676.png -------------------------------------------------------------------------------- /images/colors/770088.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/770088.png -------------------------------------------------------------------------------- /images/colors/AA1111.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/AA1111.png -------------------------------------------------------------------------------- /images/colors/AA5500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/AA5500.png -------------------------------------------------------------------------------- /images/colors/D7D4F0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/D7D4F0.png -------------------------------------------------------------------------------- /images/colors/DDDDDD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/DDDDDD.png -------------------------------------------------------------------------------- /images/colors/EAEAEA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/EAEAEA.png -------------------------------------------------------------------------------- /images/colors/FF5500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/FF5500.png -------------------------------------------------------------------------------- /images/colors/FFFFFF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/colors/FFFFFF.png -------------------------------------------------------------------------------- /images/custom-layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/custom-layout.png -------------------------------------------------------------------------------- /images/edge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/edge.png -------------------------------------------------------------------------------- /images/firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/firefox.png -------------------------------------------------------------------------------- /images/fold-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/fold-example.png -------------------------------------------------------------------------------- /images/hide-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/hide-example.png -------------------------------------------------------------------------------- /images/ie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/ie.png -------------------------------------------------------------------------------- /images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/preview.png -------------------------------------------------------------------------------- /images/red-cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/red-cross.png -------------------------------------------------------------------------------- /images/safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/safari.png -------------------------------------------------------------------------------- /images/typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/playground-elements/a300acbe0d921ed61825cce9198f712eb6630953/images/typescript.png -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | const config = { 8 | testDir: 'src/test/playwright', 9 | // There is some flakiness, especially in webkit. 10 | // 11 | // TODO(aomarks) Investigate this further. Haven't been able to isolate the 12 | // flakes yet, but it could indicate a genuine problem. 13 | // https://github.com/google/playground-elements/issues/229 14 | retries: 3, 15 | // Not running both browsers at the same time helps with flakiness too. Could 16 | // this imply a race condition that is influenced by CPU load? 17 | workers: 1, 18 | // Defaults to 30 seconds. We shouldn't need that much time, and it's better 19 | // to fail fast and retry. 20 | timeout: 10000, 21 | projects: [ 22 | { 23 | name: 'chromium', 24 | use: { 25 | browserName: 'chromium', 26 | }, 27 | }, 28 | // Webkit tests are *very* flakey compared to Chrome. Temporarily disabled. 29 | // { 30 | // name: 'webkit', 31 | // use: { 32 | // browserName: 'webkit', 33 | // }, 34 | // }, 35 | // Sadly playwright Firefox does not currently work with service workers at 36 | // all, see https://github.com/microsoft/playwright/issues/7288. 37 | // { 38 | // name: 'firefox', 39 | // use: { 40 | // browserName: 'firefox', 41 | // }, 42 | // }, 43 | ], 44 | }; 45 | export default config; 46 | -------------------------------------------------------------------------------- /release-process.md: -------------------------------------------------------------------------------- 1 | # playground-elements Release Process 2 | 3 | This document outlines the process for publishing playground-elements to [npm](https://www.npmjs.com/package/playground-elements). 4 | 5 | Prerequisites: 6 | 7 | - Must have npm publish permissions for playground-elements. Note: it can take a few hours for the permissions to propagate and allow publishing. 8 | 9 | > **Warning** 10 | > This release process has manual steps. At the ⚠️ emoji, proceed with caution. 11 | 12 | ## Steps 13 | 14 | 1. Manually create a release PR. Prior examples [#264](https://github.com/google/playground-elements/pull/376) [#245](https://github.com/google/playground-elements/pull/245) 15 | 1. Create a branch from `main`. E.g., `git checkout -b release-vX-XX-X` 16 | 1. Modify the CHANGELOG.md to reflect the release version 17 | 1. Run `npm version --no-git-tag-version [patch|minor|major]` to update `package.json` and `package-lock.json` with the correct version bump. This will also automatically run `npm run update-version-module` and stage the `src/shared/version.ts` file. 18 | 1. Commit and create PR with name “Release ” 19 | 1. Get code review & tests passing on PR. Squash and merge once approved. 20 | 1. Checkout the `main` branch and `git pull`. `git status` and `git log` to ensure on `main` and last commit was the release commit. 21 | 22 | > **Warning** 23 | > Manual Publish to NPM - proceed with caution 24 | 25 | 1. ⚠️ Running `npm publish` will always replace the `latest` tag with the version just published. The `latest` tag is what users get from `npm install playground-elements`. If publishing a pre-release, use `npm publish --no-tag`. Otherwise for a normal release, use `npm publish`. 26 | 1. Check https://www.npmjs.com/package/playground-elements to validate that the publish succeeded, or run `npm info playground-elements` 27 | 1. Add a [Github Release via this dashboard.](https://github.com/google/playground-elements/releases) 28 | 1. Press “Draft a new release” 29 | 1. Click “Choose a tag” and type in the version that was released. You will create this new tag when publishing the release notes. It will be associated with the latest commit on `main` which is your release. 30 | 1. Release title should be identical to the tag. 31 | 1. In the “describe this release” textbox, copy paste the `CHANGELOG.md` for the release. 32 | 1. Press “Publish release”. 33 | 34 | Great job! You've successfully released playground-elements to npm and tagged the release. 35 | 36 | Verification links: 37 | 38 | - NPM: https://www.npmjs.com/package/playground-elements 39 | - Tagged release: https://github.com/google/playground-elements/releases 40 | -------------------------------------------------------------------------------- /rollup.config.codemirror.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | 9 | import {maybeTerser} from './rollup.config.common.js'; 10 | 11 | function simpleReplace(replacements) { 12 | return { 13 | name: 'simple-replace', 14 | renderChunk(code) { 15 | for (const [from, to] of replacements) { 16 | code = code.replace(from, to); 17 | } 18 | return {code}; 19 | }, 20 | }; 21 | } 22 | 23 | 24 | export default { 25 | input: 'src/_codemirror/codemirror-bundle.js', 26 | output: { 27 | file: '_codemirror/codemirror-bundle.js', 28 | format: 'esm', 29 | // CodeMirror doesn't include any @license or @preserve annotations on the 30 | // copyright headers, so terser doesn't know which comments need to be 31 | // preserved. Add it back with the annotation. 32 | banner: `/* @license CodeMirror, copyright (c) by Marijn Haverbeke and others 33 | Distributed under an MIT license: https://codemirror.net/LICENSE */ 34 | `, 35 | }, 36 | // TODO(aomarks) If we created and exported some module-scoped object as our 37 | // context, then we should be able to make a properly isolated ES module 38 | // here which doesn't set `window.CodeMirror`. However, there seems to be 39 | // some code in the `google_modes` files use a hard-coded `CodeMirror` 40 | // global instead of using the "global" variable that is passed into the 41 | // factory, so some extra patching/search-replacing would be required. 42 | context: 'window', 43 | plugins: [ 44 | resolve(), 45 | simpleReplace([ 46 | // Every CodeMirror file includes UMD-style tests to check for CommonJS 47 | // or AMD. Re-write these expressions directly to `false` so that we 48 | // always run in global mode, and terser will dead-code remove the other 49 | // branches. 50 | [/typeof exports ?===? ?['"`]object['"`]/g, 'false'], 51 | [/typeof define ?===? ?['"`]function['"`]/g, 'false'], 52 | ]), 53 | ...maybeTerser 54 | ], 55 | }; 56 | -------------------------------------------------------------------------------- /rollup.config.common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import terser from '@rollup/plugin-terser'; 8 | 9 | const terserOptions = { 10 | warnings: true, 11 | ecma: 2017, 12 | compress: { 13 | unsafe: true, 14 | passes: 2, 15 | }, 16 | output: { 17 | // "some" preserves @license and @preserve comments 18 | comments: 'some', 19 | inline_script: false, 20 | }, 21 | mangle: { 22 | properties: false, 23 | }, 24 | }; 25 | 26 | export const maybeTerser = process.env.NO_TERSER ? [] : [terser(terserOptions)]; 27 | -------------------------------------------------------------------------------- /rollup.config.configurator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import terser from '@rollup/plugin-terser'; 9 | import copy from 'rollup-plugin-copy'; 10 | 11 | export default [ 12 | { 13 | input: 'configurator/playground-configurator.js', 14 | output: { 15 | file: 'configurator/deploy/playground-configurator.js', 16 | format: 'esm', 17 | }, 18 | plugins: [ 19 | resolve(), 20 | terser({ 21 | warnings: true, 22 | ecma: 2017, 23 | compress: { 24 | unsafe: true, 25 | passes: 2, 26 | }, 27 | output: { 28 | comments: 'some', 29 | }, 30 | mangle: { 31 | properties: false, 32 | }, 33 | }), 34 | copy({ 35 | targets: [ 36 | { 37 | src: 'configurator/index.html', 38 | dest: 'configurator/deploy/', 39 | }, 40 | { 41 | src: 'configurator/project', 42 | dest: 'configurator/deploy/', 43 | }, 44 | { 45 | src: 'playground-typescript-worker.js', 46 | dest: 'configurator/deploy/', 47 | }, 48 | { 49 | src: 'playground-service-worker.js', 50 | dest: 'configurator/deploy/', 51 | }, 52 | { 53 | src: 'playground-service-worker-proxy.html', 54 | dest: 'configurator/deploy/', 55 | }, 56 | ], 57 | }), 58 | ], 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /rollup.config.service-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import * as fs from 'fs'; 9 | import {maybeTerser} from './rollup.config.common.js'; 10 | 11 | /** 12 | * Rollup plugin which generates an HTML file with all input JavaScript 13 | * bundles inlined into script tags. 14 | */ 15 | function inlineHtml(htmlOutPath) { 16 | return { 17 | name: 'inline-html', 18 | generateBundle: async (_outputOptions, bundles, isWrite) => { 19 | if (!isWrite) { 20 | return; 21 | } 22 | const scripts = []; 23 | for (const [name, bundle] of Object.entries(bundles)) { 24 | scripts.push(``); 25 | // Don't emit the input bundle as its own file. 26 | delete bundles[name]; 27 | } 28 | const html = `${scripts.join('')}`; 29 | await fs.promises.writeFile(htmlOutPath, html, 'utf8'); 30 | }, 31 | }; 32 | } 33 | 34 | export default [ 35 | // Generate playground-service-worker-proxy.html, the HTML file + inline 36 | // script that proxies between a project and a service worker on a possibly 37 | // different origin. 38 | { 39 | input: 'playground-service-worker-proxy.js', 40 | output: { 41 | file: 'playground-service-worker-proxy-temp-bundle.js', 42 | format: 'esm', 43 | }, 44 | plugins: [ 45 | ...maybeTerser, 46 | inlineHtml('playground-service-worker-proxy.html'), 47 | ], 48 | }, 49 | { 50 | input: 'service-worker/playground-service-worker.js', 51 | output: { 52 | file: 'playground-service-worker.js', 53 | format: 'iife', 54 | exports: 'none', 55 | }, 56 | plugins: [resolve(), ...maybeTerser], 57 | } 58 | ]; 59 | -------------------------------------------------------------------------------- /rollup.config.styles.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import litcss from 'rollup-plugin-lit-css'; 8 | 9 | export default [ 10 | { 11 | input: 'playground-styles.css', 12 | output: { 13 | file: 'src/playground-styles.ts', 14 | }, 15 | plugins: [litcss()], 16 | }, 17 | ]; 18 | -------------------------------------------------------------------------------- /rollup.config.typescript.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import commonjs from '@rollup/plugin-commonjs'; 8 | 9 | /** 10 | * This Rollup config generates a JS module version of typescript from its 11 | * CommonJS version. 12 | * 13 | * It's generated separately from the main Rollup config because it is an input 14 | * to bundles generated there. We could also run this commonjs transform in the 15 | * main Rollup config, but having a genuine module in the sources makes a few 16 | * things easier: 17 | * 18 | * - We can import it into tests without also having to configure the commonjs 19 | * transform there. @web/test-runner does support running the commonjs Rollup 20 | * plugin, but for some reason it was still erroring. 21 | * 22 | * - The development loop generating the worker bundle is slightly faster, 23 | * because we don't need to run the commonjs transform every time we build the 24 | * worker. 25 | * 26 | * - We use module imports in the worker, for a faster development mode that 27 | * doesn't bundle. Having only module sources makes this easier too. 28 | */ 29 | export default [ 30 | { 31 | input: 'node_modules/typescript/lib/typescript.js', 32 | output: { 33 | file: 'internal/typescript.js', 34 | format: 'esm', 35 | }, 36 | plugins: [ 37 | commonjs({ 38 | ignore: (id) => true, 39 | }), 40 | ], 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /rollup.config.web-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import {maybeTerser} from './rollup.config.common.js'; 9 | import * as path from 'path'; 10 | 11 | const internalTypescriptPath = path.resolve(process.cwd(), 'internal/typescript.js'); 12 | 13 | export default { 14 | input: 'playground-typescript-worker-entrypoint.js', 15 | external(id, parentId, isResolved) { 16 | if (!isResolved && parentId !== undefined) { 17 | id = path.resolve(path.dirname(parentId), id); 18 | } 19 | return id === internalTypescriptPath; 20 | }, 21 | output: { 22 | file: 'playground-typescript-worker.js', 23 | format: 'esm', 24 | exports: 'none', 25 | }, 26 | plugins: [resolve(), ...maybeTerser], 27 | }; 28 | -------------------------------------------------------------------------------- /scripts/theme-generator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 16 | 17 |
18 | 19 | background 20 | default-color 21 | 22 | 23 |
cursor
24 | 25 | 26 |
selection
33 | 34 | 35 |
gutter-background
36 |
gutter-filler-background
37 |
gutter-border
38 |
gutter-box-shadow
39 |
linenumber
40 | 41 | 42 | 43 | 44 | atom 45 | attribute 46 | builtin 47 | comment 48 | def 49 | keyword 50 | meta 51 | number 52 | operator 53 | qualifier 54 | string 55 | string-2 56 | tag 57 | type 58 | variable 59 | variable-2 60 | variable-3 61 | 62 | 66 | callee 67 | property 68 | 69 | 70 |
71 | 72 | 117 | -------------------------------------------------------------------------------- /scripts/theme-generator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | // This script generates the following files: 8 | // 9 | // 1) themes/*.css : A CSS file for each of the standard CodeMirror themes, which 10 | // expresses that theme in terms of CSS custom properties. 11 | // 12 | // 2) src/themes/*.css.ts : A JavaScript module for each file in #2 that exports 13 | // the style as a lit CSSResult. 14 | // 15 | // 3) src/configurator/themes.ts : An aggregation of all theme names and theme 16 | // CSSResults, for use by the configurator. 17 | // 18 | // We convert each standard CodeMirror theme to a set of CSS custom properties by 19 | // loading ./theme-generator.html in a playwright-controlled Chromium browser, 20 | // loading each of the original themes stylesheets, and extracting colors from 21 | // probe nodes that mimic the structure of /actual CodeMirror DOM. 22 | 23 | /* eslint-env browser */ 24 | 25 | import * as fs from 'fs'; 26 | import * as pathlib from 'path'; 27 | import * as url from 'url'; 28 | 29 | import * as webDevServer from '@web/dev-server'; 30 | import * as playwright from 'playwright'; 31 | import CleanCSS from 'clean-css'; 32 | 33 | const rootDir = pathlib.resolve( 34 | pathlib.dirname(url.fileURLToPath(import.meta.url)), 35 | '..' 36 | ); 37 | 38 | const mkdirAndWriteUtf8 = async (path, data) => { 39 | await fs.promises.mkdir(pathlib.dirname(path), {recursive: true}); 40 | await fs.promises.writeFile(path, data, 'utf8'); 41 | }; 42 | 43 | // The main reason we use a CSS minifier here is to remove redundant rules from 44 | // the default stylesheet, because we replace its fixed color values with var()s 45 | // simply by appending rules to the end of the stylesheet. For example, we want 46 | // to remove the first rule from: 47 | // 48 | // .cm-string { color: blue; } 49 | // .cm-string { color: var(--playground-code-string-color); } 50 | const minifier = new CleanCSS({level: {2: {all: true}}, format: 'beautify'}); 51 | const minifyCss = (cssText) => { 52 | const result = minifier.minify(cssText); 53 | if (result.errors.length !== 0) { 54 | throw new Error(`CleanCSS errors: ${result.errors.join(';')}`); 55 | } 56 | let minified = result.styles; 57 | return minified; 58 | }; 59 | 60 | const postMinifyCss = (css) => { 61 | // The string value of `window.getComputedStyle(...).background` includes all 62 | // values, but in practice only color and url are set, so we can strip off 63 | // most of it. 64 | css = css.replace( 65 | / none repeat scroll 0% 0%\s*\/\s*auto padding-box border-box/g, 66 | '' 67 | ); 68 | // The computed value of a `border` property includes all values even when 69 | // width is 0. We can strip off the rest when width is 0. 70 | css = css.replace(/0px none rgb.*;/g, '0px;'); 71 | return css; 72 | }; 73 | 74 | const makeThemeCss = (themeName, results, defaultResults) => { 75 | const excludeDefaults = Object.values(results).filter( 76 | (r) => defaultResults[r.to].value !== r.value 77 | ); 78 | return postMinifyCss( 79 | minifyCss(`.playground-theme-${themeName} { 80 | ${excludeDefaults.map(({to, value}) => ` ${to}: ${value};`).join('\n')} 81 | }`) 82 | ); 83 | }; 84 | 85 | const makeCssModule = (css) => { 86 | return `import {css} from 'lit'; 87 | const style = css\` 88 | ${css} 89 | \`; 90 | export default style; 91 | `; 92 | }; 93 | 94 | const main = async () => { 95 | const serverPromise = webDevServer.startDevServer({ 96 | config: { 97 | rootDir, 98 | }, 99 | }); 100 | 101 | const browser = await playwright.chromium.launch({ 102 | headless: true, 103 | args: ['--auto-open-devtools-for-tabs'], 104 | }); 105 | const context = await browser.newContext(); 106 | const page = await context.newPage(); 107 | const server = await serverPromise; 108 | 109 | const url = `http://localhost:${server.config.port}/scripts/theme-generator.html`; 110 | await page.goto(url); 111 | 112 | const writes = []; 113 | 114 | // Default style 115 | const defaultResults = await page.evaluate(() => window.probe('default')); 116 | 117 | // Themes 118 | const excludeThemes = new Set([ 119 | // This file just removes box-shadow for some reason, not a real theme. 120 | 'ambiance-mobile.css', 121 | ]); 122 | const themeFilenames = fs 123 | .readdirSync( 124 | pathlib.resolve(rootDir, 'node_modules', 'codemirror', 'theme') 125 | ) 126 | .filter((name) => !excludeThemes.has(name)); 127 | const themeNames = []; 128 | for (const filename of themeFilenames) { 129 | const themeName = filename.replace(/\.css$/, ''); 130 | themeNames.push(themeName); 131 | const styleUrl = `/node_modules/codemirror/theme/${filename}`; 132 | const results = await page.evaluate( 133 | ([themeName, styleUrl]) => window.probe(themeName, styleUrl), 134 | [themeName, styleUrl] 135 | ); 136 | const css = makeThemeCss(themeName, results, defaultResults); 137 | writes.push( 138 | mkdirAndWriteUtf8(pathlib.join(rootDir, 'themes', filename), css) 139 | ); 140 | writes.push( 141 | mkdirAndWriteUtf8( 142 | pathlib.join(rootDir, 'src', 'themes', `${filename}.ts`), 143 | makeCssModule(css) 144 | ) 145 | ); 146 | } 147 | 148 | // All themes for configurator 149 | const allThemesTs = ` 150 | export const themeNames = [ 151 | ${themeNames.map((themeName) => ` '${themeName}',`).join('\n')} 152 | ] as const; 153 | 154 | ${themeNames 155 | .map( 156 | (themeName) => 157 | `import t${themeName.replace( 158 | /-/g, 159 | '_' 160 | )} from '../themes/${themeName}.css.js';` 161 | ) 162 | .join('\n')} 163 | 164 | export const themeStyles = [ 165 | ${themeNames 166 | .map((themeName) => ` t${themeName.replace(/-/g, '_')},`) 167 | .join('\n')} 168 | ] as const; 169 | `; 170 | 171 | writes.push( 172 | mkdirAndWriteUtf8( 173 | pathlib.join(rootDir, 'src', 'configurator', 'themes.ts'), 174 | allThemesTs 175 | ) 176 | ); 177 | 178 | await Promise.all([browser.close(), server.stop(), ...writes]); 179 | }; 180 | 181 | main(); 182 | -------------------------------------------------------------------------------- /scripts/update-version-module.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import * as fs from 'fs/promises'; 8 | import * as crypto from 'crypto'; 9 | import {dirname, join} from 'path'; 10 | import {fileURLToPath} from 'url'; 11 | 12 | // This script is automatically run before pack/publish to ensure that the 13 | // src/shared/version.ts module contains the current NPM package version and 14 | // service worker hash. 15 | // 16 | // We use the version number to generate a correct unpkg.com URL for the default 17 | // sandbox location, and we use the service worker hash to determine when we 18 | // need to force a service worker update. 19 | 20 | /* eslint-env node */ 21 | 22 | const thisDir = dirname(fileURLToPath(import.meta.url)); 23 | 24 | const getNpmVersion = async () => { 25 | const path = join(thisDir, '..', 'package.json'); 26 | const data = await fs.readFile(path, 'utf8'); 27 | const parsed = JSON.parse(data); 28 | return parsed.version; 29 | }; 30 | 31 | const getServiceWorkerHash = async () => { 32 | const path = join(thisDir, '..', 'playground-service-worker.js'); 33 | const code = await fs.readFile(path, 'utf8'); 34 | // Note in theory Terser could minimize the version code differently, but it 35 | // should be obvious because this script will start to fail. 36 | const versionPattern = 37 | /version:\s*(?["'`])(?[a-z0-9]+)\k/g; 38 | const matches = [...code.matchAll(versionPattern)]; 39 | if (matches.length !== 1) { 40 | throw new Error( 41 | 'Expected exactly one version string in playground-service-worker.js. ' + 42 | `Found ${matches.length}.` 43 | ); 44 | } 45 | // We must remove the version string itself from the service worker code 46 | // before hashing, so that we get a stable hash. 47 | const oldHash = matches[0].groups.hex; 48 | if (!oldHash) { 49 | throw new Error('Could not find "hash" capture group in regexp match.'); 50 | } 51 | const hashableCode = code.replace(oldHash, ''); 52 | const hash = crypto 53 | .createHash('sha256') 54 | .update(hashableCode) 55 | // Hex is a little nicer than base64 because it doesn't use the _/= 56 | // charaters which makes copy/paste annoying (can't double click on it). 57 | .digest('hex') 58 | // 8 hex chars = 32 hash bits. Should be sufficient. 59 | .slice(0, 8); 60 | return hash; 61 | }; 62 | 63 | const newVersionModuleCode = `/** 64 | * @license 65 | * Copyright 2021 Google LLC 66 | * SPDX-License-Identifier: BSD-3-Clause 67 | */ 68 | 69 | // DO NOT UPDATE MANUALLY. 70 | 71 | // This file is automatically generated by scripts/update-version-module.js 72 | // before publishing. 73 | export const npmVersion = '${await getNpmVersion()}'; 74 | export const serviceWorkerHash = '${await getServiceWorkerHash()}'; 75 | `; 76 | 77 | const versionModulePath = join(thisDir, '..', 'src', 'shared', 'version.ts'); 78 | const oldVersionModuleCode = await fs.readFile(versionModulePath, 'utf8'); 79 | if (oldVersionModuleCode !== newVersionModuleCode) { 80 | await fs.writeFile(versionModulePath, newVersionModuleCode, 'utf8'); 81 | console.log( 82 | 'Updated src/shared/version.ts, please commit this change and rebuild.' 83 | ); 84 | // Fail to help the publisher remember to commit. 85 | process.exit(1); 86 | } else { 87 | console.log('src/shared/version.ts already up to date.'); 88 | } 89 | -------------------------------------------------------------------------------- /src/_codemirror/codemirror-bundle.js: -------------------------------------------------------------------------------- 1 | // The main CodeMirror 5 editor. 2 | import 'codemirror/lib/codemirror.js'; 3 | 4 | // Hints 5 | import 'codemirror/addon/hint/show-hint.js'; 6 | 7 | // Folding 8 | import 'codemirror/addon/fold/foldcode.js'; 9 | 10 | // Comment 11 | import 'codemirror/addon/comment/comment.js'; 12 | 13 | // Runtime dependency for all CodeMirror modes that are generated using 14 | // https://github.com/codemirror/grammar-mode (i.e. the google_modes). 15 | import 'codemirror-grammar-mode/src/mode.js'; 16 | 17 | // These versions of the JavaScript and TypeScript modes add support for nested 18 | // HTML-in-JS syntax highlighting, so we prefer these to the stock ones. 19 | import 'google_modes/dist/javascript.js'; 20 | import 'google_modes/dist/typescript.js'; 21 | 22 | // We can't use the stock HTML mode, because it's called "htmlmixed", and our 23 | // HTML-in-JS syntax support assumes that the nested syntax will be called 24 | // either "html" or "google-html". Not sure what is otherwise different about 25 | // the google_mode HTML mode, but it seems fine. 26 | import 'google_modes/dist/html.js'; 27 | 28 | // Stock CSS mode. 29 | import 'codemirror/mode/css/css.js'; 30 | 31 | // Stock JSX mode is a combination of Javascript and XML. 32 | // Both Javascript and XML need to be imported for JSX to function. 33 | // The Javascript mode is distict from 'google-javascript' and does not support 34 | // nested back ticks. 35 | import 'codemirror/mode/javascript/javascript.js'; 36 | import 'codemirror/mode/xml/xml.js'; 37 | import 'codemirror/mode/jsx/jsx.js'; 38 | -------------------------------------------------------------------------------- /src/configurator/highlight-tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | export const tokens = [ 8 | { 9 | id: 'codeBackground', 10 | label: 'Background', 11 | cssProperty: '--playground-code-background', 12 | defaultValue: '#ffffff', 13 | }, 14 | { 15 | id: 'synDefault', 16 | label: 'Default', 17 | cssProperty: '--playground-code-default-color', 18 | cmClass: '.CodeMirror-line [role="presentation"]', 19 | defaultValue: '#000000', 20 | }, 21 | { 22 | id: 'synKeyword', 23 | label: 'Keyword', 24 | cssProperty: '--playground-code-keyword-color', 25 | cmClass: 'cm-keyword', 26 | defaultValue: '#770088', 27 | }, 28 | { 29 | id: 'synAtom', 30 | label: 'Atom', 31 | cssProperty: '--playground-code-atom-color', 32 | cmClass: 'cm-atom', 33 | defaultValue: '#221199', 34 | }, 35 | { 36 | id: 'synNumber', 37 | label: 'Number', 38 | cssProperty: '--playground-code-number-color', 39 | cmClass: 'cm-number', 40 | defaultValue: '#116644', 41 | }, 42 | { 43 | id: 'synDef', 44 | label: 'Def', 45 | cssProperty: '--playground-code-def-color', 46 | cmClass: 'cm-def', 47 | defaultValue: '#0000ff', 48 | }, 49 | { 50 | id: 'synVariable', 51 | label: 'Variable', 52 | cssProperty: '--playground-code-variable-color', 53 | cmClass: 'cm-variable', 54 | defaultValue: '#000000', 55 | }, 56 | { 57 | id: 'synProperty', 58 | label: 'Property', 59 | cssProperty: '--playground-code-property-color', 60 | cmClass: 'cm-property', 61 | defaultValue: '#000000', 62 | }, 63 | { 64 | id: 'synOperator', 65 | label: 'Operator', 66 | cssProperty: '--playground-code-operator-color', 67 | cmClass: 'cm-operator', 68 | defaultValue: '#000000', 69 | }, 70 | { 71 | id: 'synVariable2', 72 | label: 'Variable 2', 73 | cssProperty: '--playground-code-variable-2-color', 74 | cmClass: 'cm-variable-2', 75 | defaultValue: '#0055aa', 76 | }, 77 | { 78 | id: 'synVariable3', 79 | label: 'Variable 3', 80 | cssProperty: '--playground-code-variable-3-color', 81 | cmClass: 'cm-variable-3', 82 | defaultValue: '#008855', 83 | }, 84 | { 85 | id: 'synType', 86 | label: 'Type', 87 | cssProperty: '--playground-code-type-color', 88 | cmClass: 'cm-type', 89 | defaultValue: '#008855', 90 | }, 91 | { 92 | id: 'synComment', 93 | label: 'Comment', 94 | cssProperty: '--playground-code-comment-color', 95 | cmClass: 'cm-comment', 96 | defaultValue: '#aa5500', 97 | }, 98 | { 99 | id: 'synString', 100 | label: 'String', 101 | cssProperty: '--playground-code-string-color', 102 | cmClass: 'cm-string', 103 | defaultValue: '#aa1111', 104 | }, 105 | { 106 | id: 'synString2', 107 | label: 'String 2', 108 | cssProperty: '--playground-code-string-2-color', 109 | cmClass: 'cm-string-2', 110 | defaultValue: '#ff5500', 111 | }, 112 | { 113 | id: 'synMeta', 114 | label: 'Meta', 115 | cssProperty: '--playground-code-meta-color', 116 | cmClass: 'cm-meta', 117 | defaultValue: '#555555', 118 | }, 119 | { 120 | id: 'synQualifier', 121 | label: 'Qualifier', 122 | cssProperty: '--playground-code-qualifier-color', 123 | cmClass: 'cm-qualifier', 124 | defaultValue: '#555555', 125 | }, 126 | { 127 | id: 'synBuiltin', 128 | label: 'Builtin', 129 | cssProperty: '--playground-code-builtin-color', 130 | cmClass: 'cm-builtin', 131 | defaultValue: '#3300aa', 132 | }, 133 | { 134 | id: 'synTag', 135 | label: 'Tag', 136 | cssProperty: '--playground-code-tag-color', 137 | cmClass: 'cm-tag', 138 | defaultValue: '#117700', 139 | }, 140 | { 141 | id: 'synAttribute', 142 | label: 'Attribute', 143 | cssProperty: '--playground-code-attribute-color', 144 | cmClass: 'cm-attribute', 145 | defaultValue: '#0000cc', 146 | }, 147 | { 148 | id: 'synCallee', 149 | label: 'Callee', 150 | cssProperty: '--playground-code-callee-color', 151 | cmClass: 'cm-callee', 152 | defaultValue: '#000000', 153 | }, 154 | // TODO(aomarks) local? 155 | ] as const; 156 | -------------------------------------------------------------------------------- /src/configurator/knobs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | /** 8 | * This kinda turned into something like storybook.js and similar. Maybe we 9 | * should just use another library. 10 | */ 11 | 12 | import {themeNames} from './themes.js'; 13 | import {tokens} from './highlight-tokens.js'; 14 | 15 | interface BaseKnob { 16 | id: Id; 17 | label: string; 18 | section: string; 19 | default: T; 20 | cssProperty?: string; 21 | formatCss?: (value: T) => string; 22 | htmlAttribute?: string; 23 | } 24 | 25 | interface CheckboxKnob extends BaseKnob { 26 | type: 'checkbox'; 27 | } 28 | 29 | interface SliderKnob extends BaseKnob { 30 | type: 'slider'; 31 | min: number; 32 | max: number; 33 | } 34 | 35 | interface ColorKnob extends BaseKnob { 36 | type: 'color'; 37 | unsetLabel?: string; 38 | // Gross hack. When we load a new theme, we change color knob defaults. But if 39 | // we switch back to the default theme, this is how we know what the original 40 | // default was. 41 | originalDefault?: string; 42 | } 43 | 44 | interface SelectKnob 45 | extends BaseKnob { 46 | type: 'select'; 47 | options: ReadonlyArray; 48 | } 49 | 50 | interface InputKnob extends BaseKnob { 51 | type: 'input'; 52 | placeholder?: string; 53 | inputType?: string; 54 | } 55 | 56 | function checkbox( 57 | def: Omit, 'type'> 58 | ): CheckboxKnob { 59 | return {type: 'checkbox', ...def}; 60 | } 61 | 62 | function slider( 63 | def: Omit, 'type'> 64 | ): SliderKnob { 65 | return {type: 'slider', ...def}; 66 | } 67 | 68 | function color( 69 | def: Omit, 'type'> 70 | ): ColorKnob { 71 | return {type: 'color', ...def}; 72 | } 73 | 74 | function select( 75 | def: Omit, 'type'> 76 | ): SelectKnob { 77 | return {type: 'select', ...def}; 78 | } 79 | 80 | function input( 81 | def: Omit, 'type'> 82 | ): InputKnob { 83 | return {type: 'input', ...def}; 84 | } 85 | 86 | const pixels = (value: number) => value + 'px'; 87 | 88 | export const knobs = [ 89 | // code style 90 | select({ 91 | id: 'theme', 92 | label: 'Theme', 93 | section: 'code', 94 | options: ['default', ...themeNames], 95 | default: 'default', 96 | }), 97 | slider({ 98 | id: 'fontSize', 99 | label: 'Font size', 100 | section: 'code', 101 | cssProperty: '--playground-code-font-size', 102 | formatCss: pixels, 103 | min: 1, 104 | max: 30, 105 | default: 14, 106 | }), 107 | select({ 108 | id: 'fontFamily', 109 | label: 'Font', 110 | section: 'code', 111 | cssProperty: '--playground-code-font-family', 112 | options: ['monospace', 'Roboto Mono', 'Source Code Pro', 'Ubuntu Mono'], 113 | formatCss: (value: string | number | boolean) => { 114 | if (value !== 'monospace') { 115 | return `${value}, monospace`; 116 | } 117 | return value; 118 | }, 119 | default: 'monospace', 120 | }), 121 | 122 | ...tokens.map((token) => { 123 | const {id, label, cssProperty, defaultValue} = token; 124 | return color({ 125 | id, 126 | label, 127 | cssProperty, 128 | default: defaultValue, 129 | originalDefault: defaultValue, 130 | section: 'code', 131 | }); 132 | }), 133 | 134 | // features 135 | input({ 136 | id: 'cdnBaseUrl', 137 | label: 'CDN Base URL', 138 | section: 'features', 139 | default: '', 140 | placeholder: 'Resolves to https://unpkg.com', 141 | htmlAttribute: 'cdn-base-url', 142 | inputType: 'text', 143 | }), 144 | checkbox({ 145 | id: 'resizable', 146 | label: 'Resizable', 147 | section: 'features', 148 | default: false, 149 | htmlAttribute: 'resizable', 150 | }), 151 | checkbox({ 152 | id: 'editableFileSystem', 153 | label: 'Editable filesystem', 154 | section: 'features', 155 | default: false, 156 | htmlAttribute: 'editable-file-system', 157 | }), 158 | checkbox({ 159 | id: 'lineNumbers', 160 | label: 'Line numbers', 161 | section: 'features', 162 | default: false, 163 | htmlAttribute: 'line-numbers', 164 | }), 165 | checkbox({ 166 | id: 'lineWrapping', 167 | label: 'Line wrapping', 168 | section: 'features', 169 | default: false, 170 | htmlAttribute: 'line-wrapping', 171 | }), 172 | checkbox({ 173 | id: 'noCompletions', 174 | label: 'No completions', 175 | section: 'features', 176 | default: false, 177 | htmlAttribute: 'no-completions', 178 | }), 179 | 180 | // general appearance 181 | color({ 182 | id: 'highlight', 183 | label: 'Highlight', 184 | cssProperty: '--playground-highlight-color', 185 | default: '#6200ee', 186 | section: 'general appearance', 187 | }), 188 | color({ 189 | id: 'pageBackground', 190 | label: 'Page background', 191 | default: '#cccccc', 192 | originalDefault: '#cccccc', 193 | section: 'general appearance', 194 | }), 195 | slider({ 196 | id: 'radius', 197 | label: 'Radius', 198 | cssProperty: 'border-radius', 199 | formatCss: pixels, 200 | min: 0, 201 | max: 30, 202 | default: 0, 203 | section: 'general appearance', 204 | }), 205 | checkbox({ 206 | id: 'borders', 207 | label: 'Borders', 208 | cssProperty: '--playground-border', 209 | formatCss: (value: string | number | boolean) => 210 | value ? '1px solid #ddd' : 'none', 211 | default: true, 212 | section: 'general appearance', 213 | }), 214 | 215 | // tab bar 216 | color({ 217 | id: 'tabBarBackground', 218 | label: 'Background', 219 | cssProperty: '--playground-tab-bar-background', 220 | default: '#eaeaea', 221 | section: 'tab bar', 222 | }), 223 | color({ 224 | id: 'tabBarForeground', 225 | label: 'Foreground', 226 | cssProperty: '--playground-tab-bar-foreground-color', 227 | default: '#000000', 228 | section: 'tab bar', 229 | }), 230 | slider({ 231 | id: 'barHeight', 232 | label: 'Bar height', 233 | cssProperty: '--playground-bar-height', 234 | formatCss: pixels, 235 | min: 10, 236 | max: 100, 237 | default: 40, 238 | section: 'tab bar', 239 | }), 240 | 241 | // preview 242 | color({ 243 | id: 'previewToolbarBackground', 244 | label: 'Background', 245 | cssProperty: '--playground-preview-toolbar-background', 246 | default: '#ffffff', 247 | section: 'preview', 248 | }), 249 | color({ 250 | id: 'previewToolbarForeground', 251 | label: 'Foreground', 252 | cssProperty: '--playground-preview-toolbar-foreground-color', 253 | default: '#444444', 254 | section: 'preview', 255 | }), 256 | slider({ 257 | id: 'previewWidth', 258 | label: 'Preview width', 259 | cssProperty: '--playground-preview-width', 260 | formatCss: (val) => `${val}%`, 261 | min: 0, 262 | max: 100, 263 | default: 30, 264 | section: 'preview', 265 | }), 266 | ] as const; 267 | 268 | export type Knob = (typeof knobs)[number]; 269 | export type KnobId = Knob['id']; 270 | 271 | type KnobsById = {[K in Knob as K['id']]: K}; 272 | export const knobsById = {} as KnobsById; 273 | export const knobsBySection = {} as {[section: string]: Knob[]}; 274 | for (const knob of knobs) { 275 | // Cast to an arbitrary specific Knob type here because TypeScript isn't quite 276 | // clever enough to know that the id will match the type. 277 | knobsById[(knob as (typeof knobs)[0]).id] = knob as (typeof knobs)[0]; 278 | let catArr = knobsBySection[knob.section]; 279 | if (catArr === undefined) { 280 | catArr = knobsBySection[knob.section] = []; 281 | } 282 | catArr.push(knob); 283 | } 284 | export const knobIds = Object.keys(knobsById) as KnobId[]; 285 | export const knobSectionNames = Object.keys(knobsBySection); 286 | 287 | export type KnobsOfType = Exclude< 288 | { 289 | [K in Knob as K['id']]: K extends {type: T} ? K : never; 290 | }[KnobId], 291 | never 292 | >; 293 | 294 | export type KnobValueType = KnobsById[T]['default']; 295 | 296 | export class KnobValues { 297 | private values = new Map(); 298 | 299 | getValue(id: T): KnobValueType { 300 | if (this.values.has(id)) { 301 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 302 | return this.values.get(id) as any; 303 | } 304 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 305 | return knobsById[id].default as any; 306 | } 307 | 308 | setValue(id: T, value: KnobValueType) { 309 | this.values.set(id, value); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | export * from './playground-ide.js'; 8 | -------------------------------------------------------------------------------- /src/internal/build.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {Deferred} from '../shared/deferred.js'; 8 | 9 | import { 10 | SampleFile, 11 | BuildOutput, 12 | FileBuildOutput, 13 | DiagnosticBuildOutput, 14 | HttpError, 15 | } from '../shared/worker-api.js'; 16 | import {Diagnostic} from 'vscode-languageserver-protocol'; 17 | 18 | const unreachable = (n: never) => n; 19 | 20 | type State = 'active' | 'done' | 'cancelled'; 21 | 22 | const errorNotFound: HttpError = { 23 | status: /* Not Found */ 404, 24 | body: 'Playground file not found', 25 | }; 26 | 27 | const errorCancelled: HttpError = { 28 | status: /* Service Unavailable */ 503, 29 | body: 'Playground build cancelled', 30 | }; 31 | 32 | /** 33 | * The results of a particular Playground build. 34 | */ 35 | export class PlaygroundBuild { 36 | diagnostics = new Map(); 37 | private _state: State = 'active'; 38 | private _stateChange = new Deferred(); 39 | private _files = new Map>(); 40 | private _diagnosticsCallback: () => void; 41 | private _diagnosticsDebounceId: number | undefined; 42 | 43 | /** 44 | * @param diagnosticsCallback Function that will be invoked when one or more 45 | * new diagnostics have been received. Fires at most once per animation frame. 46 | */ 47 | constructor(diagnosticsCallback: () => void) { 48 | this._diagnosticsCallback = diagnosticsCallback; 49 | } 50 | 51 | /** 52 | * The current state of this build. 53 | */ 54 | state(): State { 55 | // Note this could be a getter, but TypeScript optimistically preserves 56 | // type-narrowing on properties between awaits, which makes usage awkward in 57 | // this case (see https://github.com/microsoft/TypeScript/issues/31429). 58 | return this._state; 59 | } 60 | 61 | /** 62 | * Promise of the next state change. 63 | */ 64 | get stateChange(): Promise { 65 | return this._stateChange.promise; 66 | } 67 | 68 | /** 69 | * Set this build's state to cancelled, ignore any future build results, and 70 | * fail any pending file gets. 71 | */ 72 | cancel(): void { 73 | this._errorPendingFileRequests(errorCancelled); 74 | this._changeState('cancelled'); 75 | } 76 | 77 | /** 78 | * Return a promise of a build output with the given name. If the file is not 79 | * received before the build is completed or cancelled, this promise will be 80 | * rejected. 81 | */ 82 | async getFile(name: string): Promise { 83 | let deferred = this._files.get(name); 84 | if (deferred === undefined) { 85 | if (this._state === 'done') { 86 | return errorNotFound; 87 | } else if (this._state === 'cancelled') { 88 | return errorCancelled; 89 | } 90 | deferred = new Deferred(); 91 | this._files.set(name, deferred); 92 | } 93 | return deferred.promise; 94 | } 95 | 96 | /** 97 | * Handle a worker build output. 98 | */ 99 | onOutput(output: BuildOutput) { 100 | if (this._state !== 'active') { 101 | return; 102 | } 103 | if (output.kind === 'file') { 104 | this._onFile(output); 105 | } else if (output.kind === 'diagnostic') { 106 | this._onDiagnostic(output); 107 | } else if (output.kind === 'done') { 108 | this._onDone(); 109 | } else { 110 | throw new Error( 111 | `Unexpected BuildOutput kind: ${ 112 | (unreachable(output) as BuildOutput).kind 113 | }` 114 | ); 115 | } 116 | } 117 | 118 | private _changeState(state: State) { 119 | this._state = state; 120 | this._stateChange.resolve(); 121 | this._stateChange = new Deferred(); 122 | } 123 | 124 | private _onFile(output: FileBuildOutput) { 125 | let deferred = this._files.get(output.file.name); 126 | if (deferred === undefined) { 127 | deferred = new Deferred(); 128 | this._files.set(output.file.name, deferred); 129 | } 130 | deferred.resolve(output.file); 131 | } 132 | 133 | private _onDiagnostic(output: DiagnosticBuildOutput) { 134 | let arr = this.diagnostics.get(output.filename); 135 | if (arr === undefined) { 136 | arr = []; 137 | this.diagnostics.set(output.filename, arr); 138 | } 139 | arr.push(output.diagnostic); 140 | if (this._diagnosticsDebounceId === undefined) { 141 | this._diagnosticsDebounceId = requestAnimationFrame(() => { 142 | if (this._state !== 'cancelled') { 143 | this._diagnosticsDebounceId = undefined; 144 | this._diagnosticsCallback(); 145 | } 146 | }); 147 | } 148 | } 149 | 150 | private _onDone() { 151 | this._errorPendingFileRequests(errorNotFound); 152 | this._changeState('done'); 153 | } 154 | 155 | private _errorPendingFileRequests(error: HttpError) { 156 | for (const file of this._files.values()) { 157 | if (!file.settled) { 158 | file.resolve(error); 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/internal/codemirror.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | // Our own specialized CodeMirror bundle (see rollup.config.js). 8 | import '../_codemirror/codemirror-bundle.js'; 9 | 10 | // Note it's critical we use `import type` here, or else we'll also import the 11 | // wrong runtime modules. 12 | import CodeMirrorCore from 'codemirror'; 13 | import CoreMirrorFolding from 'codemirror/addon/fold/foldcode.js'; 14 | import CodeMirrorHinting from 'codemirror/addon/hint/show-hint.js'; 15 | import CodeMirrorComment from 'codemirror/addon/comment/comment.js'; 16 | 17 | /** 18 | * CodeMirror function. 19 | * 20 | * This function is defined as window.CodeMirror, but @types/codemirror doesn't 21 | * declare that. 22 | */ 23 | export const CodeMirror = ( 24 | window as { 25 | CodeMirror: typeof CodeMirrorCore & 26 | typeof CoreMirrorFolding & 27 | typeof CodeMirrorHinting & 28 | typeof CodeMirrorComment; 29 | } 30 | ).CodeMirror; 31 | -------------------------------------------------------------------------------- /src/internal/overlay.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {LitElement, css, html} from 'lit'; 8 | import {customElement} from 'lit/decorators.js'; 9 | 10 | /** 11 | * An absolutely positioned scrim with a floating message. 12 | */ 13 | @customElement('playground-internal-overlay') 14 | export class PlaygroundInternalOverlay extends LitElement { 15 | static override styles = css` 16 | :host { 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | box-sizing: border-box; 21 | left: 0; 22 | top: 0; 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | background: transparent; 27 | z-index: 9; 28 | background: rgba(0, 0, 0, 0.32); 29 | overflow-y: auto; 30 | } 31 | 32 | #message { 33 | background: #fff; 34 | color: #000; 35 | padding: 10px 20px; 36 | border-radius: 5px; 37 | box-shadow: rgba(0, 0, 0, 0.3) 0 2px 10px; 38 | } 39 | `; 40 | 41 | override render() { 42 | return html`
`; 43 | } 44 | } 45 | 46 | declare global { 47 | interface HTMLElementTagNameMap { 48 | 'playground-internal-overlay': PlaygroundInternalOverlay; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/internal/tab-bar.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {html, css, LitElement} from 'lit'; 8 | import {customElement, property} from 'lit/decorators.js'; 9 | import {ifDefined} from 'lit/directives/if-defined.js'; 10 | 11 | import {PlaygroundInternalTab} from './tab.js'; 12 | 13 | /** 14 | * A horizontal bar of tabs. 15 | * 16 | * Slots: 17 | * - default: The tabs. 18 | */ 19 | @customElement('playground-internal-tab-bar') 20 | export class PlaygroundInternalTabBar extends LitElement { 21 | static override styles = css` 22 | :host { 23 | display: flex; 24 | overflow-x: auto; 25 | } 26 | 27 | :host::-webkit-scrollbar { 28 | display: none; 29 | } 30 | 31 | div { 32 | display: flex; 33 | } 34 | `; 35 | 36 | /** 37 | * Aria label of the tab list. 38 | */ 39 | @property() 40 | label?: string; 41 | 42 | /** 43 | * Get or set the active tab. 44 | */ 45 | get active(): PlaygroundInternalTab | undefined { 46 | return this._active; 47 | } 48 | 49 | set active(tab: PlaygroundInternalTab | undefined) { 50 | /** 51 | * Note the active tab can be set either by setting the bar's `active` 52 | * property to the tab, or by setting the tab's `active` property to 53 | * true. The two become synchronized according to the following flow: 54 | * 55 | * bar click/keydown 56 | * | 57 | * v 58 | * bar.active = tab ---> changed? ---> tab.active = true 59 | * ^ | 60 | * | v 61 | * bar tabchange listener changed from false to true? 62 | * ^ | 63 | * | | 64 | * +--- tab dispatches tabchange <---+ 65 | */ 66 | const oldActive = this._active; 67 | if (tab === oldActive) { 68 | return; 69 | } 70 | this._active = tab; 71 | if (oldActive !== undefined) { 72 | oldActive.active = false; 73 | } 74 | if (tab !== undefined) { 75 | tab.active = true; 76 | } else { 77 | // Usually the tab itself emits the tabchange event, but we need to handle 78 | // the "no active tab" case here. 79 | this.dispatchEvent( 80 | new CustomEvent<{tab?: PlaygroundInternalTab}>('tabchange', { 81 | detail: {tab: undefined}, 82 | bubbles: true, 83 | }) 84 | ); 85 | } 86 | } 87 | 88 | private _tabs: PlaygroundInternalTab[] = []; 89 | private _active: PlaygroundInternalTab | undefined = undefined; 90 | 91 | override render() { 92 | return html` 93 |
94 | 100 |
101 | `; 102 | } 103 | 104 | private _onSlotchange(event: Event) { 105 | this._tabs = ( 106 | event.target as HTMLSlotElement 107 | ).assignedElements() as PlaygroundInternalTab[]; 108 | let newActive; 109 | // Manage the idx and active properties on all tabs. The first tab that 110 | // asserts it is active wins. 111 | for (let i = 0; i < this._tabs.length; i++) { 112 | const tab = this._tabs[i]; 113 | tab.index = i; 114 | if (newActive !== undefined) { 115 | tab.active = false; 116 | } else if (tab.active || tab.hasAttribute('active')) { 117 | // Check both the active property and the active attribute, because the 118 | // user could have set the initial active state either way, and it might 119 | // not have reflected to the other yet. 120 | newActive = tab; 121 | } 122 | } 123 | this.active = newActive; 124 | } 125 | 126 | private _activateTab(event: Event) { 127 | const tab = this._findEventTab(event); 128 | if (tab === undefined) { 129 | return; 130 | } 131 | this.active = tab; 132 | this._scrollTabIntoViewIfNeeded(tab); 133 | } 134 | 135 | /** 136 | * If the given tab is not visible, or if not enough of its adjacent tabs are 137 | * visible, scroll so that the tab is centered. 138 | */ 139 | private _scrollTabIntoViewIfNeeded(tab: PlaygroundInternalTab) { 140 | // Note we don't want to use tab.scrollIntoView() because that would also 141 | // scroll the viewport to show the tab bar. 142 | const barRect = this.getBoundingClientRect(); 143 | const tabRect = tab.getBoundingClientRect(); 144 | // Add a margin so that we'll also scroll if not enough of an adjacent tab 145 | // is visible, so that it's clickable. 48px is the recommended minimum touch 146 | // target size from the Material Accessibility guidelines 147 | // (https://material.io/design/usability/accessibility.html#layout-and-typography) 148 | const margin = 48; 149 | if ( 150 | tabRect.left - margin < barRect.left || 151 | tabRect.right + margin > barRect.right 152 | ) { 153 | const centered = 154 | tabRect.left - 155 | barRect.left + 156 | this.scrollLeft - 157 | barRect.width / 2 + 158 | tabRect.width / 2; 159 | this.scroll({left: centered, behavior: 'smooth'}); 160 | } 161 | } 162 | 163 | private async _onKeydown(event: KeyboardEvent) { 164 | const oldIdx = this.active?.index ?? 0; 165 | const endIdx = this._tabs.length - 1; 166 | let newIdx = oldIdx; 167 | switch (event.key) { 168 | case 'ArrowLeft': { 169 | if (oldIdx === 0) { 170 | newIdx = endIdx; // Wrap around. 171 | } else { 172 | newIdx--; 173 | } 174 | break; 175 | } 176 | case 'ArrowRight': { 177 | if (oldIdx === endIdx) { 178 | newIdx = 0; // Wrap around. 179 | } else { 180 | newIdx++; 181 | } 182 | break; 183 | } 184 | case 'Home': { 185 | newIdx = 0; 186 | break; 187 | } 188 | case 'End': { 189 | newIdx = endIdx; 190 | break; 191 | } 192 | } 193 | if (newIdx !== oldIdx) { 194 | // Prevent default scrolling behavior. 195 | event.preventDefault(); 196 | const tab = this._tabs[newIdx]; 197 | this.active = tab; 198 | // Wait for tabindex to update so we can call focus. 199 | await tab.updateComplete; 200 | tab.focus(); 201 | } 202 | } 203 | 204 | private _findEventTab(event: Event): PlaygroundInternalTab | undefined { 205 | const target = event.target as HTMLElement | undefined; 206 | if (target?.localName === 'playground-internal-tab') { 207 | return event.target as PlaygroundInternalTab; 208 | } 209 | for (const el of event.composedPath()) { 210 | if ( 211 | (el as HTMLElement | undefined)?.localName === 'playground-internal-tab' 212 | ) { 213 | return el as PlaygroundInternalTab; 214 | } 215 | } 216 | return undefined; 217 | } 218 | } 219 | 220 | declare global { 221 | interface HTMLElementTagNameMap { 222 | 'playground-internal-tab-bar': PlaygroundInternalTabBar; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/internal/tab.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {html, css, LitElement, PropertyValues} from 'lit'; 8 | import {customElement, property, query} from 'lit/decorators.js'; 9 | 10 | /** 11 | * A tab in a . 12 | * 13 | * Slots: 14 | * - default: Label or other contents of the tab. 15 | * 16 | * Parts: 17 | * - button: Button with tab role. 18 | */ 19 | @customElement('playground-internal-tab') 20 | export class PlaygroundInternalTab extends LitElement { 21 | static override styles = css` 22 | :host { 23 | display: flex; 24 | } 25 | 26 | button { 27 | flex: 1; 28 | border: none; 29 | font-size: inherit; 30 | font-family: inherit; 31 | color: inherit; 32 | background: transparent; 33 | display: flex; 34 | align-items: center; 35 | cursor: pointer; 36 | position: relative; 37 | outline: none; 38 | } 39 | 40 | button::before { 41 | content: ''; 42 | position: absolute; 43 | width: 100%; 44 | height: 100%; 45 | left: 0; 46 | top: 0; 47 | background: currentcolor; 48 | opacity: 0; 49 | transition: opacity 150ms; 50 | } 51 | 52 | button:focus::before, 53 | button:hover::before { 54 | opacity: 10%; 55 | } 56 | 57 | button:active::before { 58 | opacity: 20%; 59 | } 60 | 61 | :host([active]) > button::after { 62 | content: ''; 63 | position: absolute; 64 | left: 0; 65 | bottom: 0; 66 | width: 100%; 67 | height: 2px; 68 | background: var( 69 | --playground-tab-bar-indicator-color, 70 | var(--playground-highlight-color, #6200ee) 71 | ); 72 | } 73 | `; 74 | 75 | /** 76 | * Whether this tab is currently active. 77 | */ 78 | @property({type: Boolean, reflect: true}) 79 | active = false; 80 | 81 | @query('button') 82 | private _button!: HTMLButtonElement; 83 | 84 | /** 85 | * The 0-indexed position of this tab within its . 86 | * 87 | * Note this property is managed by the containing and 88 | * should not be set directly. 89 | */ 90 | index = 0; 91 | 92 | override render() { 93 | return html``; 101 | } 102 | 103 | override updated(changes: PropertyValues) { 104 | if (changes.has('active') && this.active) { 105 | this.dispatchEvent( 106 | new CustomEvent<{tab?: PlaygroundInternalTab}>('tabchange', { 107 | detail: {tab: this}, 108 | bubbles: true, 109 | }) 110 | ); 111 | } 112 | } 113 | 114 | override focus() { 115 | this._button.focus(); 116 | } 117 | } 118 | 119 | declare global { 120 | interface HTMLElementTagNameMap { 121 | 'playground-internal-tab': PlaygroundInternalTab; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/internal/typescript.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import ts from 'typescript'; 8 | export default ts; 9 | -------------------------------------------------------------------------------- /src/playground-connected-element.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {LitElement} from 'lit'; 8 | import {property, state} from 'lit/decorators.js'; 9 | import {PlaygroundProject} from './playground-project.js'; 10 | 11 | /** 12 | * Base class that connects an element to a . 13 | */ 14 | export class PlaygroundConnectedElement extends LitElement { 15 | /** 16 | * The project that this element is associated with. Either the 17 | * `` node itself, or its `id` in the host scope. 18 | */ 19 | @property() 20 | set project(elementOrId: PlaygroundProject | string | undefined) { 21 | if (typeof elementOrId === 'string') { 22 | // Defer querying the host to a rAF because if the host renders this 23 | // element before the one we're querying for, it might not quite exist 24 | // yet. 25 | requestAnimationFrame(() => { 26 | const root = this.getRootNode() as ShadowRoot | Document; 27 | this._project = 28 | (root.getElementById(elementOrId) as PlaygroundProject | null) ?? 29 | undefined; 30 | }); 31 | } else { 32 | this._project = elementOrId; 33 | } 34 | } 35 | 36 | /** 37 | * The actual `` node, determined by the `project` 38 | * property. 39 | */ 40 | @state() 41 | protected _project?: PlaygroundProject; 42 | } 43 | -------------------------------------------------------------------------------- /src/playground-file-editor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {html, css, PropertyValues} from 'lit'; 8 | import {customElement, property, query} from 'lit/decorators.js'; 9 | import {live} from 'lit/directives/live.js'; 10 | 11 | import './playground-code-editor.js'; 12 | import {PlaygroundConnectedElement} from './playground-connected-element.js'; 13 | 14 | import {PlaygroundProject} from './playground-project.js'; 15 | import {PlaygroundCodeEditor} from './playground-code-editor.js'; 16 | import {CodeEditorChangeData} from './shared/worker-api.js'; 17 | 18 | /** 19 | * A text editor associated with a . 20 | */ 21 | @customElement('playground-file-editor') 22 | export class PlaygroundFileEditor extends PlaygroundConnectedElement { 23 | static override styles = css` 24 | :host { 25 | display: block; 26 | /* Prevents scrollbars from changing container size and shifting layout 27 | slightly. */ 28 | box-sizing: border-box; 29 | height: 350px; 30 | } 31 | 32 | slot { 33 | height: 100%; 34 | display: block; 35 | background: var(--playground-code-background, unset); 36 | } 37 | 38 | playground-code-editor { 39 | height: 100%; 40 | border-radius: inherit; 41 | border-top-left-radius: 0; 42 | border-top-right-radius: 0; 43 | } 44 | `; 45 | 46 | @query('playground-code-editor') 47 | private _editor!: PlaygroundCodeEditor; 48 | 49 | /** 50 | * The name of the project file that is currently being displayed. 51 | */ 52 | @property() 53 | filename?: string; 54 | 55 | /** 56 | * If true, display a left-hand-side gutter with line numbers. Default false 57 | * (hidden). 58 | */ 59 | @property({type: Boolean, attribute: 'line-numbers'}) 60 | lineNumbers = false; 61 | 62 | /** 63 | * If true, wrap for long lines. Default false 64 | */ 65 | @property({type: Boolean, attribute: 'line-wrapping'}) 66 | lineWrapping = false; 67 | 68 | /** 69 | * How to handle `playground-hide` and `playground-fold` comments. 70 | * 71 | * See https://github.com/google/playground-elements#hiding--folding for 72 | * more details. 73 | * 74 | * Options: 75 | * - on: Hide and fold regions, and hide the special comments. 76 | * - off: Don't hide or fold regions, but still hide the special comments. 77 | * - off-visible: Don't hide or fold regions, and show the special comments as 78 | * literal text. 79 | */ 80 | @property() 81 | pragmas: 'on' | 'off' | 'off-visible' = 'on'; 82 | 83 | /** 84 | * If true, this editor is not editable. 85 | */ 86 | @property({type: Boolean, reflect: true}) 87 | readonly = false; 88 | 89 | /** 90 | * If true, will disable code completions in the code-editor. 91 | */ 92 | @property({type: Boolean, attribute: 'no-completions'}) 93 | noCompletions = false; 94 | 95 | private get _files() { 96 | return this._project?.files ?? []; 97 | } 98 | 99 | private get _currentFile() { 100 | return this.filename 101 | ? this._files.find((file) => file.name === this.filename) 102 | : undefined; 103 | } 104 | 105 | override async update(changedProperties: PropertyValues) { 106 | if (changedProperties.has('_project')) { 107 | const oldProject = changedProperties.get('_project') as PlaygroundProject; 108 | if (oldProject) { 109 | oldProject.removeEventListener( 110 | 'filesChanged', 111 | this._onProjectFilesChanged 112 | ); 113 | oldProject.removeEventListener('compileDone', this._onCompileDone); 114 | oldProject.removeEventListener( 115 | 'diagnosticsChanged', 116 | this._onDiagnosticsChanged 117 | ); 118 | } 119 | if (this._project) { 120 | this._project.addEventListener( 121 | 'filesChanged', 122 | this._onProjectFilesChanged 123 | ); 124 | this._project.addEventListener('compileDone', this._onCompileDone); 125 | this._project.addEventListener( 126 | 'diagnosticsChanged', 127 | this._onDiagnosticsChanged 128 | ); 129 | } 130 | this._onProjectFilesChanged(); 131 | } 132 | super.update(changedProperties); 133 | } 134 | 135 | override render() { 136 | return html` 137 | ${this._files 138 | ? html` 139 | 161 | 162 | ` 163 | : html``} 164 | `; 165 | } 166 | 167 | private _onProjectFilesChanged = () => { 168 | this.filename ??= this._files[0]?.name; 169 | this.requestUpdate(); 170 | }; 171 | 172 | private _onCompileDone = () => { 173 | // Propagate diagnostics. 174 | this.requestUpdate(); 175 | }; 176 | 177 | private _onDiagnosticsChanged = () => { 178 | // Propagate diagnostics. 179 | this.requestUpdate(); 180 | }; 181 | 182 | private _onEdit() { 183 | if ( 184 | this._project === undefined || 185 | this._currentFile === undefined || 186 | this._editor.value === undefined 187 | ) { 188 | return; 189 | } 190 | this._project.editFile(this._currentFile, this._editor.value); 191 | } 192 | 193 | private async _onRequestCompletions(e: CustomEvent) { 194 | const codeEditorChangeData = e.detail as CodeEditorChangeData; 195 | codeEditorChangeData.fileName = this.filename ?? ''; 196 | const completions = await this._project?.getCompletions( 197 | codeEditorChangeData 198 | ); 199 | if (completions) { 200 | codeEditorChangeData.provideCompletions(completions); 201 | } 202 | } 203 | } 204 | 205 | declare global { 206 | interface HTMLElementTagNameMap { 207 | 'playground-file-editor': PlaygroundFileEditor; 208 | } 209 | } 210 | 211 | const mimeTypeToTypeEnum = (mimeType?: string) => { 212 | // TODO: infer type based on extension too 213 | if (mimeType === undefined) { 214 | return; 215 | } 216 | const encodingSepIndex = mimeType.indexOf(';'); 217 | if (encodingSepIndex !== -1) { 218 | mimeType = mimeType.substring(0, encodingSepIndex); 219 | } 220 | switch (mimeType) { 221 | // TypeScript: this is the mime-type returned by servers 222 | // .ts files aren't usually served to browsers, so they don't yet 223 | // have their own mime-type. 224 | case 'video/mp2t': 225 | return 'ts'; 226 | case 'text/javascript': 227 | case 'application/javascript': 228 | return 'js'; 229 | case 'text/jsx': 230 | return 'jsx'; 231 | case 'text/typescript-jsx': 232 | return 'tsx'; 233 | case 'application/json': 234 | return 'json'; 235 | case 'text/html': 236 | return 'html'; 237 | case 'text/css': 238 | return 'css'; 239 | } 240 | return undefined; 241 | }; 242 | -------------------------------------------------------------------------------- /src/playground-service-worker-proxy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import { 8 | PlaygroundMessage, 9 | CONNECT_SW_TO_PROJECT, 10 | CONNECT_PROJECT_TO_SW, 11 | CONFIGURE_PROXY, 12 | MISSING_FILE_API, 13 | UPDATE_SERVICE_WORKER, 14 | } from './shared/worker-api.js'; 15 | 16 | void (async () => { 17 | try { 18 | // Note we detect same-origin here by actually trying to access the parent 19 | // window. We can't trust the parent to compare the origins of the URLs, 20 | // because a redirect could have lead us back to the same origin. 21 | parent.window.console.warn( 22 | 'Playground sandbox is executing with the same origin as its parent.', 23 | 'This is a security risk.', 24 | 'https://github.com/google/playground-elements#sandbox-security' 25 | ); 26 | // eslint-disable-next-line no-empty 27 | } catch {} 28 | 29 | // Wait for our parent to send us: 30 | // 1. The URL and scope of the Service Worker to register. 31 | // 2. A MessagePort, on which we'll forward up new Service Worker ports. 32 | const {scope, port: parentPort} = await new Promise<{ 33 | scope: string; 34 | port: MessagePort; 35 | }>((resolve) => { 36 | const listener = (event: MessageEvent) => { 37 | if (event.data.type === CONFIGURE_PROXY) { 38 | window.removeEventListener('message', listener); 39 | resolve(event.data); 40 | } 41 | }; 42 | window.addEventListener('message', listener); 43 | }); 44 | 45 | const registration = await navigator.serviceWorker.register( 46 | new URL('playground-service-worker.js', import.meta.url).href, 47 | {scope} 48 | ); 49 | 50 | /** https://www.w3.org/TR/service-workers/#get-newest-worker-algorithm */ 51 | const getNewestWorker = () => 52 | registration.installing ?? registration.waiting ?? registration.active; 53 | 54 | /** 55 | * Resolve when the given service worker reaches its activated state. 56 | */ 57 | const activated = async (sw: ServiceWorker): Promise => { 58 | if (sw.state === 'activated') { 59 | return; 60 | } 61 | return new Promise((resolve) => { 62 | const aborter = new AbortController(); 63 | sw.addEventListener( 64 | 'statechange', 65 | () => { 66 | if (sw.state === 'activated') { 67 | resolve(); 68 | aborter.abort(); 69 | } 70 | }, 71 | {signal: aborter.signal} 72 | ); 73 | }); 74 | }; 75 | 76 | /** The service worker we most recently connected to. */ 77 | let connected: ServiceWorker | null = null; 78 | 79 | const connectToNewest = async (force = false) => { 80 | const sw = getNewestWorker(); 81 | if (!force && sw === connected) { 82 | // This can happen because on a fresh install we'll call connectToNewest() 83 | // but "updatefound" will also fire and call it too. 84 | return; 85 | } 86 | connected = sw; 87 | if (sw === null) { 88 | // Can't find a way to reproduce this scenario, possibly can't happen. 89 | console.error('No playground service worker found.'); 90 | return; 91 | } 92 | 93 | // Don't connect to the project until the service worker is activated. 94 | // Otherwise the project might try to load a preview before the service 95 | // worker is actively controlling the preview's URL space. 96 | await activated(sw); 97 | if (sw !== connected) { 98 | // A new service worker appeared while we were waiting. This probably 99 | // can't happen in practice. 100 | return; 101 | } 102 | 103 | const {port1, port2} = new MessageChannel(); 104 | const projectMessage: PlaygroundMessage = { 105 | type: CONNECT_PROJECT_TO_SW, 106 | port: port1, 107 | }; 108 | parentPort.postMessage(projectMessage, [port1]); 109 | const swMessage: PlaygroundMessage = { 110 | type: CONNECT_SW_TO_PROJECT, 111 | port: port2, 112 | }; 113 | sw.postMessage(swMessage, [port2]); 114 | }; 115 | 116 | void connectToNewest(); 117 | 118 | registration.addEventListener('updatefound', () => { 119 | // We can get a new service worker at any time, so we need to listen for 120 | // updates and connect to new workers on demand. 121 | void connectToNewest(); 122 | }); 123 | 124 | // A message from the service worker. 125 | navigator.serviceWorker.addEventListener( 126 | 'message', 127 | (event: MessageEvent) => { 128 | if (event.source !== connected) { 129 | // Ignore messages from outdated service workers. 130 | return; 131 | } 132 | if (event.data.type === MISSING_FILE_API) { 133 | // A fetch was made for a session that the service worker doesn't have a 134 | // file API for. Most likely the service worker was stopped and lost its 135 | // state, as can happen at any time. The fetch re-awakened it, and now 136 | // the fetch is waiting for us to re-connect. 137 | // 138 | // Force required because we usually avoid connecting to a service 139 | // worker we've already connected to, but in this case that's exactly 140 | // what we must do. 141 | void connectToNewest(true); 142 | } 143 | } 144 | ); 145 | 146 | // A message from the project. 147 | window.addEventListener( 148 | 'message', 149 | async (event: MessageEvent) => { 150 | if (event.data.type === UPDATE_SERVICE_WORKER) { 151 | // When the project handshakes with the service worker, it may notice a 152 | // version mismatch, and will send this message to request an update. 153 | const newestBefore = getNewestWorker(); 154 | await registration.update(); 155 | if (getNewestWorker() === newestBefore) { 156 | // The update() promise resolves when the check has finished, but it 157 | // might not have actually found a new service worker. In that case, 158 | // maybe one of these things happened: 159 | // 160 | // - The project code is outdated so the page needs to be reloaded. 161 | // - The server(s) are returning mismatched versions. 162 | // - A caching issue is preventing the new version from being fetched. 163 | // 164 | // TODO(aomarks) Show a more prominent error directly on the preview 165 | // prompting the user to reload the page. Otherwise it will just spin 166 | // forever. 167 | console.error('Playground service worker update failed.'); 168 | } 169 | } 170 | } 171 | ); 172 | })(); 173 | -------------------------------------------------------------------------------- /src/playground-typescript-worker-entrypoint.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {expose} from 'comlink'; 8 | import {build} from './typescript-worker/build.js'; 9 | import {WorkerAPI} from './shared/worker-api.js'; 10 | import { 11 | getCompletionItemDetails, 12 | queryCompletions, 13 | } from './typescript-worker/completions.js'; 14 | 15 | const workerAPI: WorkerAPI = { 16 | compileProject: build, 17 | getCompletions: queryCompletions, 18 | getCompletionItemDetails: getCompletionItemDetails, 19 | }; 20 | expose(workerAPI); 21 | -------------------------------------------------------------------------------- /src/service-worker/playground-service-worker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import { 8 | ACKNOWLEDGE_SW_CONNECTION, 9 | CONNECT_SW_TO_PROJECT, 10 | MISSING_FILE_API, 11 | PlaygroundMessage, 12 | ServiceWorkerAPI, 13 | FileAPI, 14 | } from '../shared/worker-api.js'; 15 | import {expose} from 'comlink'; 16 | import {Deferred} from '../shared/deferred.js'; 17 | import {serviceWorkerHash} from '../shared/version.js'; 18 | 19 | // eslint-disable-next-line no-var 20 | declare var self: ServiceWorkerGlobalScope; 21 | 22 | type SessionID = string; 23 | 24 | /** 25 | * A collection of FileAPI objects registered by instances, 26 | * keyed by session ID. 27 | */ 28 | const fileAPIs = new Map>(); 29 | 30 | /** 31 | * API exposed to the UI thread via Comlink. The static methods on this class 32 | * become instance methods on SwControllerAPI. 33 | */ 34 | const workerAPI: ServiceWorkerAPI = { 35 | setFileAPI(fileAPI: FileAPI, sessionID: SessionID) { 36 | let deferred = fileAPIs.get(sessionID); 37 | if (deferred === undefined || deferred.settled) { 38 | deferred = new Deferred(); 39 | fileAPIs.set(sessionID, deferred); 40 | } 41 | deferred.resolve(fileAPI); 42 | }, 43 | }; 44 | 45 | const findSessionProxyClient = async ( 46 | sessionId: string 47 | ): Promise => { 48 | for (const client of await self.clients.matchAll({ 49 | includeUncontrolled: true, 50 | })) { 51 | const hash = new URL(client.url).hash; 52 | const hashParams = new URLSearchParams(hash.slice(1)); 53 | if (hashParams.get('playground-session-id') === sessionId) { 54 | return client; 55 | } 56 | } 57 | return undefined; 58 | }; 59 | 60 | const getFileApi = async (sessionId: string): Promise => { 61 | let deferred = fileAPIs.get(sessionId); 62 | if (deferred !== undefined) { 63 | return deferred.promise; 64 | } 65 | // Find the proxy that manages this session, and tell it to connect us to the 66 | // session file API. Service Workers can stop and start at any time, clearing 67 | // global state. This kind of restart does _not_ count as a state change. The 68 | // only way we can tell this has happened is that a fetch occurs for a session 69 | // we don't know about. 70 | const client = await findSessionProxyClient(sessionId); 71 | if (client === undefined) { 72 | // This could happen if a user directly opened a playground URL after a 73 | // proxy iframe has been destroyed. 74 | return undefined; 75 | } 76 | deferred = new Deferred(); 77 | fileAPIs.set(sessionId, deferred); 78 | const missingMessage: PlaygroundMessage = {type: MISSING_FILE_API}; 79 | client.postMessage(missingMessage); 80 | return deferred.promise; 81 | }; 82 | 83 | const getFile = async (_e: FetchEvent, path: string, sessionId: SessionID) => { 84 | const fileAPI = await getFileApi(sessionId); 85 | if (fileAPI === undefined) { 86 | return new Response('Playground project not available', { 87 | status: /* Service Unavailable */ 503, 88 | }); 89 | } 90 | const fileOrError = await fileAPI.getFile(path); 91 | if ('status' in fileOrError) { 92 | const {body, status} = fileOrError; 93 | return new Response(body, {status}); 94 | } 95 | const {content, contentType} = fileOrError; 96 | const headers = new Headers(); 97 | // By default, a browser is only able to allocate a separate process or thread 98 | // for the Playground preview iframe if it is hosted on a different _site_ 99 | // (protocol + top-level domain) from the parent window. For example, lit.dev 100 | // and playground.lit.dev are the same site, but different origins. By setting 101 | // this Origin-Agent-Cluster header, we additionally allow process isolation 102 | // for different origins even if they are same-site. 103 | // 104 | // Note that _all_ responses from the sandbox origin must include this header 105 | // in order isolation to be possible, because the browser persists the setting 106 | // based on the first response it gets from that origin, so users will also 107 | // need to configure their HTTP server's response headers, since this line 108 | // only affects responses handled by the the service worker, and not e.g. the 109 | // service worker script itself. 110 | // 111 | // See: 112 | // https://web.dev/origin-agent-cluster/ 113 | // https://html.spec.whatwg.org/multipage/origin.html#origin-keyed-agent-clusters 114 | headers.set('Origin-Agent-Cluster', '?1'); 115 | if (contentType) { 116 | headers.set('Content-Type', contentType); 117 | } 118 | return new Response(content, {headers}); 119 | }; 120 | 121 | const onFetch = (e: FetchEvent) => { 122 | const url = e.request.url; 123 | if (url.startsWith(self.registration.scope)) { 124 | const {filePath, sessionId} = parseScopedUrl(url); 125 | if (sessionId !== undefined) { 126 | e.respondWith(getFile(e, filePath!, sessionId)); 127 | } 128 | } 129 | }; 130 | 131 | const parseScopedUrl = (url: string) => { 132 | const scope = self.registration.scope; 133 | // URLs in scope will be of the form: {scope}{sessionId}/{filePath}. Scope is 134 | // always a full URL prefix, including a trailing slash. Strip query params or 135 | // else the filename won't match. 136 | const sessionAndPath = url.substring(scope.length).split('?')[0]; 137 | const slashIndex = sessionAndPath.indexOf('/'); 138 | let sessionId, filePath: string | undefined; 139 | if (slashIndex === -1) { 140 | console.warn(`Invalid sample file URL: ${url}`); 141 | } else { 142 | sessionId = sessionAndPath.slice(0, slashIndex); 143 | filePath = sessionAndPath.slice(slashIndex + 1); 144 | } 145 | return { 146 | sessionId, 147 | filePath, 148 | }; 149 | }; 150 | 151 | const onInstall = () => { 152 | // Force this service worker to become the active service worker, in case 153 | // it's an updated worker and waiting. 154 | void self.skipWaiting(); 155 | }; 156 | 157 | const onActivate = (event: ExtendableEvent) => { 158 | // Make sure active clients use this service worker instance without being 159 | // reloaded. 160 | event.waitUntil(self.clients.claim()); 161 | }; 162 | 163 | const onMessage = ( 164 | e: Omit & {data: PlaygroundMessage} 165 | ) => { 166 | // Receive a handshake message from a page and setup Comlink. 167 | if (e.data.type === CONNECT_SW_TO_PROJECT) { 168 | const ack: PlaygroundMessage = { 169 | type: ACKNOWLEDGE_SW_CONNECTION, 170 | version: serviceWorkerHash, 171 | }; 172 | e.data.port.postMessage(ack); 173 | expose(workerAPI, e.data.port); 174 | } 175 | }; 176 | 177 | self.addEventListener('fetch', onFetch); 178 | self.addEventListener('activate', onActivate); 179 | self.addEventListener('install', onInstall); 180 | self.addEventListener('message', onMessage); 181 | -------------------------------------------------------------------------------- /src/service-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "../../service-worker", 6 | "rootDir": ".", 7 | "tsBuildInfoFile": "../../service-worker/.tsbuildinfo", 8 | "lib": ["ES2017", "WebWorker"], 9 | "skipLibCheck": true 10 | }, 11 | "include": ["*.ts"], 12 | "exclude": [], 13 | "references": [ 14 | { 15 | "path": "../shared" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/completion-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import Fuse from 'fuse.js'; 8 | import {CompletionInfo} from 'typescript'; 9 | import { 10 | EditorCompletion, 11 | EditorCompletionMatch, 12 | CompletionEntryWithDetails, 13 | CompletionInfoWithDetails, 14 | EditorCompletionDetails, 15 | } from './worker-api.js'; 16 | 17 | export function sortCompletionItems( 18 | completions: CompletionEntryWithDetails[] | undefined, 19 | searchWord: string 20 | ): EditorCompletion[] { 21 | if (!completions) return []; 22 | // If the user input a letter or a partial word, we want to offer 23 | // the closest matches first, and the weaker matches after. We will use 24 | // Fuse to score our completions by their fuzzy matches. 25 | // See https://fusejs.io/api/options.html 26 | const fuse = new Fuse(completions ?? [], { 27 | // Keep the threshold a bit lower than the default 28 | // so that the matching isn't too forgiving/confusing, but so 29 | // that a small typo doesn't delete all of the matches 30 | threshold: 0.3, 31 | shouldSort: true, 32 | isCaseSensitive: true, 33 | includeScore: true, 34 | includeMatches: true, 35 | keys: ['name'], 36 | // Match characters so that at least most of the word matches 37 | minMatchCharLength: Math.max(searchWord.length / 1.2, 1), 38 | }); 39 | const relevantCompletions = fuse.search(searchWord); 40 | 41 | const editorCompletions: EditorCompletion[] = relevantCompletions 42 | // Map the relevant info from fuse scoring 43 | .map( 44 | (item) => 45 | ({ 46 | text: item.item.name, 47 | displayText: item.item.name, 48 | score: item.score ?? 0, 49 | matches: item.matches as EditorCompletionMatch[], 50 | get details() { 51 | return item.item.details; 52 | }, 53 | } as EditorCompletion) 54 | ) 55 | // Sort the completions by how well they matched the given keyword 56 | .sort((a, b) => { 57 | if (a.score === b.score) { 58 | return a.text.localeCompare(b.text); 59 | } 60 | return a.score - b.score; 61 | }); 62 | 63 | return editorCompletions; 64 | } 65 | 66 | export function completionEntriesAsEditorCompletions( 67 | completions: CompletionEntryWithDetails[] | undefined, 68 | prefix = '' 69 | ): EditorCompletion[] { 70 | return ( 71 | completions?.map( 72 | (comp) => 73 | ({ 74 | // Since the completion engine will only append the word 75 | // given as the text property here, auto-completing from a period 76 | // would replace the period with the word. This is why we need 77 | // to append the period into the text property. This is not visible to the 78 | // user however, so no harm is done. 79 | text: prefix + comp.name, 80 | displayText: comp.name, 81 | score: Number.parseInt(comp.sortText), 82 | get details() { 83 | return comp.details; 84 | }, 85 | } as EditorCompletion) 86 | ) ?? [] 87 | ); 88 | } 89 | 90 | /** 91 | * Create a array of completion entries with a details fetching 92 | * function built in, so that the code editor can use it to fetch 93 | * the details when needed itself, instead of having to ask the project 94 | * layer for them. 95 | */ 96 | export function populateCompletionInfoWithDetailGetters( 97 | completionInfo: CompletionInfo, 98 | filename: string, 99 | cursorIndex: number, 100 | getCompletionDetailsFunction: ( 101 | filename: string, 102 | cursorIndex: number, 103 | completionWord: string 104 | ) => Promise 105 | ) { 106 | const completionInfoWithDetails = completionInfo as CompletionInfoWithDetails; 107 | completionInfoWithDetails.entries = completionInfo?.entries.map( 108 | (entry) => 109 | ({ 110 | ...entry, 111 | // Details are fetched using a proxy pattern, in which the details 112 | // are not instantiated until requested for. When asking for details 113 | // from the completion item, the getter is called, launching the 114 | // query if needed. 115 | _details: undefined, 116 | get details() { 117 | if (!this._details) { 118 | this._details = getCompletionDetailsFunction( 119 | filename, 120 | cursorIndex, 121 | entry.name 122 | ); 123 | } 124 | return this._details; 125 | }, 126 | } as CompletionEntryWithDetails) 127 | ); 128 | return completionInfoWithDetails; 129 | } 130 | -------------------------------------------------------------------------------- /src/shared/deferred.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | export class Deferred { 8 | readonly promise: Promise; 9 | private _resolve!: (value: T) => void; 10 | private _reject!: (reason?: unknown) => void; 11 | settled = false; 12 | 13 | constructor() { 14 | this.promise = new Promise((resolve, reject) => { 15 | this._resolve = resolve; 16 | this._reject = reject; 17 | }); 18 | } 19 | 20 | resolve(value: T) { 21 | this.settled = true; 22 | this._resolve(value); 23 | } 24 | 25 | reject(reason: unknown) { 26 | this.settled = true; 27 | this._reject(reason); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "../../shared", 6 | "rootDir": ".", 7 | "tsBuildInfoFile": "../../shared/.tsbuildinfo", 8 | "lib": ["ES2017", "WebWorker"], 9 | "skipLibCheck": true 10 | }, 11 | "include": ["*.ts"], 12 | "exclude": [] 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | export const endWithSlash = (s: string) => (s.endsWith('/') ? s : s + '/'); 8 | 9 | export const getRandomString = () => 10 | crypto.getRandomValues(new Uint32Array(1))[0].toString(32); 11 | 12 | /** 13 | * If the given URL object is a Skypack URL, perform an in-place update that 14 | * switches from optimized mode to raw mode. 15 | * 16 | * See https://github.com/google/playground-elements/issues/107 17 | */ 18 | export const forceSkypackRawMode = (url: URL): URL => { 19 | if (url.hostname === 'cdn.skypack.dev') { 20 | url.pathname = url.pathname.replace( 21 | /mode=imports\/(un)?optimized/, 22 | 'mode=raw' 23 | ); 24 | } 25 | return url; 26 | }; 27 | 28 | export type Result = 29 | | {result: V; error?: undefined} 30 | | {result?: undefined; error: E}; 31 | -------------------------------------------------------------------------------- /src/shared/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | // DO NOT UPDATE MANUALLY. 8 | 9 | // This file is automatically generated by scripts/update-version-module.js 10 | // before publishing. 11 | export const npmVersion = '0.20.0'; 12 | export const serviceWorkerHash = '09cd6a4e'; 13 | -------------------------------------------------------------------------------- /src/shared/worker-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {CompletionEntry, CompletionInfo, WithMetadata} from 'typescript'; 8 | import {Diagnostic} from 'vscode-languageserver-protocol'; 9 | 10 | /** 11 | * Sent from the project to the proxy, with configuration and a port for further 12 | * messages. 13 | */ 14 | export const CONFIGURE_PROXY = 1; 15 | 16 | /** 17 | * Sent from the proxy to the service worker, with a port that will be connected 18 | * to the project. 19 | */ 20 | export const CONNECT_SW_TO_PROJECT = 2; 21 | 22 | /** 23 | * Sent from the proxy to the project, with a port that will be connected to the 24 | * service worker. 25 | */ 26 | export const CONNECT_PROJECT_TO_SW = 3; 27 | 28 | /** 29 | * Sent from the service worker to the project, to confirm that the port was 30 | * received. 31 | */ 32 | export const ACKNOWLEDGE_SW_CONNECTION = 4; 33 | 34 | /** 35 | * Sent from the service worker to the proxy, to notify when a file API is 36 | * missing and hence a re-connection is probably required. 37 | */ 38 | export const MISSING_FILE_API = 5; 39 | 40 | /** 41 | * Sent from the project to the service worker proxy when there is a version 42 | * mismatch to request a call to ServiceWorkerRegistration.update(). 43 | */ 44 | export const UPDATE_SERVICE_WORKER = 6; 45 | 46 | export type PlaygroundMessage = 47 | | { 48 | type: typeof CONFIGURE_PROXY; 49 | scope: string; 50 | port: MessagePort; 51 | } 52 | | { 53 | type: typeof CONNECT_SW_TO_PROJECT; 54 | port: MessagePort; 55 | } 56 | | { 57 | type: typeof CONNECT_PROJECT_TO_SW; 58 | port: MessagePort; 59 | } 60 | | { 61 | type: typeof ACKNOWLEDGE_SW_CONNECTION; 62 | version: string; 63 | } 64 | | { 65 | type: typeof MISSING_FILE_API; 66 | } 67 | | { 68 | type: typeof UPDATE_SERVICE_WORKER; 69 | }; 70 | 71 | export interface ServiceWorkerAPI { 72 | setFileAPI(fileAPI: FileAPI, sessionID: string): void; 73 | } 74 | 75 | export interface WorkerConfig { 76 | importMap: ModuleImportMap; 77 | cdnBaseUrl: string; 78 | } 79 | 80 | export interface EditorToken { 81 | /** The character (on the given line) at which the token starts. */ 82 | start: number; 83 | /** The character at which the token ends. */ 84 | end: number; 85 | /** Code string under the cursor. */ 86 | string: string; 87 | } 88 | 89 | export interface EditorPosition { 90 | ch: number; 91 | line: number; 92 | } 93 | 94 | type RangeTuple = [number, number]; 95 | 96 | export type EditorCompletionMatch = { 97 | indices: ReadonlyArray; 98 | }; 99 | 100 | export interface EditorCompletion { 101 | text: string; 102 | displayText: string; 103 | score: number; 104 | matches?: EditorCompletionMatch[]; 105 | details: Promise; 106 | } 107 | 108 | export interface EditorTagInfo { 109 | name: string; 110 | text?: EditorTag[]; 111 | } 112 | 113 | export interface EditorTag { 114 | text: string; 115 | kind: string; 116 | } 117 | 118 | export interface EditorCompletionDetails { 119 | text: string; 120 | tags: EditorTagInfo[]; 121 | documentation: string[]; 122 | } 123 | 124 | export interface WorkerAPI { 125 | compileProject( 126 | files: Array, 127 | config: WorkerConfig, 128 | emit: (result: BuildOutput) => void 129 | ): Promise; 130 | getCompletions( 131 | filename: string, 132 | fileContent: string, 133 | tokenUnderCursor: string, 134 | cursorIndex: number, 135 | config: WorkerConfig 136 | ): Promise | undefined>; 137 | getCompletionItemDetails( 138 | filename: string, 139 | cursorIndex: number, 140 | config: WorkerConfig, 141 | completionWord: string 142 | ): Promise; 143 | } 144 | 145 | export interface HttpError { 146 | status: number; 147 | body: string; 148 | } 149 | 150 | export interface FileAPI { 151 | getFile(name: string): Promise; 152 | } 153 | 154 | export interface SampleFile { 155 | /** Filename. */ 156 | name: string; 157 | /** Optional display label. */ 158 | label?: string; 159 | /** File contents. */ 160 | content: string; 161 | /** MIME type. */ 162 | contentType?: string; 163 | /** Don't display in tab bar. */ 164 | hidden?: boolean; 165 | /** Whether the file should be selected when loaded */ 166 | selected?: boolean; 167 | } 168 | 169 | export interface FileOptions { 170 | /** Optional file content. If omitted, files are fetched by name. */ 171 | content?: string; 172 | /** 173 | * Optional content MIME type. If omitted, type is taken from fetch 174 | * Content-Type header if available, otherwise inferred from filename. 175 | */ 176 | contentType?: string; 177 | /** Optional display label. */ 178 | label?: string; 179 | /** Don't display in tab bar. */ 180 | hidden?: boolean; 181 | /** Whether the file should be selected when loaded */ 182 | selected?: boolean; 183 | } 184 | 185 | export interface ProjectManifest { 186 | /** Optional project manifest URL to extend from */ 187 | extends?: string; 188 | files?: {[filename: string]: FileOptions}; 189 | importMap?: ModuleImportMap; 190 | cdnBaseUrl?: string; 191 | } 192 | 193 | export interface ModuleImportMap { 194 | imports?: {[name: string]: string}; 195 | // No scopes for now. 196 | } 197 | 198 | export interface CodeEditorChangeData { 199 | isRefinement: boolean; 200 | fileName: string; 201 | fileContent: string; 202 | tokenUnderCursor: string; 203 | cursorIndex: number; 204 | provideCompletions: (completions: EditorCompletion[]) => void; 205 | } 206 | 207 | export interface CompletionEntryWithDetails extends CompletionEntry { 208 | _details: undefined | Promise; 209 | details: Promise; 210 | } 211 | 212 | export interface CompletionInfoWithDetails 213 | extends WithMetadata { 214 | entries: CompletionEntryWithDetails[]; 215 | } 216 | 217 | export type BuildOutput = FileBuildOutput | DiagnosticBuildOutput | DoneOutput; 218 | 219 | export type FileBuildOutput = { 220 | kind: 'file'; 221 | file: SampleFile; 222 | }; 223 | 224 | export type DiagnosticBuildOutput = { 225 | kind: 'diagnostic'; 226 | filename: string; 227 | diagnostic: Diagnostic; 228 | }; 229 | 230 | export type DoneOutput = { 231 | kind: 'done'; 232 | }; 233 | -------------------------------------------------------------------------------- /src/test/fake-cdn-plugin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {TestRunnerPlugin} from '@web/test-runner-core/dist/server/TestRunnerPlugin.js'; 8 | import semver from 'semver'; 9 | 10 | /** 11 | * A plugin for @web/test-runner that simulates an NPM CDN like unpkg.com. It 12 | * extends the built-in @web/test-runner server, and can be re-configured from 13 | * the test suite itself between each test using a server command 14 | * (https://modern-web.dev/docs/test-runner/commands/). 15 | * 16 | * Usage: 17 | * 18 | * import {executeServerCommand} from '@web/test-runner-commands'; 19 | * 20 | * const cdnData = { 21 | * "foo": { 22 | * "versions": { 23 | * "1.2.3": { 24 | * "files": { 25 | * "package.json": { 26 | * "content": `{ 27 | * "main": "lib/index.js" 28 | * }` 29 | * }, 30 | * "lib/index.js": { 31 | * "content": "console.log('hello');" 32 | * }, 33 | * } 34 | * } 35 | * } 36 | * } 37 | * }; 38 | * const {cdnBaseUrl, id} = await executeServerCommand('set-fake-cdn-data', cdnData); 39 | * // Redirects to /foo@1.2.3/lib/index.js and serves its content. 40 | * const result = await fetch(new URL("foo@^1.0.0", cdnBaseUrl).href); 41 | * await executeServerCommand('delete-fake-cdn-data', id); 42 | */ 43 | export function fakeCdnPlugin(): TestRunnerPlugin { 44 | const pathPrefix = '/fake-cdn/'; 45 | let baseUrl: string | undefined; 46 | const dataMap = new Map(); 47 | let _nextId = 0; 48 | 49 | return { 50 | name: 'fake-cdn', 51 | 52 | serverStart(args) { 53 | baseUrl = `http://${args.config.hostname}:${args.config.port}${pathPrefix}`; 54 | }, 55 | 56 | executeCommand({command, payload}) { 57 | if (command === 'set-fake-cdn-data') { 58 | // Create a separate data store for each configuration, so that we can 59 | // support concurrent tests. 60 | const id = String(_nextId++); 61 | dataMap.set(id, payload as CdnData); 62 | return { 63 | id, 64 | cdnBaseUrl: `${baseUrl}${id}/`, 65 | }; 66 | } else if (command === 'delete-fake-cdn-data') { 67 | // Allow deleting test data so that memory doesn't grow unbounded, 68 | // especially for when we're in a long running --watch mode session. 69 | const id = payload as string; 70 | dataMap.delete(id); 71 | return true; 72 | } 73 | return null; 74 | }, 75 | 76 | serve(ctx) { 77 | if (!ctx.path.startsWith(pathPrefix)) { 78 | return undefined; 79 | } 80 | const urlMatch = ctx.path.slice(pathPrefix.length).match(/^(\d+)\/(.*)/); 81 | if (urlMatch === null) { 82 | ctx.response.status = 404; 83 | return undefined; 84 | } 85 | const id = urlMatch[1]; 86 | const data = dataMap.get(id); 87 | if (data === undefined) { 88 | ctx.response.status = 404; 89 | return undefined; 90 | } 91 | const specifier = decodeURIComponent(urlMatch[2]); 92 | const parsed = parseNpmModuleSpecifier(specifier); 93 | if (!parsed) { 94 | ctx.response.status = 404; 95 | return undefined; 96 | } 97 | const {pkg, semverRangeOrTag, path} = parsed; 98 | const packageData = data[pkg]; 99 | if (!packageData) { 100 | ctx.response.status = 404; 101 | return undefined; 102 | } 103 | // Note we don't support tags other than "latest" for now, but we could 104 | // easily add that to CdnData if needed. 105 | const semverRange = 106 | semverRangeOrTag === '' || semverRangeOrTag === 'latest' 107 | ? '*' 108 | : semverRangeOrTag; 109 | const versions = Object.keys(packageData.versions); 110 | const version = semver.maxSatisfying(versions, semverRange); 111 | if (!version) { 112 | ctx.response.status = 404; 113 | return undefined; 114 | } 115 | if (version !== semverRange) { 116 | // The version in the URL was a range, rather than a concrete version. 117 | // Redirect to the concrete version's URL. Note that redirecting here, 118 | // rather than just serving the resolved file directly, is an important 119 | // behavior of unpkg that we want to preserve in tests, because we rely 120 | // on being able to extract resolved versions from redirect URLs. 121 | ctx.response.status = 302; 122 | ctx.response.redirect(`${pathPrefix}${id}/${pkg}@${version}/${path}`); 123 | return undefined; 124 | } 125 | const versionData = packageData.versions[version]; 126 | if (!versionData) { 127 | ctx.response.status = 404; 128 | return undefined; 129 | } 130 | if (path === '') { 131 | const packageJson = JSON.parse( 132 | versionData.files['package.json']?.content ?? '{}' 133 | ) as {main?: string}; 134 | // Only look at the "main" field, not "module", because that's how 135 | // unpkg.com works when it's not in ?module mode, which is how we're 136 | // using it (so that we get raw bare module specifiers). 137 | const main = packageJson.main ?? 'index.js'; 138 | ctx.response.status = 302; 139 | ctx.response.redirect(`${pathPrefix}${id}/${pkg}@${version}/${main}`); 140 | return undefined; 141 | } 142 | const file = versionData.files[path]; 143 | if (file !== undefined) { 144 | ctx.response.status = file.status ?? 200; 145 | ctx.response.body = file.content; 146 | ctx.response.type = path.endsWith('.js') 147 | ? 'text/javascript' 148 | : path.endsWith('.json') 149 | ? 'application/json' 150 | : 'text/plain'; 151 | return undefined; 152 | } 153 | if (path === 'package.json') { 154 | // You can't publish to NPM without a package.json; for convenience in 155 | // testing, just make an empty one if the test data didn't contain one. 156 | return { 157 | body: '{}', 158 | type: 'application/json', 159 | }; 160 | } 161 | if (versionData.files[path + '.js']) { 162 | ctx.response.status = 302; 163 | ctx.response.redirect(ctx.path + '.js'); 164 | return undefined; 165 | } 166 | ctx.response.status = 404; 167 | return undefined; 168 | }, 169 | }; 170 | } 171 | 172 | export type CdnData = { 173 | [pkg: string]: { 174 | versions: { 175 | [version: string]: { 176 | files: { 177 | [path: string]: { 178 | content: string; 179 | status?: number; 180 | }; 181 | }; 182 | }; 183 | }; 184 | }; 185 | }; 186 | 187 | const parseNpmModuleSpecifier = ( 188 | specifier: string 189 | ): {pkg: string; semverRangeOrTag: string; path: string} | undefined => { 190 | const match = specifier.match(/^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/?(.*)$/); 191 | if (match === null) { 192 | return undefined; 193 | } 194 | const [, pkg, semverRangeOrTag, path] = match; 195 | return {pkg, semverRangeOrTag, path}; 196 | }; 197 | -------------------------------------------------------------------------------- /src/test/node-module-resolver_test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {assert} from '@esm-bundle/chai'; 8 | import {NodeModuleResolver} from '../typescript-worker/node-module-resolver.js'; 9 | 10 | import {PackageJson} from '../typescript-worker/util.js'; 11 | 12 | suite('NodeModuleResolver', () => { 13 | const check = ({ 14 | path, 15 | packageJson, 16 | conditions = ['default', 'module', 'import'], 17 | expected, 18 | }: { 19 | path: string; 20 | packageJson: PackageJson; 21 | conditions?: string[]; 22 | expected: string; 23 | }) => { 24 | const resolver = new NodeModuleResolver({conditions}); 25 | const actual = resolver.resolve( 26 | {pkg: 'mydep', version: '1.2.3', path}, 27 | packageJson, 28 | './my-element.js' 29 | ); 30 | assert.equal(actual, expected); 31 | }; 32 | 33 | test('default with path', () => { 34 | check({ 35 | path: 'foo.js', 36 | packageJson: {}, 37 | expected: 'foo.js', 38 | }); 39 | }); 40 | 41 | test('default no path', () => { 42 | check({ 43 | path: '', 44 | packageJson: {}, 45 | expected: 'index.js', 46 | }); 47 | }); 48 | 49 | test('default with main', () => { 50 | check({ 51 | path: '', 52 | packageJson: { 53 | main: 'main.js', 54 | }, 55 | expected: 'main.js', 56 | }); 57 | }); 58 | 59 | test('exports default module', () => { 60 | check({ 61 | path: '', 62 | packageJson: { 63 | exports: { 64 | '.': './main.js', 65 | }, 66 | }, 67 | expected: 'main.js', 68 | }); 69 | }); 70 | 71 | test('exports main sugar', () => { 72 | check({ 73 | path: '', 74 | packageJson: { 75 | exports: './main.js', 76 | }, 77 | expected: 'main.js', 78 | }); 79 | }); 80 | 81 | test('exports remapped module', () => { 82 | check({ 83 | path: 'foo.js', 84 | packageJson: { 85 | exports: { 86 | './foo.js': './lib/foo.js', 87 | }, 88 | }, 89 | expected: 'lib/foo.js', 90 | }); 91 | }); 92 | 93 | test('throws if exports but no mapping', () => { 94 | assert.throws(() => { 95 | check({ 96 | path: 'foo.js', 97 | packageJson: { 98 | exports: { 99 | './bar.js': './lib/bar.js', 100 | }, 101 | }, 102 | expected: 'Package subpath', 103 | }); 104 | }, 'Package subpath \'./foo.js\' is not defined by "exports" in /node_modules/mydep@1.2.3/package.json imported from ./my-element.js'); 105 | }); 106 | 107 | test("throws if target doesn't start with ./", () => { 108 | assert.throws(() => { 109 | check({ 110 | path: 'foo.js', 111 | packageJson: { 112 | exports: { 113 | './foo.js': 'dist/foo.js', 114 | }, 115 | }, 116 | expected: '', 117 | }); 118 | }, 'Invalid "exports" target "dist/foo.js" defined for \'./foo.js\' in the package config /node_modules/mydep@1.2.3/package.json imported from ./my-element.js; targets must start with "./"'); 119 | }); 120 | 121 | test('subpath export', () => { 122 | check({ 123 | path: 'features/foo.js', 124 | packageJson: { 125 | exports: { 126 | './features/*': './src/features/*', 127 | }, 128 | }, 129 | expected: 'src/features/foo.js', 130 | }); 131 | }); 132 | 133 | test('banned subpath export', () => { 134 | assert.throws(() => { 135 | check({ 136 | path: 'features/private/foo.js', 137 | packageJson: { 138 | exports: { 139 | './features/*': './src/features/*', 140 | './features/private/*': null, 141 | }, 142 | }, 143 | expected: '', 144 | }); 145 | }, 'Package subpath \'./features/private/foo.js\' is not defined by "exports" in /node_modules/mydep@1.2.3/package.json imported from ./my-element.js'); 146 | }); 147 | 148 | test('conditions', () => { 149 | check({ 150 | path: '', 151 | packageJson: { 152 | exports: { 153 | '.': { 154 | import: './main.js', 155 | require: './main.cjs', 156 | }, 157 | }, 158 | }, 159 | expected: 'main.js', 160 | }); 161 | }); 162 | 163 | test('conditions with main sugar', () => { 164 | check({ 165 | path: '', 166 | packageJson: { 167 | exports: { 168 | import: './main.js', 169 | require: './main.cjs', 170 | }, 171 | }, 172 | expected: 'main.js', 173 | }); 174 | }); 175 | 176 | test('condition precedence', () => { 177 | check({ 178 | path: '', 179 | packageJson: { 180 | exports: { 181 | '.': { 182 | development: './dev.js', 183 | default: './prod.js', 184 | }, 185 | }, 186 | }, 187 | conditions: ['development'], 188 | expected: 'dev.js', 189 | }); 190 | }); 191 | 192 | test('nested conditions', () => { 193 | const path = ''; 194 | const packageJson = { 195 | exports: { 196 | node: { 197 | import: './feature-node.mjs', 198 | require: './feature-node.cjs', 199 | }, 200 | default: './feature.mjs', 201 | }, 202 | }; 203 | check({ 204 | path, 205 | packageJson, 206 | conditions: ['browser'], 207 | expected: 'feature.mjs', 208 | }); 209 | check({ 210 | path, 211 | packageJson, 212 | conditions: ['node', 'import'], 213 | expected: 'feature-node.mjs', 214 | }); 215 | check({ 216 | path, 217 | packageJson, 218 | conditions: ['node', 'require'], 219 | expected: 'feature-node.cjs', 220 | }); 221 | }); 222 | 223 | test('target array, first is valid', () => { 224 | check({ 225 | path: '', 226 | packageJson: { 227 | exports: { 228 | '.': ['./main.js', 'INVALID'], 229 | }, 230 | }, 231 | expected: 'main.js', 232 | }); 233 | }); 234 | 235 | test('target array, second is valid', () => { 236 | check({ 237 | path: '', 238 | packageJson: { 239 | exports: { 240 | '.': ['INVALID', './main.js'], 241 | }, 242 | }, 243 | expected: 'main.js', 244 | }); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /src/test/node-modules-layout-maker_test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {assert} from '@esm-bundle/chai'; 8 | import { 9 | NodeModulesLayoutMaker, 10 | DependencyGraph, 11 | NodeModulesDirectory, 12 | PackageDependencies, 13 | } from '../typescript-worker/node-modules-layout-maker.js'; 14 | 15 | suite('NodeModulesLayoutMaker', () => { 16 | const checkLayout = ( 17 | rootDeps: PackageDependencies, 18 | depGraph: DependencyGraph, 19 | expected: NodeModulesDirectory 20 | ) => { 21 | const layoutMaker = new NodeModulesLayoutMaker(); 22 | const actual = layoutMaker.layout(rootDeps, depGraph); 23 | assert.deepEqual(actual, expected); 24 | }; 25 | 26 | test('no dependencies', () => { 27 | // ROOT 28 | const rootDeps: PackageDependencies = {}; 29 | const depGraph: DependencyGraph = {}; 30 | const expected: NodeModulesDirectory = {}; 31 | checkLayout(rootDeps, depGraph, expected); 32 | }); 33 | 34 | test('only direct dependencies', () => { 35 | // ROOT 36 | // /\ 37 | // / \ 38 | // v v 39 | // A1 B2 40 | const rootDeps: PackageDependencies = { 41 | a: '1', 42 | b: '2', 43 | }; 44 | const depGraph: DependencyGraph = {}; 45 | // ROOT 46 | // ├── A1 47 | // └── B2 48 | const expected: NodeModulesDirectory = { 49 | a: {version: '1', nodeModules: {}}, 50 | b: {version: '2', nodeModules: {}}, 51 | }; 52 | checkLayout(rootDeps, depGraph, expected); 53 | }); 54 | 55 | test('simple chain', () => { 56 | // ROOT 57 | // | 58 | // v 59 | // A1 60 | // | 61 | // v 62 | // B2 63 | // | 64 | // v 65 | // C3 66 | const rootDeps: PackageDependencies = { 67 | a: '1', 68 | }; 69 | const depGraph: DependencyGraph = { 70 | a: {1: {b: '2'}}, 71 | b: {2: {c: '3'}}, 72 | }; 73 | // ROOT 74 | // ├── A1 75 | // ├── B2 76 | // └── C3 77 | const expected: NodeModulesDirectory = { 78 | a: {version: '1', nodeModules: {}}, 79 | b: {version: '2', nodeModules: {}}, 80 | c: {version: '3', nodeModules: {}}, 81 | }; 82 | checkLayout(rootDeps, depGraph, expected); 83 | }); 84 | 85 | test('version conflict between root and branch', () => { 86 | // ROOT 87 | // /\ 88 | // v v 89 | // A1 B1 90 | // | 91 | // v 92 | // B2 93 | const rootDeps: PackageDependencies = { 94 | a: '1', 95 | b: '1', 96 | }; 97 | const depGraph: DependencyGraph = { 98 | a: {1: {b: '2'}}, 99 | }; 100 | // ROOT 101 | // ├── A1 102 | // │ └── B2 103 | // └── B1 104 | const expected: NodeModulesDirectory = { 105 | a: { 106 | version: '1', 107 | nodeModules: { 108 | b: {version: '2', nodeModules: {}}, 109 | }, 110 | }, 111 | b: {version: '1', nodeModules: {}}, 112 | }; 113 | checkLayout(rootDeps, depGraph, expected); 114 | }); 115 | 116 | test('version conflict between root and longer branch', () => { 117 | // ROOT 118 | // /\ 119 | // / \ 120 | // v v 121 | // A1 B1 122 | // | 123 | // v 124 | // C1 125 | // | 126 | // v 127 | // B2 128 | const rootDeps: PackageDependencies = { 129 | a: '1', 130 | b: '1', 131 | }; 132 | const depGraph: DependencyGraph = { 133 | a: {1: {c: '1'}}, 134 | c: {1: {b: '2'}}, 135 | }; 136 | // ROOT 137 | // ├── A1 138 | // ├── B1 139 | // └── C1 140 | // └── B2 141 | const expected: NodeModulesDirectory = { 142 | a: {version: '1', nodeModules: {}}, 143 | b: {version: '1', nodeModules: {}}, 144 | c: { 145 | version: '1', 146 | nodeModules: { 147 | b: { 148 | version: '2', 149 | nodeModules: {}, 150 | }, 151 | }, 152 | }, 153 | }; 154 | checkLayout(rootDeps, depGraph, expected); 155 | }); 156 | 157 | test('version conflict between two branches', () => { 158 | // ROOT 159 | // /\ 160 | // / \ 161 | // v v 162 | // A1 B1 163 | // | | 164 | // v v 165 | // C1 C2 166 | const rootDeps: PackageDependencies = { 167 | a: '1', 168 | b: '1', 169 | }; 170 | const depGraph: DependencyGraph = { 171 | a: {1: {c: '1'}}, 172 | b: {1: {c: '2'}}, 173 | }; 174 | // ROOT 175 | // ├── A1 176 | // ├── B1 177 | // │ └── C2 178 | // └── C1 179 | const expected: NodeModulesDirectory = { 180 | a: {version: '1', nodeModules: {}}, 181 | b: { 182 | version: '1', 183 | nodeModules: { 184 | c: {version: '2', nodeModules: {}}, 185 | }, 186 | }, 187 | c: {version: '1', nodeModules: {}}, 188 | }; 189 | checkLayout(rootDeps, depGraph, expected); 190 | }); 191 | 192 | test('version conflict requiring directory duplication', () => { 193 | // ROOT 194 | // /|\ 195 | // / | \ 196 | // / | \ 197 | // v v v 198 | // A1 B1 C1 199 | // \ / 200 | // \ / 201 | // v 202 | // A2 --> D1 203 | const rootDeps: PackageDependencies = { 204 | a: '1', 205 | b: '1', 206 | c: '1', 207 | }; 208 | const depGraph: DependencyGraph = { 209 | a: {2: {d: '1'}}, 210 | b: {1: {a: '2'}}, 211 | c: {1: {a: '2'}}, 212 | }; 213 | // ROOT 214 | // ├── A1 215 | // ├── B1 216 | // │ └── A2 217 | // ├── C1 218 | // │ └── A2 219 | // └── D1 220 | const expected: NodeModulesDirectory = { 221 | a: { 222 | version: '1', 223 | nodeModules: {}, 224 | }, 225 | b: { 226 | version: '1', 227 | nodeModules: { 228 | a: {version: '2', nodeModules: {}}, 229 | }, 230 | }, 231 | c: { 232 | version: '1', 233 | nodeModules: { 234 | a: {version: '2', nodeModules: {}}, 235 | }, 236 | }, 237 | d: { 238 | version: '1', 239 | nodeModules: {}, 240 | }, 241 | }; 242 | checkLayout(rootDeps, depGraph, expected); 243 | }); 244 | 245 | test('short loop', () => { 246 | // ROOT --> A1 --> B1 247 | // ^ | 248 | // | | 249 | // +-----+ 250 | const rootDeps: PackageDependencies = { 251 | a: '1', 252 | }; 253 | const depGraph: DependencyGraph = { 254 | a: {1: {b: '1'}}, 255 | b: {1: {a: '1'}}, 256 | }; 257 | // ROOT 258 | // ├── A1 259 | // └── B1 260 | const expected: NodeModulesDirectory = { 261 | a: {version: '1', nodeModules: {}}, 262 | b: {version: '1', nodeModules: {}}, 263 | }; 264 | checkLayout(rootDeps, depGraph, expected); 265 | }); 266 | 267 | test('longer loop on a branch', () => { 268 | // ROOT 269 | // /\ 270 | // / \ 271 | // v v 272 | // A1 C1 273 | // / ^ 274 | // / \ 275 | // v \ 276 | // B1 --> C2 277 | const rootDeps: PackageDependencies = { 278 | a: '1', 279 | c: '1', 280 | }; 281 | const depGraph: DependencyGraph = { 282 | a: {1: {b: '1'}}, 283 | b: {1: {c: '2'}}, 284 | c: {2: {a: '1'}}, 285 | }; 286 | // ROOT 287 | // ├── A1 288 | // ├── C1 289 | // └── B1 290 | // └── C2 291 | const expected: NodeModulesDirectory = { 292 | a: {version: '1', nodeModules: {}}, 293 | c: {version: '1', nodeModules: {}}, 294 | b: { 295 | version: '1', 296 | nodeModules: { 297 | c: {version: '2', nodeModules: {}}, 298 | }, 299 | }, 300 | }; 301 | checkLayout(rootDeps, depGraph, expected); 302 | }); 303 | }); 304 | -------------------------------------------------------------------------------- /src/test/worker-test-util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {assert} from '@esm-bundle/chai'; 8 | import {build} from '../typescript-worker/build.js'; 9 | import {executeServerCommand} from '@web/test-runner-commands'; 10 | 11 | import { 12 | BuildOutput, 13 | ModuleImportMap, 14 | SampleFile, 15 | } from '../shared/worker-api.js'; 16 | import {CdnData} from './fake-cdn-plugin.js'; 17 | 18 | export const configureFakeCdn = async ( 19 | data: CdnData 20 | ): Promise<{cdnBaseUrl: string; deleteCdnData: () => Promise}> => { 21 | const {cdnBaseUrl, id} = (await executeServerCommand( 22 | 'set-fake-cdn-data', 23 | data 24 | )) as {cdnBaseUrl: string; id: number}; 25 | const deleteCdnData = async () => { 26 | await executeServerCommand('delete-fake-cdn-data', id); 27 | }; 28 | return { 29 | cdnBaseUrl, 30 | deleteCdnData, 31 | }; 32 | }; 33 | 34 | export const checkTransform = async ( 35 | files: SampleFile[], 36 | expected: BuildOutput[], 37 | importMap: ModuleImportMap = {}, 38 | cdnData: CdnData = {} 39 | ) => { 40 | const {cdnBaseUrl, deleteCdnData} = await configureFakeCdn(cdnData); 41 | try { 42 | const results: BuildOutput[] = []; 43 | await new Promise((resolve) => { 44 | const emit = (result: BuildOutput) => { 45 | if (result.kind === 'done') { 46 | resolve(); 47 | } else { 48 | results.push(result); 49 | } 50 | }; 51 | build(files, {importMap, cdnBaseUrl}, emit); 52 | }); 53 | 54 | for (const result of results) { 55 | if (result.kind === 'diagnostic') { 56 | // Sometimes diagnostics contain a CDN URL to help with debugging 57 | // (usually the unpkg.com URL). But that will be a local dynamic URL in 58 | // testing, so we'll substitute a static string so that we can do a 59 | // simple equality test. 60 | while (result.diagnostic.message.includes(cdnBaseUrl)) { 61 | result.diagnostic.message = result.diagnostic.message.replace( 62 | cdnBaseUrl, 63 | '/' 64 | ); 65 | } 66 | } 67 | } 68 | 69 | assert.deepEqual( 70 | results.sort(sortBuildOutput), 71 | expected.sort(sortBuildOutput) 72 | ); 73 | } finally { 74 | await deleteCdnData(); 75 | } 76 | }; 77 | 78 | const sortBuildOutput = (a: BuildOutput, b: BuildOutput) => { 79 | if (a.kind === 'file' && b.kind === 'file') { 80 | return a.file.name.localeCompare(b.file.name); 81 | } 82 | if (a.kind === 'diagnostic' && b.kind === 'diagnostic') { 83 | return a.diagnostic.message.localeCompare(b.diagnostic.message); 84 | } 85 | return a.kind.localeCompare(b.kind); 86 | }; 87 | -------------------------------------------------------------------------------- /src/typescript-worker/build.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {BareModuleTransformer} from './bare-module-transformer.js'; 8 | 9 | import {SampleFile, BuildOutput, WorkerConfig} from '../shared/worker-api.js'; 10 | import {getWorkerContext} from './worker-context.js'; 11 | import {processTypeScriptFiles} from './typescript-builder.js'; 12 | 13 | export const build = async ( 14 | files: Array, 15 | config: WorkerConfig, 16 | emit: (result: BuildOutput) => void 17 | ): Promise => { 18 | const workerContext = getWorkerContext(config); 19 | const bareModuleBuilder = new BareModuleTransformer( 20 | workerContext.cdn, 21 | workerContext.importMapResolver 22 | ); 23 | 24 | const results = bareModuleBuilder.process( 25 | processTypeScriptFiles( 26 | workerContext, 27 | files.map((file) => ({kind: 'file', file})) 28 | ) 29 | ); 30 | for await (const result of results) { 31 | emit(result); 32 | } 33 | emit({kind: 'done'}); 34 | }; 35 | -------------------------------------------------------------------------------- /src/typescript-worker/caching-cdn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import { 8 | fileExtension, 9 | parseNpmStyleSpecifier, 10 | isExactSemverVersion, 11 | pkgVersion, 12 | pkgVersionPath, 13 | } from './util.js'; 14 | import {Deferred} from '../shared/deferred.js'; 15 | 16 | import {NpmFileLocation, PackageJson} from './util.js'; 17 | 18 | export interface CdnFile { 19 | content: string; 20 | contentType: string; 21 | } 22 | 23 | /** 24 | * An interface to unpkg.com or a similar NPM CDN service. 25 | */ 26 | export class CachingCdn { 27 | private readonly _cdnBaseUrl: string; 28 | 29 | /** 30 | * A cache for all fetch operations to avoid redundant network requests. 31 | * Maps from package-version-path to a promise that resolves with the URL and 32 | * file content. 33 | */ 34 | private readonly _fetchCache = new Map< 35 | string, 36 | Deferred<{url: string; file: CdnFile}> 37 | >(); 38 | 39 | /** 40 | * Maps from package + version ranges/tags to resolved semver versions. This 41 | * allows us to canonicalize more efficiently, because once we've resolved 42 | * e.g. "foo@^1.0.0/foo.js" to "foo@1.2.3/foo.js", we can then canonicalize 43 | * "foo@^1.0.0/bar.js" without another fetch. 44 | */ 45 | private readonly _versionCache = new Map(); 46 | 47 | /** 48 | * @param cdnBaseUrl Base URL for the CDN (e.g., 'https://unpkg.com') 49 | */ 50 | constructor(cdnBaseUrl: string) { 51 | this._cdnBaseUrl = cdnBaseUrl.endsWith('/') 52 | ? cdnBaseUrl.slice(0, -1) 53 | : cdnBaseUrl; 54 | } 55 | 56 | /** 57 | * Fetch a file from the CDN. 58 | */ 59 | async fetch(location: NpmFileLocation): Promise { 60 | const {file} = await this._fetch(location); 61 | return file; 62 | } 63 | 64 | /** 65 | * Resolve a package location to a CDN URL using the configured provider 66 | */ 67 | private _resolveCdnUrl(location: NpmFileLocation): string { 68 | // Construct the URL directly using the CDN base URL 69 | const packageSpec = `${location.pkg}@${location.version}`; 70 | 71 | const path = location.path ? `/${location.path}` : ''; 72 | 73 | return `${this._cdnBaseUrl}/${packageSpec}${path}`; 74 | } 75 | 76 | /** 77 | * Return a version of the given CDN file specifier where version ranges and 78 | * NPM tags are resolved to concrete semver versions, and ambiguous paths are 79 | * resolved to concrete ones. 80 | * 81 | * E.g. foo@^1.0.0 -> foo@1.2.3/index.js 82 | * 83 | * TODO(aomarks) Remove this method in favor of separate resolveVersion and 84 | * fileExists methods, so that the caller can fully control resolution. We 85 | * shouldn't rely on unpkg's redirection logic for resolving paths anymore, 86 | * because it doesn't follow Node package exports, which can arbitrary remap 87 | * paths. 88 | */ 89 | async canonicalize(location: NpmFileLocation): Promise { 90 | let exact = isExactSemverVersion(location.version); 91 | if (!exact) { 92 | const pv = pkgVersion(location); 93 | const resolved = this._versionCache.get(pv); 94 | if (resolved !== undefined) { 95 | location = {...location, version: resolved}; 96 | exact = true; 97 | } 98 | } 99 | if (!exact || fileExtension(location.path) === '') { 100 | const {url} = await this._fetch(location); 101 | location = this._parseCdnUrl(url)!; 102 | } 103 | return location; 104 | } 105 | 106 | /** 107 | * Resolve the concrete version of the given package and version range 108 | */ 109 | async resolveVersion({ 110 | pkg, 111 | version, 112 | }: { 113 | pkg: string; 114 | version: string; 115 | }): Promise { 116 | return (await this.canonicalize({pkg, version, path: 'package.json'})) 117 | .version; 118 | } 119 | 120 | /** 121 | * Fetch and parse a package's package.json file. 122 | */ 123 | async fetchPackageJson({ 124 | pkg, 125 | version, 126 | }: { 127 | pkg: string; 128 | version: string; 129 | }): Promise { 130 | const { 131 | url, 132 | file: {content}, 133 | } = await this._fetch({pkg, version, path: 'package.json'}); 134 | try { 135 | return JSON.parse(content) as PackageJson; 136 | } catch (e) { 137 | throw new Error(`Error parsing CDN package.json from ${url}: ${e}`); 138 | } 139 | } 140 | 141 | private async _fetch( 142 | location: NpmFileLocation 143 | ): Promise<{url: string; file: CdnFile}> { 144 | let exact = isExactSemverVersion(location.version); 145 | if (!exact) { 146 | const pv = pkgVersion(location); 147 | const resolved = this._versionCache.get(pv); 148 | if (resolved !== undefined) { 149 | location = {...location, version: resolved}; 150 | exact = true; 151 | } 152 | } 153 | const pvp = pkgVersionPath(location); 154 | const cached = this._fetchCache.get(pvp); 155 | if (cached !== undefined) { 156 | return cached.promise; 157 | } 158 | const deferred = new Deferred<{ 159 | url: string; 160 | file: CdnFile; 161 | }>(); 162 | this._fetchCache.set(pvp, deferred); 163 | const url = this._resolveCdnUrl(location); 164 | let res: Response; 165 | let content = ''; 166 | try { 167 | res = await fetch(url); 168 | content = await res.text(); 169 | 170 | if (res.status !== 200) { 171 | const errorMessage = `CDN HTTP ${res.status} error (${url}): ${content}`; 172 | console.warn(errorMessage); 173 | const err = new Error(errorMessage); 174 | deferred.reject(err); 175 | return deferred.promise; 176 | } 177 | } catch (e) { 178 | // Error usually happens if it's a 404 on the CDN causing the fetch to 179 | // throw a cors error 180 | console.warn( 181 | `Network error fetching ${url}: ${ 182 | e instanceof Error ? e.message : String(e) 183 | }` 184 | ); 185 | const err = new Error( 186 | `Failed to fetch from CDN (${url}): ${ 187 | e instanceof Error ? e.message : String(e) 188 | }` 189 | ); 190 | deferred.reject(err); 191 | return deferred.promise; 192 | } 193 | if (!exact) { 194 | const canonical = this._parseCdnUrl(res.url); 195 | if (canonical) { 196 | this._versionCache.set(pkgVersion(location), canonical.version); 197 | this._fetchCache.set(pkgVersionPath(canonical), deferred); 198 | } 199 | } 200 | 201 | const result = { 202 | url: res.url, 203 | file: { 204 | content, 205 | contentType: res.headers.get('content-type') ?? 'text/plain', 206 | }, 207 | }; 208 | deferred.resolve(result); 209 | return deferred.promise; 210 | } 211 | 212 | private _parseCdnUrl(url: string): NpmFileLocation | undefined { 213 | // Check if the URL starts with our CDN base URL 214 | if (!url.startsWith(this._cdnBaseUrl)) { 215 | const errorMessage = `Failed to parse CDN URL: ${url} - URL does not start with CDN base URL: ${this._cdnBaseUrl}`; 216 | console.warn(errorMessage); 217 | return undefined; 218 | } 219 | 220 | // Extract the path after the CDN base URL 221 | const path = url.slice(this._cdnBaseUrl.length + 1); // +1 for the trailing slash 222 | 223 | // Use the utility function to parse the path as an NPM-style specifier 224 | const parsed = parseNpmStyleSpecifier(path); 225 | 226 | if (parsed) { 227 | return parsed; 228 | } 229 | 230 | const errorMessage = `Failed to parse CDN URL: ${url} - could not extract package information`; 231 | console.warn(errorMessage); 232 | return undefined; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/typescript-worker/completions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import { 8 | CompletionInfo, 9 | GetCompletionsAtPositionOptions, 10 | SymbolDisplayPart, 11 | WithMetadata, 12 | } from 'typescript'; 13 | import {EditorCompletionDetails, WorkerConfig} from '../shared/worker-api.js'; 14 | import {getWorkerContext} from './worker-context.js'; 15 | 16 | /** 17 | * Query completions from the Language Service, and sort them by 18 | * relevance for user to use. 19 | */ 20 | export const queryCompletions = async ( 21 | filename: string, 22 | fileContent: string, 23 | tokenUnderCursor: string, 24 | cursorIndex: number, 25 | config: WorkerConfig 26 | ): Promise | undefined> => { 27 | const workerContext = getWorkerContext(config); 28 | 29 | const languageService = workerContext.languageServiceContext.service; 30 | const languageServiceHost = workerContext.languageServiceContext.serviceHost; 31 | const searchWordIsPeriod = tokenUnderCursor === '.'; 32 | 33 | const options = {} as GetCompletionsAtPositionOptions; 34 | if (searchWordIsPeriod) { 35 | options.triggerCharacter = '.'; 36 | } 37 | // Update language service status so that the file is up to date 38 | const fileAbsolutePath = new URL(filename, self.origin).href; 39 | // TODO: Could this cause a race condition between the build phase 40 | // and the completion phase, and could that be a problem? 41 | languageServiceHost.updateFileContentIfNeeded(fileAbsolutePath, fileContent); 42 | 43 | // Fetch the collection of completions, the language service offers us for our current context. 44 | // This list of completions is quite vast, and therefore we will need to do some extra sorting 45 | // and filtering on it before sending it back to the browser. 46 | const completions = languageService.getCompletionsAtPosition( 47 | filename, 48 | cursorIndex, 49 | options 50 | ); 51 | 52 | return completions; 53 | }; 54 | 55 | /** 56 | * Acquire extra information on the hovered completion item. 57 | * This includes some package info, context and signatures. 58 | * 59 | * This is done separate from acquiring completions, since it's slower, and 60 | * is done on a per completion basis. 61 | */ 62 | export const getCompletionItemDetails = async ( 63 | filename: string, 64 | cursorIndex: number, 65 | config: WorkerConfig, 66 | completionWord: string 67 | ): Promise => { 68 | const workerContext = getWorkerContext(config); 69 | const languageService = workerContext.languageServiceContext.service; 70 | 71 | // Only passing relevant params for now, since the other values 72 | // are not needed for current functionality 73 | const details = languageService.getCompletionEntryDetails( 74 | filename, 75 | cursorIndex, 76 | completionWord, 77 | undefined, 78 | undefined, 79 | undefined, 80 | undefined 81 | ); 82 | 83 | const detailInformation: EditorCompletionDetails = { 84 | text: displayPartsToString(details?.displayParts), 85 | tags: details?.tags ?? [], 86 | documentation: getDocumentations(details?.documentation), 87 | }; 88 | return detailInformation; 89 | }; 90 | 91 | function displayPartsToString( 92 | displayParts: SymbolDisplayPart[] | undefined 93 | ): string { 94 | if (!displayParts || displayParts.length === 0) return ''; 95 | 96 | let displayString = ''; 97 | displayParts.forEach((part) => { 98 | displayString += part.text; 99 | }); 100 | return displayString; 101 | } 102 | 103 | function getDocumentations( 104 | documentation: SymbolDisplayPart[] | undefined 105 | ): string[] { 106 | return documentation?.map((doc) => doc.text) ?? []; 107 | } 108 | -------------------------------------------------------------------------------- /src/typescript-worker/diagnostic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import ts from '../internal/typescript.js'; 8 | import * as lsp from 'vscode-languageserver-protocol'; 9 | 10 | /** 11 | * Convert a diagnostic from TypeScript format to Language Server Protocol 12 | * format. 13 | */ 14 | export function makeLspDiagnostic(tsDiagnostic: ts.Diagnostic): lsp.Diagnostic { 15 | return { 16 | code: tsDiagnostic.code, 17 | source: tsDiagnostic.source ?? 'typescript', 18 | message: ts.flattenDiagnosticMessageText(tsDiagnostic.messageText, '\n'), 19 | severity: diagnosticCategoryMapping[tsDiagnostic.category], 20 | range: { 21 | start: 22 | tsDiagnostic.file !== undefined && tsDiagnostic.start !== undefined 23 | ? tsDiagnostic.file.getLineAndCharacterOfPosition(tsDiagnostic.start) 24 | : {character: 0, line: 0}, 25 | end: 26 | tsDiagnostic.file !== undefined && 27 | tsDiagnostic.start !== undefined && 28 | tsDiagnostic.length !== undefined 29 | ? tsDiagnostic.file.getLineAndCharacterOfPosition( 30 | tsDiagnostic.start + tsDiagnostic.length 31 | ) 32 | : {character: 0, line: 0}, 33 | }, 34 | }; 35 | } 36 | 37 | /** 38 | * We don't want a runtime import of 'vscode-languageserver-protocol' just for the 39 | * DiagnosticSeverity constants. We can duplicate the values instead, and assert 40 | * we got them right with a type constraint. 41 | */ 42 | const diagnosticCategoryMapping: { 43 | [ts.DiagnosticCategory.Error]: (typeof lsp.DiagnosticSeverity)['Error']; 44 | [ts.DiagnosticCategory.Warning]: (typeof lsp.DiagnosticSeverity)['Warning']; 45 | [ts.DiagnosticCategory 46 | .Message]: (typeof lsp.DiagnosticSeverity)['Information']; 47 | [ts.DiagnosticCategory.Suggestion]: (typeof lsp.DiagnosticSeverity)['Hint']; 48 | } = { 49 | [ts.DiagnosticCategory.Error]: 1, 50 | [ts.DiagnosticCategory.Warning]: 2, 51 | [ts.DiagnosticCategory.Message]: 3, 52 | [ts.DiagnosticCategory.Suggestion]: 4, 53 | }; 54 | -------------------------------------------------------------------------------- /src/typescript-worker/import-map-resolver.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {ModuleImportMap} from '../shared/worker-api.js'; 8 | 9 | /** 10 | * Resolves module specifiers using an import map. 11 | * 12 | * For overview, see https://github.com/WICG/import-maps. For algorithm, see 13 | * https://wicg.github.io/import-maps/#resolving 14 | * 15 | * TODO(aomarks) Add support for `scopes`. 16 | */ 17 | export class ImportMapResolver { 18 | private importMap: ModuleImportMap; 19 | 20 | constructor(importMap: ModuleImportMap) { 21 | this.importMap = importMap; 22 | } 23 | 24 | resolve(specifier: string): string | null { 25 | for (const [specifierKey, resolutionResult] of Object.entries( 26 | this.importMap.imports ?? {} 27 | )) { 28 | // Note that per spec we shouldn't do a lookup for the exact match case, 29 | // because if a trailing-slash mapping also matches and comes first, it 30 | // should have precedence. 31 | if (specifierKey === specifier) { 32 | return resolutionResult; 33 | } 34 | 35 | if (specifierKey.endsWith('/') && specifier.startsWith(specifierKey)) { 36 | if (!resolutionResult.endsWith('/')) { 37 | console.warn( 38 | `Could not resolve module specifier "${specifier}"` + 39 | ` using import map key "${specifierKey}" because` + 40 | ` address "${resolutionResult}" must end in a forward-slash.` 41 | ); 42 | return null; 43 | } 44 | 45 | const afterPrefix = specifier.substring(specifierKey.length); 46 | let url; 47 | try { 48 | url = new URL(afterPrefix, resolutionResult); 49 | } catch { 50 | console.warn( 51 | `Could not resolve module specifier "${specifier}"` + 52 | ` using import map key "${specifierKey}" because` + 53 | ` "${afterPrefix}" could not be parsed` + 54 | ` relative to "${resolutionResult}".` 55 | ); 56 | return null; 57 | } 58 | 59 | const urlSerialized = url.href; 60 | if (!urlSerialized.startsWith(resolutionResult)) { 61 | console.warn( 62 | `Could not resolve module specifier "${specifier}"` + 63 | ` using import map key "${specifierKey}" because` + 64 | ` "${afterPrefix}" backtracked above "${resolutionResult}".` 65 | ); 66 | return null; 67 | } 68 | return urlSerialized; 69 | } 70 | } 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/typescript-worker/language-service-context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import ts from '../internal/typescript.js'; 8 | 9 | const compilerOptions = { 10 | target: ts.ScriptTarget.ES2021, 11 | module: ts.ModuleKind.ESNext, 12 | experimentalDecorators: true, 13 | skipDefaultLibCheck: true, 14 | skipLibCheck: true, 15 | allowJs: true, 16 | moduleResolution: ts.ModuleResolutionKind.NodeNext, 17 | jsx: ts.JsxEmit.React, 18 | lib: ['dom', 'esnext'], 19 | }; 20 | 21 | /** 22 | * Compiles a project, returning a Map of compiled file contents. The map only 23 | * contains context for files that are compiled. Other files are skipped. 24 | * 25 | * TODO (justinfagnani): We could share a DocumentRegistry across 26 | * multiple instances to save memory and type analysis of 27 | * common lib files like lit-element, lib.d.ts and dom.d.ts. 28 | */ 29 | export class LanguageServiceContext { 30 | readonly compilerOptions = compilerOptions; 31 | 32 | readonly serviceHost = new WorkerLanguageServiceHost( 33 | self.origin, 34 | compilerOptions 35 | ); 36 | 37 | readonly service = ts.createLanguageService( 38 | this.serviceHost, 39 | ts.createDocumentRegistry() 40 | ); 41 | } 42 | 43 | interface VersionedFile { 44 | version: number; 45 | content: string; 46 | } 47 | 48 | class WorkerLanguageServiceHost implements ts.LanguageServiceHost { 49 | readonly compilerOptions: ts.CompilerOptions; 50 | readonly packageRoot: string; 51 | readonly files: Map = new Map(); 52 | 53 | constructor(packageRoot: string, compilerOptions: ts.CompilerOptions) { 54 | this.packageRoot = packageRoot; 55 | this.compilerOptions = compilerOptions; 56 | } 57 | 58 | /* 59 | * When a new new "process" command is received, we iterate through all of the files, 60 | * and update files accordingly depending on if they have new content or not. 61 | * 62 | * With how the TS API works, we can use simple versioning to tell the 63 | * Language service that a file has been updated 64 | * 65 | * If the file submitted is a new file, we add it to our collection 66 | */ 67 | updateFileContentIfNeeded(fileName: string, content: string) { 68 | const file = this.files.get(fileName); 69 | if (file && file.content !== content) { 70 | file.content = content; 71 | file.version += 1; 72 | } else { 73 | this.files.set(fileName, {content, version: 0}); 74 | } 75 | } 76 | 77 | /** 78 | * Sync up the freshly acquired project files. 79 | * In the syncing process files yet to be added are added, and versioned. 80 | * Files that existed already but are modified are updated, and their version number 81 | * gets bumped fo that the languageservice knows to update these files. 82 | * */ 83 | sync(files: Map) { 84 | files.forEach((file, fileName) => 85 | this.updateFileContentIfNeeded(fileName, file) 86 | ); 87 | this._removeDeletedFiles(files); 88 | } 89 | 90 | private _removeDeletedFiles(files: Map) { 91 | this.getScriptFileNames().forEach((fileName) => { 92 | // Do not delete the dependency files, as then they will get re-applied every compilation. 93 | // This is because the compilation step is aware of these files, but the completion step isn't. 94 | if (!fileName.includes('node_modules') && !files.has(fileName)) { 95 | this.files.delete(fileName); 96 | } 97 | }); 98 | } 99 | 100 | getCompilationSettings(): ts.CompilerOptions { 101 | return this.compilerOptions; 102 | } 103 | 104 | getScriptFileNames(): string[] { 105 | return [...this.files.keys()]; 106 | } 107 | 108 | getScriptVersion(fileName: string) { 109 | return this.files.get(fileName)?.version.toString() ?? '-1'; 110 | } 111 | 112 | fileExists(fileName: string): boolean { 113 | return this.files.has(fileName); 114 | } 115 | 116 | readFile(fileName: string): string | undefined { 117 | return this.files.get(fileName)?.content; 118 | } 119 | 120 | getScriptSnapshot(fileName: string): ts.IScriptSnapshot | undefined { 121 | if (!this.fileExists(fileName)) { 122 | return undefined; 123 | } 124 | return ts.ScriptSnapshot.fromString(this.readFile(fileName)!); 125 | } 126 | 127 | getCurrentDirectory(): string { 128 | return this.packageRoot; 129 | } 130 | 131 | getDefaultLibFileName(): string { 132 | return '__lib.d.ts'; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/typescript-worker/node-module-resolver.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {packageExportsResolve} from './node/resolve.js'; 8 | import {NpmFileLocation, PackageJson, PackageJsonWithExports} from './util.js'; 9 | 10 | /** 11 | * Resolves a path according to the Node package exports algorithm. 12 | * 13 | * Documentation: 14 | * 15 | * Node: https://nodejs.org/api/packages.html#packages_package_entry_points 16 | * Rollup: https://github.com/rollup/plugins/tree/master/packages/node-resolve/#package-entrypoints 17 | * Webpack: https://webpack.js.org/guides/package-exports/ 18 | * 19 | * Reference implementations: 20 | * 21 | * Node: https://github.com/nodejs/node/blob/a9dd03b1ec89a75186f05967fc76ec0704050c36/lib/internal/modules/esm/resolve.js#L615 22 | * Rollup: 23 | * https://github.com/rollup/plugins/blob/53fb18c0c2852598200c547a0b1d745d15b5b487/packages/node-resolve/src/package/resolvePackageImportsExports.js#L6 24 | */ 25 | export class NodeModuleResolver { 26 | private readonly _conditions: Set; 27 | 28 | constructor({conditions}: {conditions: string[]}) { 29 | this._conditions = new Set(conditions); 30 | } 31 | 32 | /** 33 | * @param location Package/version/path to resolve. 34 | * @param packageJson The package's package.json (parsed object, not string). 35 | * @param base Path of the importing module, used for error messages (e.g. 36 | * "./my-element.js"). 37 | * @return The resolved subpath. 38 | * @throws If the given subpath could not be resolved. 39 | */ 40 | resolve( 41 | location: NpmFileLocation, 42 | packageJson: PackageJson, 43 | base: string 44 | ): string { 45 | const packageSubpath = addRelativePrefix(location.path); 46 | 47 | if (packageJson.exports === undefined) { 48 | if (packageSubpath === '.') { 49 | if (packageJson.module !== undefined) { 50 | return removeRelativePrefix(packageJson.module); 51 | } 52 | if (packageJson.main !== undefined) { 53 | return removeRelativePrefix(packageJson.main); 54 | } 55 | return 'index.js'; 56 | } 57 | return location.path; 58 | } 59 | 60 | // Node's resolve functions works with file:// URLs. It doesn't really 61 | // matter what we use as the base, but let's make one that matches the 62 | // Playground URL space for dependencies, so that errors make sense. 63 | const packageBase = `/node_modules/${location.pkg}@${location.version}/`; 64 | const packageJsonUrl = new URL(packageBase, 'file://'); 65 | 66 | const resolved = packageExportsResolve( 67 | packageJsonUrl, 68 | packageSubpath, 69 | packageJson as PackageJsonWithExports, 70 | base, 71 | this._conditions 72 | ); 73 | if (!resolved.pathname.startsWith(packageBase)) { 74 | throw new Error( 75 | `Unexpected error: ${resolved.pathname} expected to start with ${packageBase}` 76 | ); 77 | } 78 | return resolved.pathname.slice(packageBase.length); 79 | } 80 | } 81 | 82 | /** 83 | * Convert e.g. "" to "." and "foo.js" to "./foo.js". 84 | */ 85 | const addRelativePrefix = (path: string): string => { 86 | if (path === '') { 87 | return '.'; 88 | } 89 | if (!path.startsWith('.') && !path.startsWith('/')) { 90 | return './' + path; 91 | } 92 | return path; 93 | }; 94 | 95 | /** 96 | * Convert e.g. "." to "" and "./foo.js" to "foo.js". 97 | */ 98 | const removeRelativePrefix = (path: string): string => { 99 | if (path === '.') { 100 | return ''; 101 | } 102 | if (path.startsWith('./')) { 103 | return path.slice(2); 104 | } 105 | return path; 106 | }; 107 | -------------------------------------------------------------------------------- /src/typescript-worker/node-modules-layout-maker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | type VersionNumber = string; 8 | 9 | /** 10 | * Like the "dependencies" field of a package.json file, except the version 11 | * numbers are always concrete instead of semver ranges. 12 | */ 13 | export interface PackageDependencies { 14 | [pkg: string]: VersionNumber; 15 | } 16 | 17 | /** 18 | * Maps from NPM package name, to the versions of that package, to its 19 | * dependencies. 20 | */ 21 | export interface DependencyGraph { 22 | [pkg: string]: { 23 | [version: string]: PackageDependencies; 24 | }; 25 | } 26 | 27 | /** 28 | * The contents of a "node_modules/" directory. Keys are NPM package names. 29 | * Values are objects containing the version, and the contents of a nested 30 | * "node_modules/" directory. 31 | */ 32 | export interface NodeModulesDirectory { 33 | [pkg: string]: { 34 | version: VersionNumber; 35 | nodeModules: NodeModulesDirectory; 36 | }; 37 | } 38 | 39 | /** 40 | * Temporary data structure used during graph traversal to associate a node in 41 | * the dependency graph with its corresponding node_modules/ directory, and the 42 | * parent of that directory. 43 | */ 44 | interface GraphTraversalState { 45 | dependencies: PackageDependencies; 46 | nodeModules: NodeModulesDirectory; 47 | parent: GraphTraversalState | null; 48 | } 49 | 50 | /** 51 | * Given an NPM-style dependency graph, makes a directory structure comprised of 52 | * nested "node_modules/" folders that is compatible with the layout expected by 53 | * the Node module resolution algorithm 54 | * (https://nodejs.org/api/modules.html#modules_loading_from_node_modules_folders). 55 | * 56 | * Attempts to create the flattest directory layout possible to reduce file 57 | * duplication, without causing version conflicts. 58 | * 59 | * For example, given the dependency graph: 60 | * 61 | * ROOT 62 | * /|\ 63 | * / | \ 64 | * / | \ 65 | * v v v 66 | * A1 B1 C1 67 | * \ / 68 | * \ / 69 | * v 70 | * A2 --> D1 71 | * 72 | * Returns the directory tree: 73 | * 74 | * ROOT 75 | * ├── A1 76 | * ├── B1 77 | * │ └── A2 78 | * ├── C1 79 | * │ └── A2 80 | * └── D1 81 | */ 82 | export class NodeModulesLayoutMaker { 83 | layout( 84 | rootDeps: PackageDependencies, 85 | depGraph: DependencyGraph 86 | ): NodeModulesDirectory { 87 | const rootNodeModulesDir: NodeModulesDirectory = {}; 88 | // Traverse the dependency graph using breadth-first search so that we 89 | // "install" all of a package's direct dependencies before moving on to its 90 | // transitive dependencies. 91 | const bfsQueue: Array = [ 92 | { 93 | dependencies: rootDeps, 94 | nodeModules: rootNodeModulesDir, 95 | parent: null, 96 | }, 97 | ]; 98 | while (bfsQueue.length > 0) { 99 | const currentNode = bfsQueue.shift()!; 100 | for (const [pkg, version] of Object.entries(currentNode.dependencies)) { 101 | // Find the best node_modules/ directory to install this dependency 102 | // into. We're looking for the directory that is closest to the root 103 | // (including the root itself), but which has no conflicting version of 104 | // the same package along the path. 105 | let installLocation = currentNode; 106 | let alreadyInstalled = false; 107 | while (installLocation.parent !== null) { 108 | const parentVersion = 109 | installLocation.parent.nodeModules[pkg]?.version; 110 | if (parentVersion !== undefined) { 111 | alreadyInstalled = parentVersion === version; 112 | break; 113 | } 114 | installLocation = installLocation.parent; 115 | } 116 | if (alreadyInstalled) { 117 | // This package was already installed at the exact same version in 118 | // some reachable ancestor directory, so we're already done. 119 | continue; 120 | } 121 | // "Install" this dependency. 122 | const nestedNodeModules = {}; 123 | installLocation.nodeModules[pkg] = { 124 | version, 125 | nodeModules: nestedNodeModules, 126 | }; 127 | // Add this dependency's own dependencies onto the breadth-first search 128 | // queue. 129 | const childDependencies = depGraph[pkg]?.[version]; 130 | if (childDependencies !== undefined) { 131 | bfsQueue.push({ 132 | dependencies: childDependencies, 133 | nodeModules: nestedNodeModules, 134 | parent: installLocation, 135 | }); 136 | } 137 | } 138 | } 139 | return rootNodeModulesDir; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/typescript-worker/node/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2017 Node.js contributors. All rights reserved. 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | // This file is derived from 8 | // https://github.com/nodejs/node/blob/a9dd03b1ec89a75186f05967fc76ec0704050c36/lib/internal/errors.js 9 | // and adapted for use in playground-elements. 10 | 11 | import {fileURLToPath} from './url.js'; 12 | import {PackageExportsTarget} from '../util.js'; 13 | 14 | export class InvalidModuleSpecifierError extends Error { 15 | constructor(request: string, reason: string, base?: string) { 16 | super( 17 | `Invalid module "${request}" ${reason}${ 18 | base ? ` imported from ${base}` : '' 19 | }` 20 | ); 21 | } 22 | } 23 | 24 | export class InvalidPackageConfigError extends Error { 25 | constructor(path: string, base: string, message: string) { 26 | super( 27 | `Invalid package config ${path}${base ? ` while importing ${base}` : ''}${ 28 | message ? `. ${message}` : '' 29 | }` 30 | ); 31 | } 32 | } 33 | 34 | export class InvalidPackageTargetError extends Error { 35 | constructor( 36 | pkgPath: URL, 37 | key: string, 38 | target: PackageExportsTarget, 39 | isImport = false, 40 | base?: string 41 | ) { 42 | const relError = 43 | typeof target === 'string' && 44 | !isImport && 45 | target.length && 46 | !target.startsWith('./'); 47 | let msg; 48 | if (key === '.') { 49 | msg = 50 | `Invalid "exports" main target ${JSON.stringify(target)} defined ` + 51 | `in the package config ${fileURLToPath(pkgPath)}package.json${ 52 | base ? ` imported from ${base}` : '' 53 | }${relError ? '; targets must start with "./"' : ''}`; 54 | } else { 55 | msg = `Invalid "${ 56 | isImport ? 'imports' : 'exports' 57 | }" target ${JSON.stringify( 58 | target 59 | )} defined for '${key}' in the package config ${fileURLToPath( 60 | pkgPath 61 | )}package.json${base ? ` imported from ${base}` : ''}${ 62 | relError ? '; targets must start with "./"' : '' 63 | }`; 64 | } 65 | super(msg); 66 | } 67 | } 68 | export class PackagePathNotExportedError extends Error { 69 | constructor(pkgPath: string, subpath: string, base?: string) { 70 | if (subpath === '.') { 71 | super( 72 | `No "exports" main defined in ${pkgPath}package.json${ 73 | base ? ` imported from ${base}` : '' 74 | }` 75 | ); 76 | } else { 77 | super( 78 | `Package subpath '${subpath}' is not defined by "exports" in ${pkgPath}package.json${ 79 | base ? ` imported from ${base}` : '' 80 | }` 81 | ); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/typescript-worker/node/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2016 Node.js contributors. All rights reserved. 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | // This file is derived from 8 | // https://github.com/nodejs/node/blob/a9dd03b1ec89a75186f05967fc76ec0704050c36/lib/internal/url.js 9 | // and adapted for use in playground-elements. 10 | 11 | export function fileURLToPath(path: URL | string): string { 12 | if (typeof path === 'string') { 13 | path = new URL(path); 14 | } 15 | if (path.protocol !== 'file:') { 16 | throw new Error('The URL must be of scheme file'); 17 | } 18 | return path.pathname; 19 | } 20 | -------------------------------------------------------------------------------- /src/typescript-worker/typescript-builder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {BuildOutput, SampleFile} from '../shared/worker-api.js'; 8 | import {TypesFetcher} from './types-fetcher.js'; 9 | import {PackageJson} from './util.js'; 10 | import {makeLspDiagnostic} from './diagnostic.js'; 11 | import {WorkerContext} from './worker-context.js'; 12 | 13 | export async function* processTypeScriptFiles( 14 | workerContext: WorkerContext, 15 | results: AsyncIterable | Iterable 16 | ): AsyncIterable { 17 | // Instantiate langservice variables for ease of access 18 | const langService = workerContext.languageServiceContext.service; 19 | const langServiceHost = workerContext.languageServiceContext.serviceHost; 20 | let packageJson: PackageJson | undefined; 21 | const compilerInputs = []; 22 | for await (const result of results) { 23 | if ( 24 | result.kind === 'file' && 25 | (result.file.name.endsWith('.ts') || 26 | result.file.name.endsWith('.jsx') || 27 | result.file.name.endsWith('.tsx')) 28 | ) { 29 | compilerInputs.push(result.file); 30 | } else { 31 | yield result; 32 | if (result.kind === 'file' && result.file.name === 'package.json') { 33 | try { 34 | packageJson = JSON.parse(result.file.content) as PackageJson; 35 | } catch (e) { 36 | // A bit hacky, but BareModuleTransformer already emits a diagnostic 37 | // for this case, so we don't need another one. 38 | } 39 | } 40 | } 41 | } 42 | 43 | if (compilerInputs.length === 0) { 44 | return; 45 | } 46 | 47 | // Immediately resolve local project files, and begin fetching types (but 48 | // don't wait for them). 49 | const loadedFiles = new Map(); 50 | const inputFiles = compilerInputs.map((file) => ({ 51 | file, 52 | url: new URL(file.name, self.origin).href, 53 | })); 54 | 55 | for (const {file, url} of inputFiles) { 56 | loadedFiles.set(url, file.content); 57 | } 58 | 59 | // TypeScript needs the local package.json because it's interested in the 60 | // "types" and "imports" fields. 61 | // 62 | // We also change the default "type" field from "commonjs" to "module" because 63 | // we're pretty much always in a web context, and wanting standard module 64 | // semantics. 65 | const defaultPackageJson = 66 | packageJson === undefined 67 | ? {type: 'module'} 68 | : packageJson.type === 'module' 69 | ? packageJson 70 | : {...packageJson, type: 'module'}; 71 | loadedFiles.set( 72 | new URL('package.json', self.origin).href, 73 | JSON.stringify(defaultPackageJson) 74 | ); 75 | 76 | // Sync the new loaded files with the servicehost. 77 | // If the file is missing, it's added, if the file is modified, 78 | // the modification data and versioning will be handled by the servicehost. 79 | // If a file is removed, it will be removed from the file list 80 | langServiceHost.sync(loadedFiles); 81 | 82 | const program = langService.getProgram(); 83 | if (program === undefined) { 84 | throw new Error('Unexpected error: program was undefined'); 85 | } 86 | 87 | for (const {file, url} of inputFiles) { 88 | for (const tsDiagnostic of langService.getSyntacticDiagnostics(url)) { 89 | yield { 90 | kind: 'diagnostic', 91 | filename: file.name, 92 | diagnostic: makeLspDiagnostic(tsDiagnostic), 93 | }; 94 | } 95 | const sourceFile = program.getSourceFile(url); 96 | let compiled: SampleFile | undefined = undefined; 97 | program!.emit(sourceFile, (url, content) => { 98 | compiled = { 99 | name: new URL(url).pathname.slice(1), 100 | content, 101 | contentType: 'text/javascript', 102 | }; 103 | }); 104 | if (compiled !== undefined) { 105 | yield {kind: 'file', file: compiled}; 106 | } 107 | } 108 | 109 | // Wait for all typings to be fetched, and then retrieve slower semantic 110 | // diagnostics. 111 | const typings = await TypesFetcher.fetchTypes( 112 | workerContext.cdn, 113 | workerContext.importMapResolver, 114 | packageJson, 115 | inputFiles.map((file) => file.file.content), 116 | workerContext.languageServiceContext.compilerOptions.lib 117 | ); 118 | for (const [path, content] of typings.files) { 119 | // TypeScript is going to look for these files as paths relative to our 120 | // source files, so we need to add them to our filesystem with those URLs. 121 | const url = new URL(`node_modules/${path}`, self.origin).href; 122 | langServiceHost.updateFileContentIfNeeded(url, content); 123 | } 124 | for (const {file, url} of inputFiles) { 125 | for (const tsDiagnostic of langService.getSemanticDiagnostics(url)) { 126 | yield { 127 | kind: 'diagnostic', 128 | filename: file.name, 129 | diagnostic: makeLspDiagnostic(tsDiagnostic), 130 | }; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/typescript-worker/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | /** 8 | * Merges multiple async iterables into one iterable. Order is not preserved. 9 | * Iterables can be added before or during iteration. After exhausted, adding a 10 | * new iterator throws. 11 | */ 12 | export class MergedAsyncIterables { 13 | private readonly _buffer: Array<{value: T; emitted: () => void}> = []; 14 | private _numSources = 0; 15 | private _notify?: () => void; 16 | private _done = false; 17 | 18 | async *[Symbol.asyncIterator]() { 19 | while (this._numSources > 0) { 20 | while (this._buffer.length > 0) { 21 | const {value, emitted} = this._buffer.shift()!; 22 | yield value; 23 | // Let the loop in add() continue 24 | emitted(); 25 | } 26 | // Wait until there is a new value or a source iterator was exhausted 27 | await new Promise((resolve) => (this._notify = resolve)); 28 | this._notify = undefined; 29 | } 30 | this._done = true; 31 | } 32 | 33 | add(iterable: AsyncIterable) { 34 | if (this._done) { 35 | throw new Error( 36 | 'Merged iterator is exhausted. Cannot add new source iterators.' 37 | ); 38 | } 39 | this._numSources++; 40 | void (async () => { 41 | for await (const value of iterable) { 42 | // Wait for this value to be emitted before continuing 43 | await new Promise((emitted) => { 44 | this._buffer.push({value, emitted}); 45 | this._notify?.(); 46 | }); 47 | } 48 | this._numSources--; 49 | this._notify?.(); 50 | })(); 51 | } 52 | } 53 | 54 | /** 55 | * Return the relative path from two URL pathnames. 56 | * 57 | * E.g. given "a/b/c.js" and "a/d.js" return "../d.js". 58 | */ 59 | export const relativeUrlPath = (from: string, to: string): string => { 60 | const fromParts = from.split('/'); 61 | const toParts = to.split('/'); 62 | let numCommon = 0; 63 | while ( 64 | numCommon < fromParts.length && 65 | numCommon < toParts.length && 66 | fromParts[numCommon] === toParts[numCommon] 67 | ) { 68 | numCommon++; 69 | } 70 | const numUp = fromParts.length - numCommon - 1; 71 | return ( 72 | (numUp === 0 ? './' : new Array(numUp + 1).join('../')) + 73 | toParts.slice(numCommon).join('/') 74 | ); 75 | }; 76 | 77 | /** 78 | * Resolve two URL pathnames into an absolute path. 79 | * 80 | * E.g. given "a/b/c.js" and "../d.js" return "a/d.js". 81 | */ 82 | export const resolveUrlPath = (a: string, b: string) => 83 | // The base URL is arbitrary and "ws://_" is very short. 84 | new URL(b, new URL(a, 'ws://_')).pathname; 85 | 86 | /** 87 | * Return whether the given module import specifier is bare, a relative URL, or 88 | * a fully qualified URL. 89 | */ 90 | export const classifySpecifier = ( 91 | specifier: string 92 | ): 'bare' | 'relative' | 'url' => { 93 | try { 94 | // Note a specifier like "te:st.js" would be classified as a URL. This is 95 | // ok, because we can assume bare specifiers are always prefixed with a NPM 96 | // package name, which cannot contain ":" characters. 97 | new URL(specifier).href; 98 | return 'url'; 99 | // eslint-disable-next-line no-empty 100 | } catch {} 101 | if (specifier.match(/^(\.){0,2}\//) !== null) { 102 | return 'relative'; 103 | } 104 | return 'bare'; 105 | }; 106 | 107 | export interface NpmFileLocation { 108 | pkg: string; 109 | version: string; 110 | path: string; 111 | } 112 | 113 | /** 114 | * Parse the given module import specifier using format 115 | * "[@][/]". 116 | * 117 | * E.g. given "foo@^1.2.3/bar.js" return { 118 | * pkg: "foo", 119 | * version: "^1.2.3", 120 | * path: "bar.js" 121 | * } 122 | */ 123 | export const parseNpmStyleSpecifier = ( 124 | specifier: string 125 | ): NpmFileLocation | undefined => { 126 | const match = specifier.match(/^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/?(.*)$/); 127 | if (match === null) { 128 | return undefined; 129 | } 130 | const [, pkg, version, path] = match as [ 131 | unknown, 132 | string, 133 | string | undefined, 134 | string 135 | ]; 136 | return {pkg, version: version ?? '', path}; 137 | }; 138 | 139 | /** 140 | * Return the file extension of the given URL path. Does not include the leading 141 | * ".". Note this only considers the final ".", so e.g. given "foo.d.ts" this 142 | * will return "ts". 143 | */ 144 | export const fileExtension = (path: string): string => { 145 | const lastSlashIdx = path.lastIndexOf('/'); 146 | const lastDotIdx = path.lastIndexOf('.'); 147 | return lastDotIdx === -1 || lastDotIdx < lastSlashIdx 148 | ? '' 149 | : path.slice(lastDotIdx + 1); 150 | }; 151 | 152 | /** 153 | * Change the given URL path's file extension to a different one. `newExt` 154 | * should not include the leading ".". Note this only considers the final ".", 155 | * so e.g. given "foo.d.ts" and ".js" this will return "foo.d.js". 156 | */ 157 | export const changeFileExtension = (path: string, newExt: string): string => { 158 | const oldExt = fileExtension(path); 159 | if (oldExt === '') { 160 | return path + '.' + newExt; 161 | } 162 | return path.slice(0, -oldExt.length) + newExt; 163 | }; 164 | 165 | /** 166 | * Given a string and string-relative character index, return the equivalent 167 | * line number and line-relative character index. 168 | */ 169 | export const charToLineAndChar = ( 170 | str: string, 171 | char: number 172 | ): {line: number; character: number} => { 173 | let line = 0; 174 | let character = 0; 175 | for (let i = 0; i < char && i < str.length; i++) { 176 | if (str[i] === '\n') { 177 | line++; 178 | character = 0; 179 | } else { 180 | character++; 181 | } 182 | } 183 | return {line, character}; 184 | }; 185 | 186 | /** 187 | * The "exports" field of a package.json. 188 | * 189 | * See https://nodejs.org/api/packages.html#packages_exports. 190 | */ 191 | export type PackageExports = 192 | | PackageExportsTarget 193 | | PackageExportsPathOrConditionMap; 194 | 195 | /** 196 | * The export result for some path or condition. 197 | */ 198 | export type PackageExportsTarget = 199 | // A concrete path. 200 | | PackageExportsTargetPath 201 | // Condition maps can be nested. 202 | | PackageExportsConditionMap 203 | // The first valid target in an array wins. 204 | | PackageExportsTarget[] 205 | // An explicit "not found". 206 | | null; 207 | 208 | /** 209 | * A concrete resolved path (e.g. "./lib/foo.js"). 210 | */ 211 | export type PackageExportsTargetPath = string; 212 | 213 | /** 214 | * Map from a path or condition to a target. 215 | */ 216 | export type PackageExportsPathOrConditionMap = { 217 | [pathOrCondition: string]: PackageExportsTarget; 218 | }; 219 | 220 | /** 221 | * Map from a condition to a target. 222 | * 223 | * Note this is technically the same type as PackageExportsPathOrConditionMap, 224 | * but it's distinguished for clarity because "path" keys are only allowed in 225 | * the top-level of the "exports" object. 226 | */ 227 | export type PackageExportsConditionMap = { 228 | [condition: string]: PackageExportsTarget; 229 | }; 230 | 231 | export interface PackageJson { 232 | version?: string; 233 | main?: string; 234 | exports?: PackageExports; 235 | module?: string; 236 | types?: string; 237 | typings?: string; 238 | type?: string; 239 | dependencies?: {[key: string]: string}; 240 | } 241 | 242 | export interface PackageJsonWithExports extends PackageJson { 243 | exports: PackageExports; 244 | } 245 | 246 | /** 247 | * Return whether the given string is an exact semver version, as opposed to a 248 | * range or tag. 249 | */ 250 | export const isExactSemverVersion = (s: string) => 251 | s.match( 252 | // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string 253 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 254 | ) !== null; 255 | 256 | export const pkgVersion = ({pkg, version}: {pkg: string; version: string}) => 257 | `${pkg}@${version || 'latest'}`; 258 | 259 | export const pkgVersionPath = ({pkg, version, path}: NpmFileLocation) => 260 | trimTrailingSlash(`${pkgVersion({pkg, version})}/${trimLeadingSlash(path)}`); 261 | 262 | export const trimLeadingSlash = (s: string) => 263 | s.startsWith('/') ? s.slice(1) : s; 264 | 265 | export const trimTrailingSlash = (s: string) => 266 | s.endsWith('/') ? s.slice(0, -1) : s; 267 | -------------------------------------------------------------------------------- /src/typescript-worker/worker-context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | 7 | import {LanguageServiceContext} from './language-service-context.js'; 8 | import {CachingCdn} from './caching-cdn.js'; 9 | import {ImportMapResolver} from './import-map-resolver.js'; 10 | import {WorkerConfig} from '../shared/worker-api.js'; 11 | 12 | let workerContext: WorkerContext | undefined; 13 | let cacheKey = ''; 14 | 15 | /** 16 | * Acquire the existing worker instance, or create a fresh one if missing. 17 | * If the config differs from the existing instance's config, a new WorkerContext is 18 | * instantiated and made the new instance. 19 | */ 20 | export function getWorkerContext(config: WorkerConfig) { 21 | const configCacheKey = JSON.stringify(config); 22 | if (workerContext && cacheKey === configCacheKey) { 23 | return workerContext; 24 | } 25 | 26 | cacheKey = configCacheKey; 27 | workerContext = new WorkerContext(config); 28 | return workerContext; 29 | } 30 | 31 | export class WorkerContext { 32 | readonly cdn: CachingCdn; 33 | readonly importMapResolver: ImportMapResolver; 34 | readonly languageServiceContext: LanguageServiceContext; 35 | 36 | constructor(config: WorkerConfig) { 37 | this.importMapResolver = new ImportMapResolver(config.importMap); 38 | this.cdn = new CachingCdn(config.cdnBaseUrl || 'https://unpkg.com'); 39 | this.languageServiceContext = new LanguageServiceContext(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test-results/.last-run.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "passed", 3 | "failedTests": [] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig-typescript-worker.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "./typescript-worker/.tsbuildinfo", 8 | "target": "es2019", 9 | "lib": ["ES2020", "WebWorker"], 10 | "skipLibCheck": true, 11 | "skipDefaultLibCheck": true 12 | }, 13 | "include": [ 14 | "src/typescript-worker/*.ts", 15 | "src/typescript-worker/**/*.ts", 16 | "src/playground-typescript-worker-entrypoint.ts" 17 | ], 18 | "exclude": [], 19 | "references": [ 20 | { 21 | "path": "./src/shared" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "incremental": true, 5 | "target": "es2019", 6 | "module": "esnext", 7 | "lib": ["ES2020", "ES2015.iterable", "DOM", "DOM.Iterable"], 8 | "declaration": true, 9 | "declarationMap": true, 10 | "sourceMap": false, 11 | "outDir": ".", 12 | "rootDir": "src", 13 | "tsBuildInfoFile": ".tsbuildinfo", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitAny": true, 20 | "noImplicitThis": true, 21 | "moduleResolution": "node", 22 | "forceConsistentCasingInFileNames": true, 23 | "experimentalDecorators": true, 24 | "allowSyntheticDefaultImports": true, 25 | "importHelpers": true, 26 | // TODO(aomarks) Remove when 27 | // https://github.com/material-components/material-web/issues/2715 is fixed. 28 | "skipLibCheck": true, 29 | "noImplicitOverride": true, 30 | "noPropertyAccessFromIndexSignature": true 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/internal/worker-api.ts", 35 | "src/shared/util.ts" 36 | ], 37 | "exclude": [ 38 | "src/service-worker", 39 | "src/shared", 40 | "src/typescript-worker", 41 | "src/playground-typescript-worker-entrypoint.ts" 42 | ], 43 | "references": [ 44 | { 45 | "path": "src/service-worker" 46 | }, 47 | { 48 | "path": "src/shared" 49 | }, 50 | { 51 | "path": "./tsconfig-typescript-worker.json" 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /web-dev-server.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [ 3 | { 4 | // When we're using web-dev-server's --watch mode, we don't want our 5 | // sample HTML files to get the injected web socket reload script tag. 6 | // This plugin reverses that transformation just for those files. See 7 | // https://github.com/modernweb-dev/web/issues/761 for a feature request 8 | // to make this easier. 9 | name: 'remove-injected-watch-script', 10 | transform(ctx) { 11 | if ( 12 | ctx.url.match(/^(\/configurator\/project\/|\/demo\/.+\/).*\.html$/) 13 | ) { 14 | return { 15 | body: ctx.body.replace( 16 | /