diff --git a/.changeset/eight-frogs-roll.md b/.changeset/blue-feet-roll.md similarity index 100% rename from .changeset/eight-frogs-roll.md rename to .changeset/blue-feet-roll.md diff --git a/.changeset/cyan-moose-know.md b/.changeset/cyan-moose-know.md new file mode 100644 index 0000000000..2573b29ffd --- /dev/null +++ b/.changeset/cyan-moose-know.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/context': patch +--- + +Make @consume decorator work with optional fields diff --git a/.changeset/thirty-years-camp.md b/.changeset/dirty-mugs-rhyme.md similarity index 100% rename from .changeset/thirty-years-camp.md rename to .changeset/dirty-mugs-rhyme.md diff --git a/.changeset/dull-months-prove.md b/.changeset/dull-months-prove.md new file mode 100644 index 0000000000..dbcde03150 --- /dev/null +++ b/.changeset/dull-months-prove.md @@ -0,0 +1,6 @@ +--- +'@lit-labs/analyzer': minor +'@lit-labs/gen-manifest': minor +--- + +Added support for export, slot, cssPart, and cssProperty to analyzer and manifest generator. Also improved JS project analysis performance. diff --git a/.changeset/five-falcons-suffer.md b/.changeset/five-falcons-suffer.md new file mode 100644 index 0000000000..f158aa283d --- /dev/null +++ b/.changeset/five-falcons-suffer.md @@ -0,0 +1,6 @@ +--- +'@lit-labs/analyzer': minor +'@lit-labs/cli': minor +--- + +Added superclass analysis to ClassDeclaration, along with the ability to query exports of a Module (via `getExport()` and `getResolvedExport()`) and the ability to dereference `Reference`s to the `Declaration` they point to (via `dereference()`). A ClassDeclaration's superClass may be interrogated via `classDeclaration.heritage.superClass.dereference()` (`heritage.superClass` returns a `Reference`, which can be dereferenced to access its superclass's `ClassDeclaration` model. diff --git a/.changeset/fresh-oranges-search.md b/.changeset/fresh-oranges-search.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fresh-oranges-search.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/lemon-llamas-rule.md b/.changeset/lemon-llamas-rule.md new file mode 100644 index 0000000000..16b2e14b1a --- /dev/null +++ b/.changeset/lemon-llamas-rule.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/context': minor +--- + +Rename ContextKey to Context diff --git a/.changeset/moody-colts-trade.md b/.changeset/moody-colts-trade.md deleted file mode 100644 index ec1a0edd26..0000000000 --- a/.changeset/moody-colts-trade.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@lit-labs/analyzer': minor -'@lit-labs/gen-wrapper-angular': minor -'@lit-labs/gen-wrapper-react': minor -'@lit-labs/gen-wrapper-vue': minor ---- - -Fixes bug where global install of CLI resulted in incompatible use of analyzer between CLI packages. Fixes #3234. diff --git a/.changeset/six-buttons-cover.md b/.changeset/six-buttons-cover.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/six-buttons-cover.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-flowers-mate.md b/.changeset/tidy-flowers-mate.md new file mode 100644 index 0000000000..8389a645c4 --- /dev/null +++ b/.changeset/tidy-flowers-mate.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/react': patch +--- + +Filter \_\_forwaredRef from build. diff --git a/.changeset/unlucky-lamps-sing.md b/.changeset/unlucky-lamps-sing.md new file mode 100644 index 0000000000..d9a3128b3a --- /dev/null +++ b/.changeset/unlucky-lamps-sing.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/context': patch +--- + +Allow ContextProvider to be added lazily and still work with ContextRoot diff --git a/.changeset/unlucky-parents-melt.md b/.changeset/unlucky-parents-melt.md new file mode 100644 index 0000000000..a987f2ccc5 --- /dev/null +++ b/.changeset/unlucky-parents-melt.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/context': patch +--- + +Rename @contextProvided and @contextProvider to @consume and @provide diff --git a/.changeset/wild-pillows-carry.md b/.changeset/wild-pillows-carry.md new file mode 100644 index 0000000000..c7f92b3f90 --- /dev/null +++ b/.changeset/wild-pillows-carry.md @@ -0,0 +1,6 @@ +--- +'@lit-labs/cli': minor +'@lit-labs/gen-utils': minor +--- + +Implemented lit init element command diff --git a/.eslintignore b/.eslintignore index 7f656a9644..f627f7c58a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -75,6 +75,7 @@ packages/lit-html/async-directive.* packages/lit-html/polyfill-support.* packages/lit-html/private-ssr-support.* packages/lit-html/static.* +packages/lit-html/is-server.* packages/lit-starter-js/node_modules/* packages/lit-starter-js/docs/* @@ -159,6 +160,9 @@ packages/labs/cli/index.d.ts packages/labs/cli/index.d.ts.map packages/labs/cli/test-gen/ +packages/labs/cli-localize/lib/ +packages/labs/cli-localize/node_modules/ + packages/labs/context/development/ packages/labs/context/test/ packages/labs/context/node_modules/ @@ -174,6 +178,13 @@ packages/labs/eleventy-plugin-lit/test/ # Switches Node into module mode for tests !packages/labs/eleventy-plugin-lit/test/package.json +packages/labs/gen-manifest/index.js +packages/labs/gen-manifest/index.js.map +packages/labs/gen-manifest/index.d.ts +packages/labs/gen-manifest/index.d.ts.map +packages/labs/gen-manifest/test/ +packages/labs/gen-manifest/gen-output/ + packages/labs/gen-utils/lib/ packages/labs/gen-utils/test/ packages/labs/gen-utils/index.js @@ -306,6 +317,8 @@ packages/labs/virtualizer/test/**/*.d.ts.map packages/labs/virtualizer/test/**/*.js packages/labs/virtualizer/test/**/*.js.map packages/labs/virtualizer/test/screenshot/cases/*/actual.*.png +packages/labs/virtualizer/events.d.ts* +packages/labs/virtualizer/events.js* packages/labs/vue-utils/development/ packages/labs/vue-utils/test/ diff --git a/.github/workflows/add-issues-to-project.yml b/.github/workflows/add-issues-to-project.yml index f7fcb83079..882eefa6f6 100644 --- a/.github/workflows/add-issues-to-project.yml +++ b/.github/workflows/add-issues-to-project.yml @@ -24,13 +24,13 @@ jobs: gh api graphql -f query=' query($organization: String!, $project_number: Int!) { organization(login: $organization){ - projectNext(number: $project_number) { + projectV2(number: $project_number) { id } } }' -f organization=$ORGANIZATION -F project_number=$PROJECT_NUMBER > project_data.json - echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV + echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV - name: Add issue to project env: @@ -39,8 +39,8 @@ jobs: run: | gh api graphql -f query=' mutation($project_id:ID!, $issue_id:ID!) { - addProjectNextItem(input: {projectId: $project_id, contentId: $issue_id}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $project_id, contentId: $issue_id}) { + item { id } } diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b2caf19eb..0f6baad1b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,9 +70,7 @@ jobs: - uses: actions/setup-node@v2 with: - # Pin to avoid version with problematic npm. See https://github.com/npm/cli/issues/4980 - # TODO(augustinekim) Unpin when latest node installed by action includes fixed npm - node-version: 16.15.0 + node-version: 16 cache: 'npm' cache-dependency-path: package-lock.json diff --git a/.prettierignore b/.prettierignore index d3f48c00af..17589d7975 100644 --- a/.prettierignore +++ b/.prettierignore @@ -75,6 +75,7 @@ packages/lit-html/async-directive.* packages/lit-html/polyfill-support.* packages/lit-html/private-ssr-support.* packages/lit-html/static.* +packages/lit-html/is-server.* packages/lit-starter-js/node_modules/ packages/lit-starter-js/**/custom-elements.json @@ -145,6 +146,10 @@ packages/labs/cli/index.d.ts packages/labs/cli/index.d.ts.map packages/labs/cli/test-gen/ +packages/labs/cli/test-goldens/ +packages/labs/cli-localize/lib/ +packages/labs/cli-localize/node_modules/ + packages/labs/context/development/ packages/labs/context/test/ packages/labs/context/node_modules/ @@ -160,6 +165,13 @@ packages/labs/eleventy-plugin-lit/test/ # Switches Node into module mode for tests !packages/labs/eleventy-plugin-lit/test/package.json +packages/labs/gen-manifest/index.js +packages/labs/gen-manifest/index.js.map +packages/labs/gen-manifest/index.d.ts +packages/labs/gen-manifest/index.d.ts.map +packages/labs/gen-manifest/test/ +packages/labs/gen-manifest/gen-output/ + packages/labs/gen-utils/lib/ packages/labs/gen-utils/test/ packages/labs/gen-utils/index.js @@ -289,6 +301,8 @@ packages/labs/virtualizer/test/**/*.d.ts.map packages/labs/virtualizer/test/**/*.js packages/labs/virtualizer/test/**/*.js.map packages/labs/virtualizer/test/screenshot/cases/*/actual.*.png +packages/labs/virtualizer/events.d.ts* +packages/labs/virtualizer/events.js* packages/labs/vue-utils/development/ packages/labs/vue-utils/test/ diff --git a/README.md b/README.md index 81aaace752..a4afa0ac3c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@
-Lit + + + + + + Lit + ### Simple. Fast. Web Components. [![Build Status](https://github.com/lit/lit/actions/workflows/tests.yml/badge.svg)](https://github.com/lit/lit/actions/workflows/tests.yml) [![Published on npm](https://img.shields.io/npm/v/lit.svg?logo=npm)](https://www.npmjs.com/package/lit) -[![Join our Slack](https://img.shields.io/badge/slack-join%20chat-4a154b.svg?logo=slack)](https://lit.dev/slack-invite/) +[![Join our Discord](https://img.shields.io/badge/discord-join%20chat-5865F2.svg?logo=discord&logoColor=fff)](https://lit.dev/discord/) [![Mentioned in Awesome Lit](https://awesome.re/mentioned-badge.svg)](https://github.com/web-padawan/awesome-lit)
diff --git a/lit-next.code-workspace b/lit-next.code-workspace index 92c91e42f4..7cd051ab40 100644 --- a/lit-next.code-workspace +++ b/lit-next.code-workspace @@ -56,6 +56,26 @@ "name": "cli", "path": "packages/labs/cli" }, + { + "name": "gen-manifest", + "path": "packages/labs/gen-manifest" + }, + { + "name": "gen-react", + "path": "packages/labs/gen-manifest" + }, + { + "name": "gen-angular", + "path": "packages/labs/gen-manifest" + }, + { + "name": "gen-vue", + "path": "packages/labs/gen-manifest" + }, + { + "name": "gen-utils", + "path": "packages/labs/gen-manifest" + }, { "name": "lit-monorepo", "path": "." diff --git a/package-lock.json b/package-lock.json index 6809531d52..4c79cf1a5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@web/test-runner-mocha": "^0.7.5", "@web/test-runner-playwright": "^0.8.9", "@web/test-runner-saucelabs": "^0.8.0", + "cross-env": "^7.0.3", "eslint": "^8.13.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-no-only-tests": "^2.4.0", @@ -2563,6 +2564,10 @@ "resolved": "packages/labs/cli", "link": true }, + "node_modules/@lit-labs/cli-localize": { + "resolved": "packages/labs/cli-localize", + "link": true + }, "node_modules/@lit-labs/context": { "resolved": "packages/labs/context", "link": true @@ -2571,6 +2576,10 @@ "resolved": "packages/labs/eleventy-plugin-lit", "link": true }, + "node_modules/@lit-labs/gen-manifest": { + "resolved": "packages/labs/gen-manifest", + "link": true + }, "node_modules/@lit-labs/gen-utils": { "resolved": "packages/labs/gen-utils", "link": true @@ -3590,9 +3599,9 @@ } }, "node_modules/@testim/chrome-version": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.2.tgz", - "integrity": "sha512-1c4ZOETSRpI0iBfIFUqU4KqwBAB2lHUAlBjZz/YqOHqwM9dTTzjV6Km0ZkiEiSCx/tLr1BtESIKyWWMww+RUqw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.3.tgz", + "integrity": "sha512-g697J3WxV/Zytemz8aTuKjTGYtta9+02kva3C1xc7KXB8GdbfE1akGJIsZLyY/FSh2QrnE+fiB7vmWU3XNcb6A==", "dev": true }, "node_modules/@tsconfig/node10": { @@ -7395,17 +7404,17 @@ } }, "node_modules/chromedriver": { - "version": "105.0.0", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-105.0.0.tgz", - "integrity": "sha512-BX3GOUW5m6eiW9cVVF8hw+EFxvrGqYCxbwOqnpk8PjbNFqL5xjy7yel+e6ilJPjckAYFutMKs8XJvOs/W85vvg==", + "version": "107.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-107.0.3.tgz", + "integrity": "sha512-jmzpZgctCRnhYAn0l/NIjP4vYN3L8GFVbterTrRr2Ly3W5rFMb9H8EKGuM5JCViPKSit8FbE718kZTEt3Yvffg==", "dev": true, "hasInstallScript": true, "dependencies": { - "@testim/chrome-version": "^1.1.2", - "axios": "^0.27.2", - "del": "^6.0.0", + "@testim/chrome-version": "^1.1.3", + "axios": "^1.1.3", + "compare-versions": "^5.0.1", "extract-zip": "^2.0.1", - "https-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^1.1.0", "tcp-port-used": "^1.0.1" }, @@ -7413,17 +7422,18 @@ "chromedriver": "bin/chromedriver" }, "engines": { - "node": ">=10" + "node": ">=14" } }, "node_modules/chromedriver/node_modules/axios": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", - "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", + "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", "dev": true, "dependencies": { - "follow-redirects": "^1.14.9", - "form-data": "^4.0.0" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/chromedriver/node_modules/form-data": { @@ -7963,6 +7973,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/compare-versions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.1.tgz", + "integrity": "sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ==", + "dev": true + }, "node_modules/compatfactory": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/compatfactory/-/compatfactory-1.0.1.tgz", @@ -8333,6 +8349,24 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -8442,9 +8476,9 @@ }, "node_modules/custom-elements-manifest": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-1.0.0.tgz", - "integrity": "sha512-j59k0ExGCKA8T6Mzaq+7axc+KVHwpEphEERU7VZ99260npu/p/9kd+Db+I3cGKxHkM5y6q5gnlXn00mzRQkX2A==", - "dev": true + "resolved": "git+ssh://git@github.com/webcomponents/custom-elements-manifest.git#f893b205ec661f485772ace6eb7d0e15efa958d4", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/custom-event": { "version": "1.0.1", @@ -8857,28 +8891,6 @@ "node": ">=0.10.0" } }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "dev": true, - "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -12989,24 +13001,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -14661,10 +14655,6 @@ "lit-analyzer": "cli.js" } }, - "node_modules/lit-analyzer-test-files": { - "resolved": "packages/labs/analyzer/test-files/basic-elements", - "link": true - }, "node_modules/lit-analyzer/node_modules/@nodelib/fs.stat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", @@ -23803,7 +23793,7 @@ "tachometer": "^0.7.0" }, "devDependencies": { - "chromedriver": "^105.0.0" + "chromedriver": "^107.0.3" } }, "packages/internal-scripts": { @@ -23833,29 +23823,26 @@ }, "packages/labs/analyzer": { "name": "@lit-labs/analyzer", - "version": "0.2.2", + "version": "0.4.0", "license": "BSD-3-Clause", "dependencies": { "package-json-type": "^1.0.3", "typescript": "~4.7.4" - }, - "devDependencies": { - "lit-analyzer-test-files": "./test-files/basic-elements/" } }, "packages/labs/analyzer/test-files/basic-elements": { "name": "@lit-internal/test-basic-elements", - "dev": true, + "extraneous": true, "dependencies": { "lit": "^2.0.0" } }, "packages/labs/cli": { "name": "@lit-labs/cli", - "version": "0.1.0", + "version": "0.2.1", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit/localize-tools": "^0.6.1", "chalk": "^5.0.1", @@ -23877,6 +23864,33 @@ "node": ">=14.8.0" } }, + "packages/labs/cli-localize": { + "name": "@lit-labs/cli-localize", + "version": "0.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/localize-tools": "^0.6.3" + }, + "devDependencies": { + "typescript": "~4.6.2" + }, + "engines": { + "node": ">=14.8.0" + } + }, + "packages/labs/cli-localize/node_modules/typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "packages/labs/cli/node_modules/chalk": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", @@ -23918,12 +23932,41 @@ "node": ">=12.16.0" } }, + "packages/labs/gen-manifest": { + "name": "@lit-labs/gen-manifest", + "version": "0.0.2", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/analyzer": "^0.4.0", + "@lit-labs/gen-utils": "^0.1.0", + "custom-elements-manifest": "^2.0.0" + }, + "devDependencies": { + "@lit-internal/tests": "^0.0.0", + "@types/node": "^17.0.31", + "uvu": "^0.5.3" + }, + "engines": { + "node": ">=14.8.0" + } + }, + "packages/labs/gen-manifest/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "dev": true + }, + "packages/labs/gen-manifest/node_modules/custom-elements-manifest": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-2.0.0.tgz", + "integrity": "sha512-1MmhBRszwnNYqn56nkMeHXn/Zlh998+6Yal3wedbXI7NzKPG02GDgjspdN1NiuDtt2yb5n94JvFwPOF7Prnocg==" + }, "packages/labs/gen-utils": { "name": "@lit-labs/gen-utils", - "version": "0.1.0", + "version": "0.1.2", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.2.0" + "@lit-labs/analyzer": "^0.4.0" }, "devDependencies": { "@lit-internal/tests": "^0.0.0" @@ -23934,10 +23977,10 @@ }, "packages/labs/gen-wrapper-angular": { "name": "@lit-labs/gen-wrapper-angular", - "version": "0.0.1", + "version": "0.0.3", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0" }, "devDependencies": { @@ -23952,10 +23995,10 @@ }, "packages/labs/gen-wrapper-react": { "name": "@lit-labs/gen-wrapper-react", - "version": "0.1.0", + "version": "0.2.1", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0" }, "devDependencies": { @@ -23967,10 +24010,10 @@ }, "packages/labs/gen-wrapper-vue": { "name": "@lit-labs/gen-wrapper-vue", - "version": "0.1.1", + "version": "0.2.1", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit-labs/vue-utils": "^0.1.0" }, @@ -23995,7 +24038,7 @@ }, "packages/labs/observers": { "name": "@lit-labs/observers", - "version": "1.0.2", + "version": "1.1.0", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.1.0" @@ -24007,7 +24050,7 @@ }, "packages/labs/react": { "name": "@lit-labs/react", - "version": "1.0.8", + "version": "1.1.0", "license": "BSD-3-Clause", "devDependencies": { "@lit-internal/scripts": "^1.0.0", @@ -24116,7 +24159,7 @@ }, "packages/labs/task": { "name": "@lit-labs/task", - "version": "1.1.3", + "version": "2.0.0", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.1.0" @@ -24178,12 +24221,12 @@ } }, "packages/lit": { - "version": "2.3.1", + "version": "2.4.1", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.4.0", "lit-element": "^3.2.0", - "lit-html": "^2.3.0" + "lit-html": "^2.4.0" }, "devDependencies": { "@lit-internal/scripts": "^1.0.0", @@ -24209,7 +24252,7 @@ } }, "packages/lit-html": { - "version": "2.3.1", + "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" @@ -24417,7 +24460,7 @@ }, "packages/reactive-element": { "name": "@lit/reactive-element", - "version": "1.4.1", + "version": "1.4.2", "license": "BSD-3-Clause", "devDependencies": { "@babel/cli": "^7.14.6", @@ -26297,7 +26340,7 @@ "version": "file:packages/benchmarks", "requires": { "@lit/reactive-element": "^1.1.0", - "chromedriver": "^105.0.0", + "chromedriver": "^107.0.3", "lit-element": "^3.1.0", "lit-html": "^2.1.0", "tachometer": "^0.7.0" @@ -26391,7 +26434,6 @@ "@lit-labs/analyzer": { "version": "file:packages/labs/analyzer", "requires": { - "lit-analyzer-test-files": "./test-files/basic-elements/", "package-json-type": "^1.0.3", "typescript": "~4.7.4" } @@ -26400,7 +26442,7 @@ "version": "file:packages/labs/cli", "requires": { "@lit-internal/tests": "^0.0.0", - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit/localize-tools": "^0.6.1", "@types/command-line-args": "^5.2.0", @@ -26420,6 +26462,21 @@ } } }, + "@lit-labs/cli-localize": { + "version": "file:packages/labs/cli-localize", + "requires": { + "@lit/localize-tools": "^0.6.3", + "typescript": "~4.6.2" + }, + "dependencies": { + "typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true + } + } + }, "@lit-labs/context": { "version": "file:packages/labs/context", "requires": { @@ -26439,11 +26496,35 @@ "rimraf": "^3.0.2" } }, + "@lit-labs/gen-manifest": { + "version": "file:packages/labs/gen-manifest", + "requires": { + "@lit-internal/tests": "^0.0.0", + "@lit-labs/analyzer": "^0.4.0", + "@lit-labs/gen-utils": "^0.1.0", + "@types/node": "^17.0.31", + "custom-elements-manifest": "^2.0.0", + "uvu": "^0.5.3" + }, + "dependencies": { + "@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "dev": true + }, + "custom-elements-manifest": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-2.0.0.tgz", + "integrity": "sha512-1MmhBRszwnNYqn56nkMeHXn/Zlh998+6Yal3wedbXI7NzKPG02GDgjspdN1NiuDtt2yb5n94JvFwPOF7Prnocg==" + } + } + }, "@lit-labs/gen-utils": { "version": "file:packages/labs/gen-utils", "requires": { "@lit-internal/tests": "^0.0.0", - "@lit-labs/analyzer": "^0.2.0" + "@lit-labs/analyzer": "^0.4.0" } }, "@lit-labs/gen-wrapper-angular": { @@ -26451,7 +26532,7 @@ "requires": { "@esm-bundle/chai": "^4.1.5", "@lit-internal/tests": "0.0.0", - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@types/chai": "^4.0.1", "@types/mocha": "^9.0.0" @@ -26461,7 +26542,7 @@ "version": "file:packages/labs/gen-wrapper-react", "requires": { "@lit-internal/tests": "^0.0.0", - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0" } }, @@ -26469,7 +26550,7 @@ "version": "file:packages/labs/gen-wrapper-vue", "requires": { "@lit-internal/tests": "^0.0.0", - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit-labs/vue-utils": "^0.1.0" } @@ -27527,9 +27608,9 @@ } }, "@testim/chrome-version": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.2.tgz", - "integrity": "sha512-1c4ZOETSRpI0iBfIFUqU4KqwBAB2lHUAlBjZz/YqOHqwM9dTTzjV6Km0ZkiEiSCx/tLr1BtESIKyWWMww+RUqw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.3.tgz", + "integrity": "sha512-g697J3WxV/Zytemz8aTuKjTGYtta9+02kva3C1xc7KXB8GdbfE1akGJIsZLyY/FSh2QrnE+fiB7vmWU3XNcb6A==", "dev": true }, "@tsconfig/node10": { @@ -30638,28 +30719,29 @@ } }, "chromedriver": { - "version": "105.0.0", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-105.0.0.tgz", - "integrity": "sha512-BX3GOUW5m6eiW9cVVF8hw+EFxvrGqYCxbwOqnpk8PjbNFqL5xjy7yel+e6ilJPjckAYFutMKs8XJvOs/W85vvg==", + "version": "107.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-107.0.3.tgz", + "integrity": "sha512-jmzpZgctCRnhYAn0l/NIjP4vYN3L8GFVbterTrRr2Ly3W5rFMb9H8EKGuM5JCViPKSit8FbE718kZTEt3Yvffg==", "dev": true, "requires": { - "@testim/chrome-version": "^1.1.2", - "axios": "^0.27.2", - "del": "^6.0.0", + "@testim/chrome-version": "^1.1.3", + "axios": "^1.1.3", + "compare-versions": "^5.0.1", "extract-zip": "^2.0.1", - "https-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^1.1.0", "tcp-port-used": "^1.0.1" }, "dependencies": { "axios": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", - "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", + "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", "dev": true, "requires": { - "follow-redirects": "^1.14.9", - "form-data": "^4.0.0" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "form-data": { @@ -31084,6 +31166,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "compare-versions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.1.tgz", + "integrity": "sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ==", + "dev": true + }, "compatfactory": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/compatfactory/-/compatfactory-1.0.1.tgz", @@ -31380,6 +31468,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, "cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -31473,10 +31570,9 @@ "dev": true }, "custom-elements-manifest": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-1.0.0.tgz", - "integrity": "sha512-j59k0ExGCKA8T6Mzaq+7axc+KVHwpEphEERU7VZ99260npu/p/9kd+Db+I3cGKxHkM5y6q5gnlXn00mzRQkX2A==", - "dev": true + "version": "git+ssh://git@github.com/webcomponents/custom-elements-manifest.git#f893b205ec661f485772ace6eb7d0e15efa958d4", + "dev": true, + "from": "custom-elements-manifest@1.0.0" }, "custom-event": { "version": "1.0.1", @@ -31791,22 +31887,6 @@ "isobject": "^3.0.1" } }, - "del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "dev": true, - "requires": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -34858,18 +34938,6 @@ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", "dev": true }, - "is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "dev": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, "is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -36183,7 +36251,7 @@ "@webcomponents/template": "^1.4.4", "@webcomponents/webcomponentsjs": "^2.6.0", "lit-element": "^3.2.0", - "lit-html": "^2.3.0", + "lit-html": "^2.4.0", "tslib": "^2.0.3" } }, @@ -36363,12 +36431,6 @@ } } }, - "lit-analyzer-test-files": { - "version": "file:packages/labs/analyzer/test-files/basic-elements", - "requires": { - "lit": "^2.0.0" - } - }, "lit-element": { "version": "file:packages/lit-element", "requires": { diff --git a/package.json b/package.json index db0082bb0c..65cf1e01b3 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "@web/test-runner-mocha": "^0.7.5", "@web/test-runner-playwright": "^0.8.9", "@web/test-runner-saucelabs": "^0.8.0", + "cross-env": "^7.0.3", "eslint": "^8.13.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-no-only-tests": "^2.4.0", diff --git a/packages/benchmarks/package.json b/packages/benchmarks/package.json index 1c4ddda3a9..dfaffc4d67 100644 --- a/packages/benchmarks/package.json +++ b/packages/benchmarks/package.json @@ -68,6 +68,6 @@ "tachometer": "^0.7.0" }, "devDependencies": { - "chromedriver": "^105.0.0" + "chromedriver": "^107.0.3" } } diff --git a/packages/labs/analyzer/.vscode/launch.json b/packages/labs/analyzer/.vscode/launch.json index 225b6c951e..f5e40122fb 100644 --- a/packages/labs/analyzer/.vscode/launch.json +++ b/packages/labs/analyzer/.vscode/launch.json @@ -9,7 +9,7 @@ "request": "launch", "name": "Test", "skipFiles": ["/**"], - "program": "${workspaceFolder}/node_modules/.bin/uvu", + "program": "${workspaceFolder}/../../../node_modules/uvu/bin.js", "args": ["test", "\\_test\\.js$"], "outFiles": ["${workspaceFolder}/**/*.js"], "console": "integratedTerminal" diff --git a/packages/labs/analyzer/CHANGELOG.md b/packages/labs/analyzer/CHANGELOG.md index d6d405fc46..7dd7c301d2 100644 --- a/packages/labs/analyzer/CHANGELOG.md +++ b/packages/labs/analyzer/CHANGELOG.md @@ -1,5 +1,25 @@ # @lit-labs/analyzer +## 0.4.0 + +### Minor Changes + +- [#3333](https://github.com/lit/lit/pull/3333) [`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e) - Cache Module models based on dependencies. + +### Patch Changes + +- [#2990](https://github.com/lit/lit/pull/2990) [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771) - Added initial implementation of custom elements manifest generator (WIP). + +## 0.3.0 + +### Minor Changes + +- [#3304](https://github.com/lit/lit/pull/3304) [`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a) - Added support for analyzing JavaScript files. + +- [#3288](https://github.com/lit/lit/pull/3288) [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e) - Refactored Analyzer into better fit for use in plugins. Analyzer class now takes a ts.Program, and PackageAnalyzer takes a package path and creates a program to analyze a package on the filesystem. + +- [#3254](https://github.com/lit/lit/pull/3254) [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9) - Fixes bug where global install of CLI resulted in incompatible use of analyzer between CLI packages. Fixes #3234. + ## 0.2.2 ### Patch Changes diff --git a/packages/labs/analyzer/package.json b/packages/labs/analyzer/package.json index 524757cea0..f9ab4cc6a9 100644 --- a/packages/labs/analyzer/package.json +++ b/packages/labs/analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@lit-labs/analyzer", - "version": "0.2.2", + "version": "0.4.0", "publishConfig": { "access": "public" }, @@ -36,7 +36,7 @@ }, "test": { "#comment": "The quotes around the file regex must be double quotes on windows!", - "command": "uvu test \"_test\\.js$\"", + "command": "cross-env NODE_OPTIONS=--enable-source-maps uvu test \"_test\\.js$\"", "dependencies": [ "build", "../../lit:build" @@ -58,8 +58,5 @@ "dependencies": { "package-json-type": "^1.0.3", "typescript": "~4.7.4" - }, - "devDependencies": { - "lit-analyzer-test-files": "./test-files/basic-elements/" } } diff --git a/packages/labs/analyzer/src/index.ts b/packages/labs/analyzer/src/index.ts index cd3edd0d8b..3aabd2b437 100644 --- a/packages/labs/analyzer/src/index.ts +++ b/packages/labs/analyzer/src/index.ts @@ -5,16 +5,19 @@ */ export {Analyzer} from './lib/analyzer.js'; +export {createPackageAnalyzer} from './lib/analyze-package.js'; export type { Package, Module, Reference, Type, + Event, Declaration, VariableDeclaration, ClassDeclaration, LitElementDeclaration, + LitElementExport, PackageJson, ModuleWithLitElementDeclarations, } from './lib/model.js'; diff --git a/packages/labs/analyzer/src/lib/analyze-package.ts b/packages/labs/analyzer/src/lib/analyze-package.ts new file mode 100644 index 0000000000..959b3a5c86 --- /dev/null +++ b/packages/labs/analyzer/src/lib/analyze-package.ts @@ -0,0 +1,93 @@ +import ts from 'typescript'; +import {AbsolutePath} from './paths.js'; +import * as path from 'path'; +import {DiagnosticsError} from './errors.js'; +import {Analyzer} from './analyzer.js'; + +/** + * Returns an analyzer for a Lit npm package based on a filesystem path. + * + * The path may specify a package root folder, or a specific tsconfig file. When + * specifying a folder, if no tsconfig.json file is found directly in the root + * folder, the project will be analyzed as JavaScript. + */ +export const createPackageAnalyzer = (packagePath: AbsolutePath) => { + // This logic accepts either a path to folder containing a tsconfig.json + // directly inside it or a path to a specific tsconfig file. If no tsconfig + // file is found, we fallback to creating a Javascript program. + const isDirectory = ts.sys.directoryExists(packagePath); + const configFileName = isDirectory + ? path.join(packagePath, 'tsconfig.json') + : packagePath; + let commandLine: ts.ParsedCommandLine; + if (ts.sys.fileExists(configFileName)) { + const configFile = ts.readConfigFile(configFileName, ts.sys.readFile); + commandLine = ts.parseJsonConfigFileContent( + configFile.config /* json */, + ts.sys /* host */, + packagePath /* basePath */, + undefined /* existingOptions */, + path.relative(packagePath, configFileName) /* configFileName */ + ); + } else if (isDirectory) { + console.info(`No tsconfig.json found; assuming package is JavaScript.`); + commandLine = ts.parseJsonConfigFileContent( + { + compilerOptions: { + // TODO(kschaaf): probably want to make this configurable + module: 'ES2020', + lib: ['es2020', 'DOM'], + allowJs: true, + skipLibCheck: true, + skipDefaultLibCheck: true, + // With `allowJs: true`, the program will automatically include every + // .d.ts file under node_modules/@types regardless of whether the + // program imported modules associated with those types, which can + // dramatically slow down the program analysis (this does not + // automatically happen when allowJs is false). For now, eliminating + // `typeRoots` fixes the automatic over-inclusion of .d.ts files as + // long as nodeResolution is properly set (it will still import .d.ts + // files into the project as expected based on imports). It may + // however cause a failure to find definitely-typed .d.ts files for + // imports in a JS project, but it seems unlikely these would be + // installed anyway. + typeRoots: [], + moduleResolution: 'node', + }, + include: ['**/*.js'], + }, + ts.sys /* host */, + packagePath /* basePath */ + ); + } else { + throw new Error( + `The specified path '${packagePath}' was not a folder or a tsconfig file.` + ); + } + + // Ensure that `parent` nodes are set in the AST by creating a compiler + // host with this configuration; without these, `getText()` and other + // API's that require crawling up the AST tree to find the source file + // text may fail + const compilerHost = ts.createCompilerHost( + commandLine.options, + /* setParentNodes */ true + ); + const program = ts.createProgram( + commandLine.fileNames, + commandLine.options, + compilerHost + ); + + const analyzer = new Analyzer({getProgram: () => program, fs: ts.sys, path}); + + const diagnostics = program.getSemanticDiagnostics(); + if (diagnostics.length > 0) { + throw new DiagnosticsError( + diagnostics, + `Error analyzing package '${packagePath}': Please fix errors first` + ); + } + + return analyzer; +}; diff --git a/packages/labs/analyzer/src/lib/analyzer.ts b/packages/labs/analyzer/src/lib/analyzer.ts index 203427683f..02119cafb7 100644 --- a/packages/labs/analyzer/src/lib/analyzer.ts +++ b/packages/labs/analyzer/src/lib/analyzer.ts @@ -5,91 +5,117 @@ */ import ts from 'typescript'; -import {Package, PackageJson} from './model.js'; -import {ProgramContext} from './program-context.js'; +import {Package, PackageJson, AnalyzerInterface, Module} from './model.js'; import {AbsolutePath} from './paths.js'; -import * as fs from 'fs'; -import * as path from 'path'; import {getModule} from './javascript/modules.js'; export {PackageJson}; +import { + getPackageInfo, + getPackageRootForModulePath, +} from './javascript/packages.js'; + +export interface AnalyzerInit { + getProgram: () => ts.Program; + fs: AnalyzerInterface['fs']; + path: AnalyzerInterface['path']; + basePath?: AbsolutePath; +} /** - * An analyzer for Lit npm packages + * An analyzer for Lit typescript modules. */ -export class Analyzer { - readonly packageRoot: AbsolutePath; - readonly programContext: ProgramContext; +export class Analyzer implements AnalyzerInterface { + // Cache of Module models by path; invalidated when the sourceFile + // or any of its dependencies change + readonly moduleCache = new Map(); + private readonly _getProgram: () => ts.Program; + readonly fs: AnalyzerInterface['fs']; + readonly path: AnalyzerInterface['path']; + private _commandLine: ts.ParsedCommandLine | undefined = undefined; - /** - * @param packageRoot The root directory of the package to analyze. Currently - * this directory must have a tsconfig.json and package.json. - */ - constructor(packageRoot: AbsolutePath) { - this.packageRoot = packageRoot; + constructor(init: AnalyzerInit) { + this._getProgram = init.getProgram; + this.fs = init.fs; + this.path = init.path; + } - // TODO(kschaaf): Consider moving the package.json and tsconfig.json - // to analyzePackage() or move it to an async factory function that - // passes these to the constructor as arguments. - const packageJsonFilename = path.join(packageRoot, 'package.json'); - let packageJsonText; - try { - packageJsonText = fs.readFileSync(packageJsonFilename, 'utf8'); - } catch (e) { - throw new Error(`package.json not found at ${packageJsonFilename}`); - } - let packageJson; - try { - packageJson = JSON.parse(packageJsonText); - } catch (e) { - throw new Error(`Malformed package.json found at ${packageJsonFilename}`); - } - if (packageJson.name === undefined) { - throw new Error( - `package.json in ${packageJsonFilename} did not have a name.` - ); - } + get program() { + return this._getProgram(); + } - const configFileName = ts.findConfigFile( - packageRoot, - ts.sys.fileExists, - 'tsconfig.json' - ); - if (configFileName === undefined) { - // TODO: use a hard-coded tsconfig for JS projects. - throw new Error(`tsconfig.json not found in ${packageRoot}`); - } - const configFile = ts.readConfigFile(configFileName, ts.sys.readFile); - // Note `configFileName` is optional but must be set for - // `getOutputFileNames` to work correctly; however, it must be relative to - // `packageRoot` - const commandLine = ts.parseJsonConfigFileContent( - configFile.config /* json */, - ts.sys /* host */, - packageRoot /* basePath */, - undefined /* existingOptions */, - path.relative(packageRoot, configFileName) /* configFileName */ - ); + get commandLine() { + return (this._commandLine ??= getCommandLineFromProgram(this)); + } - this.programContext = new ProgramContext( - packageRoot, - commandLine, - packageJson - ); + getModule(modulePath: AbsolutePath) { + return getModule(modulePath, this); } - analyzePackage() { - const rootFileNames = this.programContext.program.getRootFileNames(); + getPackage() { + const rootFileNames = this.program.getRootFileNames(); + + // Find the package.json for this package based on the first root filename + // in the program (we assume all root files in a program belong to the same + // package) + if (rootFileNames.length === 0) { + throw new Error('No source files found in package.'); + } + const packageInfo = getPackageInfo(rootFileNames[0] as AbsolutePath, this); return new Package({ - rootDir: this.packageRoot, + ...packageInfo, modules: rootFileNames.map((fileName) => getModule( - this.programContext.program.getSourceFile(path.normalize(fileName))!, - this.programContext + this.path.normalize(fileName) as AbsolutePath, + this, + packageInfo ) ), - tsConfig: this.programContext.commandLine, - packageJson: this.programContext.packageJson, }); } } + +/** + * Extracts a `ts.ParsedCommandLine` (essentially, the key bits of a + * `tsconfig.json`) from the analyzer's `ts.Program`. + * + * The `ts.getOutputFileNames()` function must be passed a + * `ts.ParsedCommandLine`; since not all usages of the analyzer create the + * program directly from a tsconfig (plugins get passed the program only), + * this allows backing the `ParsedCommandLine` out of an existing program. + */ +export const getCommandLineFromProgram = ( + analyzer: Analyzer +): ts.ParsedCommandLine => { + const compilerOptions = analyzer.program.getCompilerOptions(); + const files = analyzer.program.getRootFileNames(); + const json = { + files, + compilerOptions, + }; + if (compilerOptions.configFilePath !== undefined) { + // For a TS project, derive the package root from the config file path + const packageRoot = analyzer.path.basename( + compilerOptions.configFilePath as string + ); + return ts.parseJsonConfigFileContent( + json, + ts.sys, + packageRoot, + undefined, + compilerOptions.configFilePath as string + ); + } else { + // Otherwise, this is a JS project; we can determine the package root + // based on the package.json location; we can look that up based on + // the first root file + const packageRoot = getPackageRootForModulePath( + files[0] as AbsolutePath, + analyzer + // Note we don't pass a configFilePath since we don't have one; This just + // means we can't use ts.getOutputFileNames(), which we isn't needed in + // JS program + ); + return ts.parseJsonConfigFileContent(json, ts.sys, packageRoot); + } +}; diff --git a/packages/labs/analyzer/src/lib/javascript/classes.ts b/packages/labs/analyzer/src/lib/javascript/classes.ts index 4d45028791..50aabacfcc 100644 --- a/packages/labs/analyzer/src/lib/javascript/classes.ts +++ b/packages/labs/analyzer/src/lib/javascript/classes.ts @@ -7,20 +7,130 @@ /** * @fileoverview * - * Utilities for working with classes + * Utilities for analyzing class declarations */ import ts from 'typescript'; -import {ClassDeclaration} from '../model.js'; -import {ProgramContext} from '../program-context.js'; +import {DiagnosticsError} from '../errors.js'; +import { + ClassDeclaration, + AnalyzerInterface, + DeclarationInfo, + ClassHeritage, + Reference, +} from '../model.js'; +import { + isLitElementSubclass, + getLitElementDeclaration, +} from '../lit-element/lit-element.js'; +import {hasExportKeyword, getReferenceForIdentifier} from '../references.js'; -export const getClassDeclaration = ( +/** + * Returns an analyzer `ClassDeclaration` model for the given + * ts.ClassDeclaration. + */ +const getClassDeclaration = ( declaration: ts.ClassDeclaration, - _programContext: ProgramContext -): ClassDeclaration => { + analyzer: AnalyzerInterface +) => { + if (isLitElementSubclass(declaration, analyzer)) { + return getLitElementDeclaration(declaration, analyzer); + } return new ClassDeclaration({ // TODO(kschaaf): support anonymous class expressions when assigned to a const name: declaration.name?.text ?? '', node: declaration, + getHeritage: () => getHeritage(declaration, analyzer), }); }; + +/** + * Returns the name of a class declaration. + */ +const getClassDeclarationName = (declaration: ts.ClassDeclaration) => { + const name = + declaration.name?.text ?? + // The only time a class declaration will not have a name is when it is + // a default export, aka `export default class { }` + (declaration.modifiers?.some((s) => s.kind === ts.SyntaxKind.DefaultKeyword) + ? 'default' + : undefined); + if (name === undefined) { + throw new DiagnosticsError( + declaration, + 'Unexpected class declaration without a name' + ); + } + return name; +}; + +/** + * Returns name and model factory for a class declaration. + */ +export const getClassDeclarationInfo = ( + declaration: ts.ClassDeclaration, + analyzer: AnalyzerInterface +): DeclarationInfo => { + return { + name: getClassDeclarationName(declaration), + factory: () => getClassDeclaration(declaration, analyzer), + isExport: hasExportKeyword(declaration), + }; +}; + +/** + * Returns the superClass and any applied mixins for a given class declaration. + */ +export const getHeritage = ( + declaration: ts.ClassLikeDeclarationBase, + analyzer: AnalyzerInterface +): ClassHeritage => { + const extendsClause = declaration.heritageClauses?.find( + (c) => c.token === ts.SyntaxKind.ExtendsKeyword + ); + if (extendsClause !== undefined) { + if (extendsClause.types.length !== 1) { + throw new DiagnosticsError( + extendsClause, + 'Internal error: did not expect extends clause to have multiple types' + ); + } + return getHeritageFromExpression( + extendsClause.types[0].expression, + analyzer + ); + } + // No extends clause; return empty heritage + return { + mixins: [], + superClass: undefined, + }; +}; + +export const getHeritageFromExpression = ( + expression: ts.Expression, + analyzer: AnalyzerInterface +): ClassHeritage => { + // TODO(kschaaf): Support for extracting mixing applications from the heritage + // expression https://github.com/lit/lit/issues/2998 + const mixins: Reference[] = []; + const superClass = getSuperClass(expression, analyzer); + return { + superClass, + mixins, + }; +}; + +export const getSuperClass = ( + expression: ts.Expression, + analyzer: AnalyzerInterface +): Reference => { + // TODO(kschaaf) Could add support for inline class expressions here as well + if (ts.isIdentifier(expression)) { + return getReferenceForIdentifier(expression, analyzer); + } + throw new DiagnosticsError( + expression, + `Expected expression to be a concrete superclass. Mixins are not yet supported.` + ); +}; diff --git a/packages/labs/analyzer/src/lib/javascript/jsdoc.ts b/packages/labs/analyzer/src/lib/javascript/jsdoc.ts new file mode 100644 index 0000000000..5b448618cc --- /dev/null +++ b/packages/labs/analyzer/src/lib/javascript/jsdoc.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import ts from 'typescript'; +import {DiagnosticsError} from '../errors.js'; +import {AnalyzerInterface, NamedJSDocInfo, NodeJSDocInfo} from '../model.js'; + +/** + * @fileoverview + * + * Utilities for parsing JSDoc comments. + */ + +/** + * Remove line feeds from JSDoc summaries, so they are normalized to + * unix `\n` line endings. + */ +const normalizeLineEndings = (s: string) => s.replace(/\r/g, ''); + +// Regex for parsing name, summary, and descriptions from JSDoc comments +const parseNameDescSummaryRE = + /^\s*(?[^\s:]+)([\s-:]+)?(?[^\n\r]+)?([\n\r]+(?[\s\S]*))?$/m; + +// Regex for parsing summary and description from JSDoc comments +const parseDescSummaryRE = + /^\s*(?[^\n\r]+)\r?\n\r?\n(?[\s\S]*)$/m; + +/** + * Parses name, summary, and description from JSDoc tag for things like @slot, + * @cssPart, and @cssProp. + * + * Supports the following patterns following the tag (TS parses the tag for us): + * * @slot name + * * @slot name summary + * * @slot name: summary + * * @slot name - summary + * * @slot name - summary... + * * + * * description (multiline) + */ +export const parseNameDescSummary = ( + tag: ts.JSDocTag +): NamedJSDocInfo | undefined => { + const {comment} = tag; + if (comment == undefined) { + return undefined; + } + if (typeof comment !== 'string') { + throw new DiagnosticsError(tag, `Internal error: unsupported node type`); + } + const nameDescSummary = comment.match(parseNameDescSummaryRE); + if (nameDescSummary === null) { + throw new DiagnosticsError(tag, 'Unexpected JSDoc format'); + } + const {name, description, summary} = nameDescSummary.groups!; + const v: NamedJSDocInfo = {name}; + if (summary !== undefined) { + v.summary = summary; + } + if (description !== undefined) { + v.description = normalizeLineEndings(description); + } + return v; +}; + +/** + * Parse summary, description, and deprecated information from JSDoc comments on + * a given node. + */ +export const parseNodeJSDocInfo = ( + node: ts.Node, + analyzer: AnalyzerInterface +): NodeJSDocInfo => { + const v: NodeJSDocInfo = {}; + const jsDocTags = ts.getJSDocTags(node); + if (jsDocTags !== undefined) { + for (const tag of jsDocTags) { + const {comment} = tag; + if (comment !== undefined && typeof comment !== 'string') { + throw new DiagnosticsError( + tag, + `Internal error: unsupported node type` + ); + } + switch (tag.tagName.text) { + case 'description': + if (comment !== undefined) { + v.description = normalizeLineEndings(comment); + } + break; + case 'summary': + if (comment !== undefined) { + v.summary = comment; + } + break; + case 'deprecated': + v.deprecated = comment !== undefined ? comment : true; + break; + } + } + } + // If we didn't have a tagged @description, we'll use any untagged text as + // the description. If we also didn't have a @summary and the untagged text + // has a line break, we'll use the first chunk as the summary, and the + // remainder as a description. + if (v.description === undefined) { + // Strangely, it only seems possible to get the untagged jsdoc comments + // via the typechecker/symbol API + const checker = analyzer.program.getTypeChecker(); + const symbol = + checker.getSymbolAtLocation(node) ?? + checker.getTypeAtLocation(node).getSymbol(); + const comments = symbol?.getDocumentationComment(checker); + if (comments !== undefined) { + const comment = comments.map((c) => c.text).join('\n'); + if (v.summary !== undefined) { + v.description = normalizeLineEndings(comment); + } else { + const info = comment.match(parseDescSummaryRE); + if (info === null) { + v.description = normalizeLineEndings(comment); + } else { + const {summary, description} = info.groups!; + v.summary = summary; + v.description = normalizeLineEndings(description); + } + } + } + } + return v; +}; diff --git a/packages/labs/analyzer/src/lib/javascript/modules.ts b/packages/labs/analyzer/src/lib/javascript/modules.ts index f4bac3057c..c3a207ce03 100644 --- a/packages/labs/analyzer/src/lib/javascript/modules.ts +++ b/packages/labs/analyzer/src/lib/javascript/modules.ts @@ -4,65 +4,323 @@ * SPDX-License-Identifier: BSD-3-Clause */ +/** + * @fileoverview + * + * Utilities for analyzing ES modules + */ + import ts from 'typescript'; -import {Module} from '../model.js'; import { - isLitElement, - getLitElementDeclaration, -} from '../lit-element/lit-element.js'; -import * as path from 'path'; -import {getClassDeclaration} from './classes.js'; -import {getVariableDeclarations} from './variables.js'; -import {ProgramContext} from '../program-context.js'; -import {AbsolutePath, absoluteToPackage} from '../paths.js'; + Module, + AnalyzerInterface, + PackageInfo, + Declaration, + DeclarationInfo, + ExportMap, + DeclarationMap, + ModuleInfo, + LocalNameOrReference, +} from '../model.js'; +import {getClassDeclarationInfo} from './classes.js'; +import { + getExportAssignmentVariableDeclarationInfo, + getVariableDeclarationInfo, +} from './variables.js'; +import {AbsolutePath, PackagePath, absoluteToPackage} from '../paths.js'; +import {getPackageInfo} from './packages.js'; +import {DiagnosticsError} from '../errors.js'; +import { + getExportReferences, + getImportReferenceForSpecifierExpression, + getSpecifierString, +} from '../references.js'; + +/** + * Returns the sourcePath, jsPath, and package.json contents of the containing + * package for the given module path. + * + * This is a minimal subset of module information needed for constructing a + * Reference object for a module. + */ +export const getModuleInfo = ( + modulePath: AbsolutePath, + analyzer: AnalyzerInterface, + packageInfo: PackageInfo = getPackageInfo(modulePath, analyzer) +): ModuleInfo => { + // The packageRoot for this module is needed for translating the source file + // path to a package relative path, and the packageName is needed for + // generating references to any symbols in this module. + const {rootDir, packageJson} = packageInfo; + const absJsPath = getJSPathFromSourcePath( + modulePath as AbsolutePath, + analyzer + ); + const jsPath = + absJsPath !== undefined + ? absoluteToPackage(absJsPath, rootDir) + : ('not/implemented' as PackagePath); + const sourcePath = absoluteToPackage( + analyzer.path.normalize(modulePath) as AbsolutePath, + rootDir + ); + return { + jsPath, + sourcePath, + packageJson, + }; +}; +/** + * Returns an analyzer `Module` model for the given module path. + */ export const getModule = ( - sourceFile: ts.SourceFile, - programContext: ProgramContext + modulePath: AbsolutePath, + analyzer: AnalyzerInterface, + packageInfo: PackageInfo = getPackageInfo(modulePath, analyzer) ) => { - const sourcePath = absoluteToPackage( - path.normalize(sourceFile.fileName) as AbsolutePath, - programContext.packageRoot + // Return cached module if we've parsed this sourceFile already and its + // dependencies haven't changed + const cachedModule = getAndValidateModuleFromCache(modulePath, analyzer); + if (cachedModule !== undefined) { + return cachedModule; + } + const sourceFile = analyzer.program.getSourceFile( + analyzer.path.normalize(modulePath) ); - const fullSourcePath = path.join(programContext.packageRoot, sourcePath); - const jsPath = ts - .getOutputFileNames(programContext.commandLine, fullSourcePath, false) - .filter((f) => f.endsWith('.js'))[0]; - // TODO(kschaaf): this could happen if someone imported only a .d.ts file; - // we might need to handle this differently - if (jsPath === undefined) { - throw new Error(`Could not determine output filename for '${sourcePath}'`); + if (sourceFile === undefined) { + throw new Error(`Program did not contain a source file for ${modulePath}`); } - const module = new Module({ - sourcePath, - // The jsPath appears to come out of the ts API with unix - // separators; since sourcePath uses OS separators, normalize - // this so that all our model paths are OS-native - jsPath: absoluteToPackage( - path.normalize(jsPath) as AbsolutePath, - programContext.packageRoot as AbsolutePath - ), - sourceFile, - }); - - programContext.currentModule = module; + const dependencies = new Set(); + const declarationMap: DeclarationMap = new Map Declaration>(); + const exportMap: ExportMap = new Map(); + const reexports: ts.Expression[] = []; + const addDeclaration = (info: DeclarationInfo) => { + const {name, factory, isExport} = info; + declarationMap.set(name, factory); + if (isExport) { + exportMap.set(name, name); + } + }; + // Find and add models for declarations in the module + // TODO(kschaaf): Add Function and MixinDeclarations for (const statement of sourceFile.statements) { if (ts.isClassDeclaration(statement)) { - module.declarations.push( - isLitElement(statement, programContext) - ? getLitElementDeclaration(statement, programContext) - : getClassDeclaration(statement, programContext) - ); + addDeclaration(getClassDeclarationInfo(statement, analyzer)); } else if (ts.isVariableStatement(statement)) { - module.declarations.push( - ...statement.declarationList.declarations - .map((dec) => getVariableDeclarations(dec, dec.name, programContext)) - .flat() + getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration); + } else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + const {exportClause, moduleSpecifier} = statement; + if (exportClause === undefined) { + // Case: `export * from 'foo';` The `exportClause` is undefined for + // wildcard exports. Add the re-exported module specifier to the + // `reexports` list, and we will add references to the exportMap lazily + // the first time exports are queried + if (moduleSpecifier === undefined) { + throw new DiagnosticsError( + statement, + `Expected a wildcard export to have a module specifier.` + ); + } + reexports.push(moduleSpecifier); + } else { + // Case: `export {...}` and `export {...} from '...'` + // Add all of the exports in this export statement to the exportMap + getExportReferences(exportClause, moduleSpecifier, analyzer).forEach( + ({exportName, decNameOrRef}) => + exportMap.set(exportName, decNameOrRef) + ); + } + } else if (ts.isExportAssignment(statement)) { + addDeclaration( + getExportAssignmentVariableDeclarationInfo(statement, analyzer) + ); + } else if (ts.isImportDeclaration(statement)) { + dependencies.add( + getPathForModuleSpecifierExpression(statement.moduleSpecifier, analyzer) ); } } - programContext.currentModule = undefined; + // Construct module and save in cache + const module = new Module({ + ...getModuleInfo(modulePath, analyzer, packageInfo), + sourceFile, + declarationMap, + dependencies, + exportMap, + finalizeExports: () => finalizeExports(reexports, exportMap, analyzer), + }); + analyzer.moduleCache.set( + analyzer.path.normalize(sourceFile.fileName) as AbsolutePath, + module + ); return module; }; + +/** + * For any re-exported modules (i.e. `export * from 'foo'`), add all of the + * exported names of the reexported module to the given exportMap, with + * References into that module. + */ +const finalizeExports = ( + reexportSpecifiers: ts.Expression[], + exportMap: ExportMap, + analyzer: AnalyzerInterface +) => { + for (const moduleSpecifier of reexportSpecifiers) { + const module = getModule( + getPathForModuleSpecifierExpression(moduleSpecifier, analyzer), + analyzer + ); + for (const name of module.exportNames) { + exportMap.set( + name, + getImportReferenceForSpecifierExpression( + moduleSpecifier, + name, + analyzer + ) + ); + } + } +}; + +/** + * Returns a cached Module model for the given module path if it and all of its + * dependencies' models are still valid since the model was cached. If the + * cached module is out-of-date and needs to be re-created, this method returns + * undefined. + */ +const getAndValidateModuleFromCache = ( + modulePath: AbsolutePath, + analyzer: AnalyzerInterface +): Module | undefined => { + const module = analyzer.moduleCache.get(modulePath); + // A cached module is only valid if the source file that was used has not + // changed in the current program, and if all of its dependencies are still + // valid + if (module !== undefined) { + if ( + module.sourceFile === analyzer.program.getSourceFile(modulePath) && + depsAreValid(module, analyzer) + ) { + return module; + } + analyzer.moduleCache.delete(modulePath); + } + return undefined; +}; + +/** + * Returns true if all dependencies of the module are still valid. + */ +const depsAreValid = (module: Module, analyzer: AnalyzerInterface) => + Array.from(module.dependencies).every((path) => depIsValid(path, analyzer)); + +/** + * Returns true if the given dependency is valid, meaning that if it has a + * cached model, the model is still valid. Dependencies that don't yet have a + * cached model are considered valid. + */ +const depIsValid = (modulePath: AbsolutePath, analyzer: AnalyzerInterface) => { + if (analyzer.moduleCache.has(modulePath)) { + // If a dep has a model, it is valid only if its deps are valid + return Boolean(getAndValidateModuleFromCache(modulePath, analyzer)); + } else { + // Deps that don't have a cached model are considered valid + return true; + } +}; + +/** + * For a given source file, return its associated JS file. + * + * For a JS source file, these will be the same thing. For a TS file, we use the + * TS API to determine where the associated JS will be output based on tsconfig + * settings. + */ +const getJSPathFromSourcePath = ( + sourcePath: AbsolutePath, + analyzer: AnalyzerInterface +) => { + sourcePath = analyzer.path.normalize(sourcePath) as AbsolutePath; + // If the source file was already JS, just return that + if (sourcePath.endsWith('js')) { + return sourcePath; + } + // TODO(kschaaf): If the source file was a declaration file, this means we're + // likely getting information about an externally imported package that had + // types. In this case, we'll need to update our logic to resolve the import + // specifier to the JS path (in addition to the source file path that we do + // today). Unfortunately, TS's specifier resolver always prefers a declaration + // file, and due to type roots and other tsconfig fancies, it's not + // straightforward to go from a declaration file to a source file. In order to + // properly implement this we'll probably need to bring our own node module + // resolver ala https://www.npmjs.com/package/resolve. That change should be + // done along with the custom-elements.json manifest work. + if (sourcePath.endsWith('.d.ts')) { + return undefined; + } + // Use the TS API to determine where the associated JS will be output based + // on tsconfig settings. + const outputPath = ts + .getOutputFileNames(analyzer.commandLine, sourcePath, false) + .filter((f) => f.endsWith('.js'))[0]; + // TODO(kschaaf): this could happen if someone imported only a .d.ts file; + // we might need to handle this differently + if (outputPath === undefined) { + throw new Error(`Could not determine output filename for '${sourcePath}'`); + } + // The filename appears to come out of the ts API with + // unix separators; since sourcePath uses OS separators, normalize this so + // that all our model paths are OS-native + return analyzer.path.normalize(outputPath) as AbsolutePath; +}; + +/** + * Resolves a module specifier expression node to an absolute path on disk. + */ +export const getPathForModuleSpecifierExpression = ( + specifierExpression: ts.Expression, + analyzer: AnalyzerInterface +): AbsolutePath => { + const specifier = getSpecifierString(specifierExpression); + return getPathForModuleSpecifier(specifier, specifierExpression, analyzer); +}; + +/** + * Resolve a module specifier to an absolute path on disk. + */ +export const getPathForModuleSpecifier = ( + specifier: string, + location: ts.Node, + analyzer: AnalyzerInterface +): AbsolutePath => { + const resolvedPath = ts.resolveModuleName( + specifier, + location.getSourceFile().fileName, + analyzer.commandLine.options, + analyzer.fs + ).resolvedModule?.resolvedFileName; + if (resolvedPath === undefined) { + throw new DiagnosticsError( + location, + `Could not resolve specifier ${specifier} to filesystem path.` + ); + } + return analyzer.path.normalize(resolvedPath) as AbsolutePath; +}; + +/** + * Returns the declaration for the named export of the given module path; + * note that if the given module re-exported a declaration from another + * module, references are followed to the concrete declaration, which is + * returned. + */ +export const getResolvedExportFromSourcePath = ( + modulePath: AbsolutePath, + name: string, + analyzer: AnalyzerInterface +) => getModule(modulePath, analyzer)?.getResolvedExport(name); diff --git a/packages/labs/analyzer/src/lib/javascript/packages.ts b/packages/labs/analyzer/src/lib/javascript/packages.ts new file mode 100644 index 0000000000..3c4b3c784a --- /dev/null +++ b/packages/labs/analyzer/src/lib/javascript/packages.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {AnalyzerInterface, PackageInfo, PackageJson} from '../model.js'; +import {AbsolutePath} from '../paths.js'; + +/** + * Starting from a given module path, searches up until the nearest package.json + * is found, returning that folder. If none is found, an error is thrown. + */ +export const getPackageRootForModulePath = ( + modulePath: AbsolutePath, + analyzer: AnalyzerInterface +): AbsolutePath => { + // TODO(kschaaf): Add caching & invalidation + const {fs, path} = analyzer; + let searchDir = modulePath as string; + const root = path.parse(searchDir).root; + while (!fs.fileExists(path.join(searchDir, 'package.json'))) { + if (searchDir === root) { + throw new Error(`No package.json found for module path ${modulePath}`); + } + searchDir = path.dirname(searchDir); + } + return searchDir as AbsolutePath; +}; + +/** + * Reads and parses a package.json file contained in the given folder. + */ +const getPackageJsonFromPackageRoot = ( + packageRoot: AbsolutePath, + analyzer: AnalyzerInterface +): PackageJson => { + // TODO(kschaaf): Add caching & invalidation + const {fs, path} = analyzer; + const packageJson = fs.readFile(path.join(packageRoot, 'package.json')); + if (packageJson !== undefined) { + return JSON.parse(packageJson) as PackageJson; + } + throw new Error(`No package.json found at ${packageRoot}`); +}; + +/** + * Returns an analyzer `PackageInfo` model for the nearest package of the given + * path. + */ +export const getPackageInfo = ( + path: AbsolutePath, + analyzer: AnalyzerInterface +) => { + const rootDir = getPackageRootForModulePath(path, analyzer); + const packageJson = getPackageJsonFromPackageRoot(rootDir, analyzer); + const {name} = packageJson; + if (name === undefined) { + throw new Error( + `Expected package name in ${analyzer.path.join(rootDir, 'package.json')}` + ); + } + return new PackageInfo({ + name, + rootDir: analyzer.path.normalize(rootDir) as AbsolutePath, + packageJson, + }); +}; diff --git a/packages/labs/analyzer/src/lib/javascript/variables.ts b/packages/labs/analyzer/src/lib/javascript/variables.ts index aead529a57..31458ec513 100644 --- a/packages/labs/analyzer/src/lib/javascript/variables.ts +++ b/packages/labs/analyzer/src/lib/javascript/variables.ts @@ -7,31 +7,72 @@ /** * @fileoverview * - * Utilities for working with classes + * Utilities for analyzing variable declarations */ import ts from 'typescript'; -import {VariableDeclaration} from '../model.js'; -import {ProgramContext} from '../program-context.js'; +import { + VariableDeclaration, + AnalyzerInterface, + DeclarationInfo, +} from '../model.js'; +import {hasExportKeyword} from '../references.js'; import {DiagnosticsError} from '../errors.js'; +import {getTypeForNode} from '../types.js'; type VariableName = | ts.Identifier | ts.ObjectBindingPattern | ts.ArrayBindingPattern; -export const getVariableDeclarations = ( +/** + * Returns an analyzer `VariableDeclaration` model for the given + * ts.Identifier within a potentially nested ts.VariableDeclaration. + */ +const getVariableDeclaration = ( + dec: ts.VariableDeclaration, + name: ts.Identifier, + analyzer: AnalyzerInterface +): VariableDeclaration => { + return new VariableDeclaration({ + name: name.text, + node: dec, + type: getTypeForNode(name, analyzer), + }); +}; + +/** + * Returns name and model factories for all variable + * declarations in a variable statement. + */ +export const getVariableDeclarationInfo = ( + statement: ts.VariableStatement, + analyzer: AnalyzerInterface +): DeclarationInfo[] => { + const isExport = hasExportKeyword(statement); + return statement.declarationList.declarations + .map((d) => getVariableDeclarationInfoList(d, d.name, isExport, analyzer)) + .flat(); +}; + +/** + * For a given `VariableName` (which might be a simple identifier or a + * destructuring pattern which contains more identifiers), return an array of + * tuples of name and factory for each declaration. + */ +const getVariableDeclarationInfoList = ( dec: ts.VariableDeclaration, name: VariableName, - programContext: ProgramContext -): VariableDeclaration[] => { + isExport: boolean, + analyzer: AnalyzerInterface +): DeclarationInfo[] => { if (ts.isIdentifier(name)) { return [ - new VariableDeclaration({ + { name: name.text, - node: dec, - type: programContext.getTypeForNode(name), - }), + factory: () => getVariableDeclaration(dec, name, analyzer), + isExport, + }, ]; } else if ( // Recurse into the elements of an array/object destructuring variable @@ -43,7 +84,9 @@ export const getVariableDeclarations = ( ts.isBindingElement(el) ) as ts.BindingElement[]; return els - .map((el) => getVariableDeclarations(dec, el.name, programContext)) + .map((el) => + getVariableDeclarationInfoList(dec, el.name, isExport, analyzer) + ) .flat(); } else { throw new DiagnosticsError( @@ -52,3 +95,37 @@ export const getVariableDeclarations = ( ); } }; + +/** + * Returns declaration info & factory for a default export assignment. + */ +export const getExportAssignmentVariableDeclarationInfo = ( + exportAssignment: ts.ExportAssignment, + analyzer: AnalyzerInterface +): DeclarationInfo => { + return { + name: 'default', + factory: () => + getExportAssignmentVariableDeclaration(exportAssignment, analyzer), + isExport: true, + }; +}; + +/** + * Returns an analyzer `VariableDeclaration` model for the given default + * ts.ExportAssignment, handling the case of: `export const 'some expression'`; + * + * Note that even though this technically isn't a VariableDeclaration in + * TS, we model it as one since it could unobservably be implemented as + * `const varDec = 'some expression'; export {varDec as default} ` + */ +const getExportAssignmentVariableDeclaration = ( + exportAssignment: ts.ExportAssignment, + analyzer: AnalyzerInterface +): VariableDeclaration => { + return new VariableDeclaration({ + name: 'default', + node: exportAssignment, + type: getTypeForNode(exportAssignment.expression, analyzer), + }); +}; diff --git a/packages/labs/analyzer/src/lib/lit-element/decorators.ts b/packages/labs/analyzer/src/lib/lit-element/decorators.ts index 2cb020715e..6954e64d4f 100644 --- a/packages/labs/analyzer/src/lib/lit-element/decorators.ts +++ b/packages/labs/analyzer/src/lib/lit-element/decorators.ts @@ -7,7 +7,7 @@ /** * @fileoverview * - * Utilities for working with ReactiveElement decorators. + * Utilities for analyzing ReactiveElement decorators. */ import ts from 'typescript'; diff --git a/packages/labs/analyzer/src/lib/lit-element/events.ts b/packages/labs/analyzer/src/lib/lit-element/events.ts index ca37e15ea9..37cc2794d7 100644 --- a/packages/labs/analyzer/src/lib/lit-element/events.ts +++ b/packages/labs/analyzer/src/lib/lit-element/events.ts @@ -7,54 +7,46 @@ /** * @fileoverview * - * Utilities for working with events + * Utilities for analyzing with events */ import ts from 'typescript'; import {DiagnosticsError} from '../errors.js'; import {Event} from '../model.js'; -import {ProgramContext} from '../program-context.js'; +import {AnalyzerInterface} from '../model.js'; +import {getTypeForJSDocTag} from '../types.js'; -import {LitClassDeclaration} from './lit-element.js'; - -export const getEvents = ( - node: LitClassDeclaration, - programContext: ProgramContext +/** + * Returns an array of analyzer `Event` models for the given + * ts.ClassDeclaration. + */ +export const addEventsToMap = ( + tag: ts.JSDocTag, + events: Map, + analyzer: AnalyzerInterface ) => { - const events = new Map(); - const jsDocTags = ts.getJSDocTags(node); - if (jsDocTags !== undefined) { - for (const tag of jsDocTags) { - if (tag.tagName.text === 'fires') { - const {comment} = tag; - if (comment === undefined) { - continue; - } else if (typeof comment === 'string') { - const result = parseFiresTagComment(comment); - if (result === undefined) { - throw new DiagnosticsError( - tag, - 'The @fires annotation was not in a recognized form. ' + - 'Use `@fires event-name {Type} - Description`.' - ); - } - const {name, type, description} = result; - events.set(name, { - name, - type: type ? programContext.getTypeForJSDocTag(tag) : undefined, - description, - }); - } else { - // TODO: when do we get a ts.NodeArray? - throw new DiagnosticsError( - tag, - `Internal error: unsupported node type` - ); - } - } + const {comment} = tag; + if (comment === undefined) { + return; + } else if (typeof comment === 'string') { + const result = parseFiresTagComment(comment); + if (result === undefined) { + throw new DiagnosticsError( + tag, + 'The @fires annotation was not in a recognized form. ' + + 'Use `@fires event-name {Type} - Description`.' + ); } + const {name, type, description} = result; + events.set(name, { + name, + type: type ? getTypeForJSDocTag(tag, analyzer) : undefined, + description, + }); + } else { + // TODO: when do we get a ts.NodeArray? + throw new DiagnosticsError(tag, `Internal error: unsupported node type`); } - return events; }; const parseFiresTagComment = (comment: string) => { diff --git a/packages/labs/analyzer/src/lib/lit-element/lit-element.ts b/packages/labs/analyzer/src/lib/lit-element/lit-element.ts index ae43865a9b..7bb26fc219 100644 --- a/packages/labs/analyzer/src/lib/lit-element/lit-element.ts +++ b/packages/labs/analyzer/src/lib/lit-element/lit-element.ts @@ -7,14 +7,20 @@ /** * @fileoverview * - * Utilities for working with LitElement (and ReactiveElement) declarations. + * Utilities for analyzing LitElement (and ReactiveElement) declarations. */ import ts from 'typescript'; -import {LitElementDeclaration} from '../model.js'; -import {ProgramContext} from '../program-context.js'; +import {getHeritage} from '../javascript/classes.js'; +import {parseNodeJSDocInfo, parseNameDescSummary} from '../javascript/jsdoc.js'; +import { + LitElementDeclaration, + AnalyzerInterface, + Event, + NamedJSDocInfo, +} from '../model.js'; import {isCustomElementDecorator} from './decorators.js'; -import {getEvents} from './events.js'; +import {addEventsToMap} from './events.js'; import {getProperties} from './properties.js'; /** @@ -23,28 +29,99 @@ import {getProperties} from './properties.js'; */ export const getLitElementDeclaration = ( node: LitClassDeclaration, - programContext: ProgramContext + analyzer: AnalyzerInterface ): LitElementDeclaration => { return new LitElementDeclaration({ tagname: getTagName(node), // TODO(kschaaf): support anonymous class expressions when assigned to a const name: node.name?.text ?? '', node, - reactiveProperties: getProperties(node, programContext), - events: getEvents(node, programContext), + reactiveProperties: getProperties(node, analyzer), + ...getJSDocData(node, analyzer), + getHeritage: () => getHeritage(node, analyzer), }); }; +/** + * Parses element metadata from jsDoc tags from a LitElement declaration into + * Maps of . + */ +export const getJSDocData = ( + node: LitClassDeclaration, + analyzer: AnalyzerInterface +) => { + const events = new Map(); + const slots = new Map(); + const cssProperties = new Map(); + const cssParts = new Map(); + const jsDocTags = ts.getJSDocTags(node); + if (jsDocTags !== undefined) { + for (const tag of jsDocTags) { + switch (tag.tagName.text) { + case 'fires': + addEventsToMap(tag, events, analyzer); + break; + case 'slot': + addNamedJSDocInfoToMap(slots, tag); + break; + case 'cssProp': + addNamedJSDocInfoToMap(cssProperties, tag); + break; + case 'cssProperty': + addNamedJSDocInfoToMap(cssProperties, tag); + break; + case 'cssPart': + addNamedJSDocInfoToMap(cssParts, tag); + break; + } + } + } + return { + ...parseNodeJSDocInfo(node, analyzer), + events, + slots, + cssProperties, + cssParts, + }; +}; + +/** + * Adds name, description, and summary info for a given jsdoc tag into the + * provided map. + */ +const addNamedJSDocInfoToMap = ( + map: Map, + tag: ts.JSDocTag +) => { + const info = parseNameDescSummary(tag); + if (info !== undefined) { + map.set(info.name, info); + } +}; + /** * Returns true if this type represents the actual LitElement class. */ -const _isLitElementClassDeclaration = (t: ts.BaseType) => { +const _isLitElementClassDeclaration = ( + t: ts.BaseType, + analyzer: AnalyzerInterface +) => { // TODO: should we memoize this for performance? const declarations = t.getSymbol()?.getDeclarations(); if (declarations?.length !== 1) { return false; } const node = declarations[0]; + return _isLitElement(node) || isLitElementSubclass(node, analyzer); +}; + +/** + * Returns true if the given declaration is THE LitElement declaration. + * + * TODO(kschaaf): consider a less brittle method of detecting canonical + * LitElement + */ +const _isLitElement = (node: ts.Declaration) => { return ( _isLitElementModule(node.getSourceFile()) && ts.isClassDeclaration(node) && @@ -52,6 +129,9 @@ const _isLitElementClassDeclaration = (t: ts.BaseType) => { ); }; +/** + * Returns true if the given source file is THE lit-element source file. + */ const _isLitElementModule = (file: ts.SourceFile) => { return ( file.fileName.endsWith('/node_modules/lit-element/lit-element.d.ts') || @@ -74,19 +154,18 @@ export type LitClassDeclaration = ts.ClassDeclaration & { /** * Returns true if `node` is a ClassLikeDeclaration that extends LitElement. */ -export const isLitElement = ( +export const isLitElementSubclass = ( node: ts.Node, - programContext: ProgramContext + analyzer: AnalyzerInterface ): node is LitClassDeclaration => { if (!ts.isClassLike(node)) { return false; } - const type = programContext.checker.getTypeAtLocation( - node - ) as ts.InterfaceType; - const baseTypes = programContext.checker.getBaseTypes(type); + const checker = analyzer.program.getTypeChecker(); + const type = checker.getTypeAtLocation(node) as ts.InterfaceType; + const baseTypes = checker.getBaseTypes(type); for (const t of baseTypes) { - if (_isLitElementClassDeclaration(t)) { + if (_isLitElementClassDeclaration(t, analyzer)) { return true; } } @@ -94,13 +173,12 @@ export const isLitElement = ( }; /** - * Returns the tagname associated with a + * Returns the tagname associated with a LitClassDeclaration * @param declaration * @returns */ export const getTagName = (declaration: LitClassDeclaration) => { - // TODO (justinfagnani): support customElements.define() - let tagname: string | undefined = undefined; + let tagName: string | undefined = undefined; const customElementDecorator = declaration.decorators?.find( isCustomElementDecorator ); @@ -109,7 +187,33 @@ export const getTagName = (declaration: LitClassDeclaration) => { customElementDecorator.expression.arguments.length === 1 && ts.isStringLiteral(customElementDecorator.expression.arguments[0]) ) { - tagname = customElementDecorator.expression.arguments[0].text; + // Get tag from decorator: `@customElement('x-foo')` + tagName = customElementDecorator.expression.arguments[0].text; + } else { + // Otherwise, look for imperative define in the form of: + // `customElements.define('x-foo', XFoo);` + declaration.parent.forEachChild((child) => { + if ( + ts.isExpressionStatement(child) && + ts.isCallExpression(child.expression) && + ts.isPropertyAccessExpression(child.expression.expression) && + child.expression.arguments.length >= 2 + ) { + const [tagNameArg, ctorArg] = child.expression.arguments; + const {expression, name} = child.expression.expression; + if ( + ts.isIdentifier(expression) && + expression.text === 'customElements' && + ts.isIdentifier(name) && + name.text === 'define' && + ts.isStringLiteralLike(tagNameArg) && + ts.isIdentifier(ctorArg) && + ctorArg.text === declaration.name?.text + ) { + tagName = tagNameArg.text; + } + } + }); } - return tagname; + return tagName; }; diff --git a/packages/labs/analyzer/src/lib/lit-element/properties.ts b/packages/labs/analyzer/src/lib/lit-element/properties.ts index c2026593f7..9ce53cd293 100644 --- a/packages/labs/analyzer/src/lib/lit-element/properties.ts +++ b/packages/labs/analyzer/src/lib/lit-element/properties.ts @@ -7,48 +7,210 @@ /** * @fileoverview * - * Utilities for working with reactive property declarations + * Utilities for analyzing reactive property declarations */ import ts from 'typescript'; import {LitClassDeclaration} from './lit-element.js'; -import {ReactiveProperty} from '../model.js'; -import {ProgramContext} from '../program-context.js'; +import {ReactiveProperty, AnalyzerInterface} from '../model.js'; +import {getTypeForNode} from '../types.js'; import {getPropertyDecorator, getPropertyOptions} from './decorators.js'; +import {DiagnosticsError} from '../errors.js'; + +const isStatic = (prop: ts.PropertyDeclaration) => + prop.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword); export const getProperties = ( - node: LitClassDeclaration, - programContext: ProgramContext + classDeclaration: LitClassDeclaration, + analyzer: AnalyzerInterface ) => { const reactiveProperties = new Map(); + const undecoratedProperties = new Map(); - const propertyDeclarations = node.members.filter((m) => - ts.isPropertyDeclaration(m) + // Filter down to just the property and getter declarations + const propertyDeclarations = classDeclaration.members.filter( + (m) => ts.isPropertyDeclaration(m) || ts.isGetAccessorDeclaration(m) ) as unknown as ts.NodeArray; + + let staticProperties; + for (const prop of propertyDeclarations) { if (!ts.isIdentifier(prop.name)) { - // TODO(justinfagnani): emit error instead - throw new Error('unsupported property name'); + throw new DiagnosticsError(prop, 'Unsupported property name'); } const name = prop.name.text; const propertyDecorator = getPropertyDecorator(prop); if (propertyDecorator !== undefined) { + // Decorated property; get property options from the decorator and add + // them to the reactiveProperties map const options = getPropertyOptions(propertyDecorator); reactiveProperties.set(name, { name, - type: programContext.getTypeForNode(prop), - node: prop, + type: getTypeForNode(prop, analyzer), attribute: getPropertyAttribute(options, name), typeOption: getPropertyType(options), reflect: getPropertyReflect(options), converter: getPropertyConverter(options), }); + } else if (name === 'properties' && isStatic(prop)) { + // This field has the static properties block (initializer or getter). + // Note we will process this after the loop so that the + // `undecoratedProperties` map is complete before processing the static + // properties block. + staticProperties = prop; + } else if (!isStatic(prop)) { + // Store the declaration node for any undecorated properties. In a TS + // program that happens to use a static properties block along with + // the `declare` keyword to type the field, we can use this node to + // get/infer the TS type of the field from + undecoratedProperties.set(name, prop); } } + + // Handle static properties block (initializer or getter). + if (staticProperties !== undefined) { + addPropertiesFromStaticBlock( + classDeclaration, + staticProperties, + undecoratedProperties, + reactiveProperties, + analyzer + ); + } + return reactiveProperties; }; +/** + * Given a static properties declaration (field or getter), add property + * options to the provided `reactiveProperties` map. + */ +const addPropertiesFromStaticBlock = ( + classDeclaration: LitClassDeclaration, + properties: ts.PropertyDeclaration | ts.GetAccessorDeclaration, + undecoratedProperties: Map, + reactiveProperties: Map, + analyzer: AnalyzerInterface +) => { + // Add any constructor initializers to the undecorated properties node map + // from which we can infer types from. This is the primary path that JS source + // can get their inferred types (in TS, types will come from the undecorated + // fields passed in, since you need to declare the field to assign it in the + // constructor). + addConstructorInitializers(classDeclaration, undecoratedProperties); + // Find the object literal from the initializer or getter return value + const object = getStaticPropertiesObjectLiteral(properties); + // Loop over each key/value in the object and add them to the map + for (const prop of object.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + ts.isObjectLiteralExpression(prop.initializer) + ) { + const name = prop.name.text; + const options = prop.initializer; + const nodeForType = undecoratedProperties.get(name); + reactiveProperties.set(name, { + name, + type: + nodeForType !== undefined + ? getTypeForNode(nodeForType, analyzer) + : undefined, + attribute: getPropertyAttribute(options, name), + typeOption: getPropertyType(options), + reflect: getPropertyReflect(options), + converter: getPropertyConverter(options), + }); + } else { + throw new DiagnosticsError( + prop, + 'Unsupported static properties entry. Expected a string identifier key and object literal value.' + ); + } + } +}; + +/** + * Find the object literal for a static properties block. + * + * If a ts.PropertyDeclaration, it will look like: + * + * static properties = { ... }; + * + * If a ts.GetAccessorDeclaration, it will look like: + * + * static get properties() { + * return {... } + * } + */ +const getStaticPropertiesObjectLiteral = ( + properties: ts.PropertyDeclaration | ts.GetAccessorDeclaration +): ts.ObjectLiteralExpression => { + let object: ts.ObjectLiteralExpression | undefined = undefined; + if ( + ts.isPropertyDeclaration(properties) && + properties.initializer !== undefined && + ts.isObjectLiteralExpression(properties.initializer) + ) { + // `properties` has a static initializer; get the object from there + object = properties.initializer; + } else if (ts.isGetAccessorDeclaration(properties)) { + // Object was in a static getter: find the object in the return value + const statements = properties.body?.statements; + const statement = statements?.[statements.length - 1]; + if ( + statement !== undefined && + ts.isReturnStatement(statement) && + statement.expression !== undefined && + ts.isObjectLiteralExpression(statement.expression) + ) { + object = statement.expression; + } + } + if (object === undefined) { + throw new DiagnosticsError( + properties, + `Unsupported static properties format. Expected an object literal assigned in a static initializer or returned from a static getter.` + ); + } + return object; +}; + +/** + * Adds any field initializers in the given class's constructor to the provided + * map. This will be used for inferring the type of fields in JS programs. + */ +const addConstructorInitializers = ( + classDeclaration: ts.ClassDeclaration, + undecoratedProperties: Map +) => { + const ctor = classDeclaration.forEachChild((node) => + ts.isConstructorDeclaration(node) ? node : undefined + ); + if (ctor !== undefined) { + ctor.body?.statements.forEach((stmt) => { + // Look for initializers in the form of `this.foo = xxxx` + if ( + ts.isExpressionStatement(stmt) && + ts.isBinaryExpression(stmt.expression) && + ts.isPropertyAccessExpression(stmt.expression.left) && + stmt.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword && + ts.isIdentifier(stmt.expression.left.name) && + !undecoratedProperties.has(stmt.expression.left.name.text) + ) { + // Add the initializer expression to the map + undecoratedProperties.set( + // Property name + stmt.expression.left.name.text, + // Expression from which we can infer a type + stmt.expression.right + ); + } + }); + } +}; + /** * Gets the `attribute` property of a property options object as a string. */ diff --git a/packages/labs/analyzer/src/lib/model.ts b/packages/labs/analyzer/src/lib/model.ts index 001dc92131..cee52cd67c 100644 --- a/packages/labs/analyzer/src/lib/model.ts +++ b/packages/labs/analyzer/src/lib/model.ts @@ -10,6 +10,9 @@ import {AbsolutePath, PackagePath} from './paths.js'; import {IPackageJson as PackageJson} from 'package-json-type'; export {PackageJson}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new (...args: any[]) => T; + /** * Return type of `getLitElementModules`: contains a module and filtered list of * LitElementDeclarations contained within it. @@ -19,23 +22,33 @@ export type ModuleWithLitElementDeclarations = { declarations: LitElementDeclaration[]; }; -export interface PackageInit { +export interface PackageInfoInit { + name: string; rootDir: AbsolutePath; packageJson: PackageJson; - tsConfig: ts.ParsedCommandLine; - modules: ReadonlyArray; } -export class Package { +export class PackageInfo { + readonly name: string; readonly rootDir: AbsolutePath; - readonly modules: ReadonlyArray; - readonly tsConfig: ts.ParsedCommandLine; readonly packageJson: PackageJson; - constructor(init: PackageInit) { + constructor(init: PackageInfoInit) { + this.name = init.name; this.rootDir = init.rootDir; this.packageJson = init.packageJson; - this.tsConfig = init.tsConfig; + } +} + +export interface PackageInit extends PackageInfo { + modules: ReadonlyArray; +} + +export class Package extends PackageInfo { + readonly modules: ReadonlyArray; + + constructor(init: PackageInit) { + super(init); this.modules = init.modules; } @@ -61,10 +74,25 @@ export class Package { } } +export type LocalNameOrReference = string | Reference; +export type ExportMap = Map; +export type DeclarationMap = Map Declaration)>; + export interface ModuleInit { sourceFile: ts.SourceFile; sourcePath: PackagePath; jsPath: PackagePath; + packageJson: PackageJson; + declarationMap: DeclarationMap; + exportMap: ExportMap; + dependencies: Set; + finalizeExports?: () => void; +} + +export interface ModuleInfo { + sourcePath: PackagePath; + jsPath: PackagePath; + packageJson: PackageJson; } export class Module { @@ -83,23 +111,164 @@ export class Module { * project this will be the same as `sourcePath`. */ readonly jsPath: PackagePath; - readonly declarations: Array = []; + /** + * A map of names to models or model factories for all Declarations in this module. + */ + private readonly _declarationMap: DeclarationMap; + /** + * Private storage for all declarations within this module, memoized + * in `get declarations()` getter. + */ + private _declarations: Declaration[] | undefined = undefined; + /** + * A set of all dependencies of this module, as module absolute paths. + */ + readonly dependencies: Set; + /** + * The package.json contents for the package containing this module. + */ + readonly packageJson: PackageJson; + /** + * A map of exported names to local declaration names or References, in + * the case of re-exported symbols. + */ + private readonly _exportMap: ExportMap; + /** + * A list of module paths for all wildcard re-exports + */ + private _finalizeExports: (() => void) | undefined; constructor(init: ModuleInit) { this.sourceFile = init.sourceFile; this.sourcePath = init.sourcePath; this.jsPath = init.jsPath; + this.packageJson = init.packageJson; + this._declarationMap = init.declarationMap; + this.dependencies = init.dependencies; + this._exportMap = init.exportMap; + this._finalizeExports = init.finalizeExports; + } + + /** + * Ensures the list of exports includes the names of all reexports + * from other modules. + */ + private _ensureExportsFinalized() { + if (this._finalizeExports !== undefined) { + this._finalizeExports(); + this._finalizeExports = undefined; + } + } + + /** + * Returns names of all exported declarations. + */ + get exportNames() { + this._ensureExportsFinalized(); + return Array.from(this._exportMap.keys()); + } + + /** + * Given an exported symbol name, returns a Declaration if it was + * defined in this module, or a Reference if it was imported from + * another module. + */ + getExport(name: string): Declaration | Reference { + this._ensureExportsFinalized(); + const exp = this._exportMap.get(name); + if (exp === undefined) { + throw new Error( + `Module ${this.sourcePath} did not contain an export named ${name}` + ); + } else if (exp instanceof Reference) { + return exp; + } else { + return this.getDeclaration(exp); + } + } + + /** + * Return Reference for given export name. + * + * For references to local declarations, the module will be undefined. + * For re-exports, the Reference will point to a package & module. + */ + getExportReference(name: string): Reference { + const exp = this.getExport(name); + if (exp instanceof Declaration) { + return new Reference({name: exp.name, dereference: () => exp}); + } else { + return exp; + } + } + + /** + * Given an exported symbol name, returns the concrete Declaration + * for that symbol, following it through any re-exports. + */ + getResolvedExport(name: string): Declaration { + let exp = this.getExport(name); + while (exp instanceof Reference) { + exp = exp.dereference(); + } + return exp as Declaration; + } + + /** + * Returns a `Declaration` model for the given name in top-level module scope. + * + * Note, the name is local to the module, and the declaration may be exported + * from with a different name. The declaration is always concrete, and will + * never be a `Reference`. + */ + getDeclaration(name: string): Declaration { + let dec = this._declarationMap.get(name); + if (dec === undefined) { + throw new Error( + `Module ${this.sourcePath} did not contain a declaration named ${name}` + ); + } + // Overwrite a factory with its output (a `Declaration` model) on first + // request + if (typeof dec === 'function') { + this._declarationMap.set(name, (dec = dec())); + } + return dec; + } + + /** + * Returns a list of all Declarations locally defined in this module. + */ + get declarations() { + return (this._declarations ??= Array.from(this._declarationMap.keys()).map( + (name) => this.getDeclaration(name) + )); + } + + /** + * Returns all custom elements registered in this module. + */ + getCustomElementExports(): LitElementExport[] { + return this.declarations.filter( + (d) => d.isLitElementDeclaration() && d.tagname !== undefined + ) as LitElementExport[]; } } -interface DeclarationInit { +interface DeclarationInit extends NodeJSDocInfo { name: string; } export abstract class Declaration { - name: string; + readonly name: string; + readonly description?: string | undefined; + readonly summary?: string | undefined; + readonly deprecated?: string | boolean | undefined; constructor(init: DeclarationInit) { this.name = init.name; + this.description = init.description; + this.summary = init.summary; + this.deprecated = init.deprecated; } isVariableDeclaration(): this is VariableDeclaration { return this instanceof VariableDeclaration; @@ -113,12 +282,12 @@ export abstract class Declaration { } export interface VariableDeclarationInit extends DeclarationInit { - node: ts.VariableDeclaration; + node: ts.VariableDeclaration | ts.ExportAssignment; type: Type | undefined; } export class VariableDeclaration extends Declaration { - readonly node: ts.VariableDeclaration; + readonly node: ts.VariableDeclaration | ts.ExportAssignment; readonly type: Type | undefined; constructor(init: VariableDeclarationInit) { super(init); @@ -127,23 +296,51 @@ export class VariableDeclaration extends Declaration { } } +export type ClassHeritage = { + mixins: Reference[]; + superClass: Reference | undefined; +}; + export interface ClassDeclarationInit extends DeclarationInit { node: ts.ClassDeclaration; + getHeritage: () => ClassHeritage; } export class ClassDeclaration extends Declaration { readonly node: ts.ClassDeclaration; + private _getHeritage: () => ClassHeritage; + private _heritage: ClassHeritage | undefined = undefined; constructor(init: ClassDeclarationInit) { super(init); this.node = init.node; + this._getHeritage = init.getHeritage; + } + + get heritage(): ClassHeritage { + return (this._heritage ??= this._getHeritage()); } } +export interface NamedJSDocInfo { + name: string; + description?: string | undefined; + summary?: string | undefined; +} + +export interface NodeJSDocInfo { + description?: string | undefined; + summary?: string | undefined; + deprecated?: string | boolean | undefined; +} + interface LitElementDeclarationInit extends ClassDeclarationInit { tagname: string | undefined; reactiveProperties: Map; - readonly events: Map; + events: Map; + slots: Map; + cssProperties: Map; + cssParts: Map; } export class LitElementDeclaration extends ClassDeclaration { @@ -159,22 +356,33 @@ export class LitElementDeclaration extends ClassDeclaration { readonly tagname: string | undefined; readonly reactiveProperties: Map; - readonly events: Map; + readonly slots: Map; + readonly cssProperties: Map; + readonly cssParts: Map; constructor(init: LitElementDeclarationInit) { super(init); this.tagname = init.tagname; this.reactiveProperties = init.reactiveProperties; this.events = init.events; + this.slots = init.slots; + this.cssProperties = init.cssProperties; + this.cssParts = init.cssParts; } } +/** + * A LitElementDeclaration that has been globally registered with a tagname. + */ +export interface LitElementExport extends LitElementDeclaration { + tagname: string; +} + export interface ReactiveProperty { name: string; - node: ts.PropertyDeclaration; - type: Type; + type: Type | undefined; reflect: boolean; @@ -213,9 +421,10 @@ export interface LitModule { export interface ReferenceInit { name: string; - package?: string; - module?: string; + package?: string | undefined; + module?: string | undefined; isGlobal?: boolean; + dereference?: () => Declaration | undefined; } export class Reference { @@ -223,11 +432,14 @@ export class Reference { readonly package: string | undefined; readonly module: string | undefined; readonly isGlobal: boolean; + private readonly _dereference: () => Declaration | undefined; + private _model: Declaration | undefined = undefined; constructor(init: ReferenceInit) { this.name = init.name; this.package = init.package; this.module = init.module; this.isGlobal = init.isGlobal ?? false; + this._dereference = init.dereference ?? (() => undefined); } get moduleSpecifier() { @@ -236,17 +448,43 @@ export class Reference { ? undefined : (this.package || '') + separator + (this.module || ''); } + + /** + * Returns the Declaration model that this reference points to, optionally + * validating (and casting) it to be of a given type by passing a model + * constructor. + */ + dereference(type?: Constructor | undefined): T { + const model = (this._model ??= this._dereference()); + if (type !== undefined && model !== undefined && !(model instanceof type)) { + throw new Error( + `Expected reference to ${this.name} in module ${this.moduleSpecifier} to be of type ${type.name}` + ); + } + return model as T; + } +} + +export interface TypeInit { + type: ts.Type; + text: string; + getReferences: () => Reference[]; } export class Type { type: ts.Type; text: string; - references: Reference[]; + private _getReferences: () => Reference[]; + private _references: Reference[] | undefined = undefined; + + constructor(init: TypeInit) { + this.type = init.type; + this.text = init.text; + this._getReferences = init.getReferences; + } - constructor(type: ts.Type, text: string, references: Reference[]) { - this.type = type; - this.text = text; - this.references = references; + get references() { + return (this._references ??= this._getReferences()); } } @@ -276,3 +514,37 @@ export const getImportsStringForReferences = (references: Reference[]) => { ) .join('\n'); }; + +export interface AnalyzerInterface { + moduleCache: Map; + program: ts.Program; + commandLine: ts.ParsedCommandLine; + fs: Pick< + ts.System, + | 'readDirectory' + | 'readFile' + | 'realpath' + | 'fileExists' + | 'useCaseSensitiveFileNames' + >; + path: Pick< + typeof import('path'), + | 'join' + | 'relative' + | 'dirname' + | 'basename' + | 'dirname' + | 'parse' + | 'normalize' + | 'isAbsolute' + >; +} + +/** + * The name, model factory, and export information about a given declaration. + */ +export type DeclarationInfo = { + name: string; + factory: () => Declaration; + isExport?: boolean; +}; diff --git a/packages/labs/analyzer/src/lib/paths.ts b/packages/labs/analyzer/src/lib/paths.ts index a31c493637..b89305a379 100644 --- a/packages/labs/analyzer/src/lib/paths.ts +++ b/packages/labs/analyzer/src/lib/paths.ts @@ -5,6 +5,7 @@ */ import * as pathlib from 'path'; +import {AnalyzerInterface} from './model.js'; /** * An absolute path @@ -40,3 +41,20 @@ export const absoluteToPackage = ( } return packagePath as PackagePath; }; + +export const resolveExtension = ( + path: AbsolutePath, + analyzer: AnalyzerInterface, + extensions = ['js', 'mjs'] +) => { + if (analyzer.fs.fileExists(path)) { + return path; + } + for (const ext of extensions) { + const fileName = `${path}.${ext}`; + if (analyzer.fs.fileExists(fileName)) { + return fileName; + } + } + throw new Error(`Could not resolve ${path} to a file on disk.`); +}; diff --git a/packages/labs/analyzer/src/lib/program-context.ts b/packages/labs/analyzer/src/lib/program-context.ts deleted file mode 100644 index ac179e424d..0000000000 --- a/packages/labs/analyzer/src/lib/program-context.ts +++ /dev/null @@ -1,566 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ - -/** - * @fileoverview Utility class that stores context about the current program - * under analysis and helpers for generating Type objects. - * - * TODO(kschaaf): This code could just be part of the Analyzer base class, but - * is factored out for now to avoid circular references. We could also just make - * an Analyzer interface that modules can import to type the Analyzer when - * passed as an argument. - */ - -import ts from 'typescript'; -import {DiagnosticsError, createDiagnostic} from './errors.js'; -import path from 'path'; -import fs from 'fs'; -import {Module, PackageJson, Type, Reference} from './model.js'; -import {AbsolutePath} from './paths.js'; - -const npmModule = /^(?(@\w+\/\w+)|\w+)\/?(?.*)$/; - -type FileCache = Map; - -/** - * Create a language service host that reads from the filesystem initially, - * and supports updating individual source files in memory by updating its - * content and version in the FileCache. - */ -const createServiceHost = ( - commandLine: ts.ParsedCommandLine, - cache: FileCache -): ts.LanguageServiceHost => { - return { - getScriptFileNames: () => commandLine.fileNames, - getScriptVersion: (fileName) => - cache.get(fileName)?.version.toString() ?? '-1', - getScriptSnapshot: (fileName) => { - let file = cache.get(fileName); - if (file === undefined) { - if (!fs.existsSync(fileName)) { - return undefined; - } - file = { - version: 0, - content: ts.ScriptSnapshot.fromString( - fs.readFileSync(fileName, 'utf-8') - ), - }; - cache.set(fileName, file); - } - return file.content; - }, - getCurrentDirectory: () => process.cwd(), - getCompilationSettings: () => commandLine.options, - getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), - fileExists: ts.sys.fileExists, - readFile: ts.sys.readFile, - readDirectory: ts.sys.readDirectory, - directoryExists: ts.sys.directoryExists, - getDirectories: ts.sys.getDirectories, - }; -}; - -/** - * Utility class that stores context about the current program under - * analysis and helpers for generating Type objects. - */ -export class ProgramContext { - readonly packageRoot: AbsolutePath; - readonly commandLine: ts.ParsedCommandLine; - readonly packageJson: PackageJson; - readonly service: ts.LanguageService; - program!: ts.Program; - checker!: ts.TypeChecker; - - currentModule: Module | undefined = undefined; - - fileCache: FileCache = new Map(); - - constructor( - packageRoot: AbsolutePath, - commandLine: ts.ParsedCommandLine, - packageJson: PackageJson - ) { - this.packageRoot = packageRoot; - this.commandLine = commandLine; - this.packageJson = packageJson; - this.service = ts.createLanguageService( - createServiceHost(commandLine, this.fileCache), - ts.createDocumentRegistry() - ); - this.invalidate(); - const diagnostics = this.program.getSemanticDiagnostics(); - if (diagnostics.length > 0) { - throw new DiagnosticsError( - diagnostics, - `Error analyzing package '${packageJson.name}': Please fix errors first` - ); - } - this.extractJsdocTypes(); - } - - /** - * Re-initialize the program and checker (causes the file cache versions to be - * diffed, and changed files to be re-parsed/checked) - */ - private invalidate() { - this.program = this.service.getProgram()!; - this.checker = this.program.getTypeChecker(); - } - - /** - * Update the file cache with the new text of one or more modified source - * file, and re-parse/check them - */ - updateFiles(sourceFiles: ts.SourceFile[]) { - for (const sourceFile of sourceFiles) { - const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); - let file = this.fileCache.get(sourceFile.fileName); - if (file === undefined) { - file = {version: 0}; - this.fileCache.set(sourceFile.fileName, file); - } - file.content = ts.ScriptSnapshot.fromString( - printer.printFile(sourceFile) - ); - file.version++; - } - this.invalidate(); - } - - /** - * Associates jsdoc tags with any string `{Type}`s in them - */ - private jsdocTypeMap = new WeakMap(); - - /** - * Generates ts.Type nodes for types specified as {Type} strings in the - * comment text of custom jsdoc tags listed in the `customJSDocTypeTags` - * allowlist. - * - * Achieved by doing a pass over the AST, finding all such jsdoc tags and - * extracting the type text, generating dummy `let __$$customJsDOCType52: - * SomeType;` statements, finding those and extracting their type, and then - * re-associating them with the jsdoc comments via a WeakMap. The original - * source is restored (and re-parsed/checked) prior to returning. - * - * This should be run once prior to analysis. The type for a jsdoc comment can - * then be looked up via `getTypeForJSDocTag`. - */ - private extractJsdocTypes() { - const origSourceFiles = []; - const modifiedSourceFiles = []; - const tags: ts.JSDocTag[] = []; - for (const fileName of this.program.getRootFileNames()) { - const inferStatements: string[] = []; - const sourceFile = this.program.getSourceFile(path.normalize(fileName))!; - // Find JSDoc tags that contain string types and generate dummy variable - // declarations using their types - visitCustomJSDocTypeTags( - sourceFile, - (tag: ts.JSDocTag, typeString: string) => { - tags.push(tag); - inferStatements.push( - `export let ${customJSDocTypeInferPrefix}${inferStatements.length}: ${typeString};\n` - ); - } - ); - // Update the source files with the dummy variable declarations (add to - // end). We could try to keep these in the same lexical scope as the jsdoc - // comment, but that's a lot more work, and given any references used also - // need to be exported, seems unlikely they would use non-module scoped - // symbols - if (inferStatements.length > 0) { - const inferStatementText = inferStatements.join(''); - const newSourceFile = sourceFile.update( - sourceFile.text + inferStatementText, - ts.createTextChangeRange( - ts.createTextSpan(sourceFile.text.length, 0), - inferStatementText.length - ) - ); - origSourceFiles.push(sourceFile); - modifiedSourceFiles.push(newSourceFile); - } - } - if (modifiedSourceFiles.length > 0) { - // If we had source files with type tags, update the language service - // with the modified files - this.updateFiles(modifiedSourceFiles); - const origErrors = this.program.getSemanticDiagnostics(); - if (origErrors.length) { - const retargetedErrors: ts.Diagnostic[] = []; - for (const error of origErrors) { - const tag = getTagForError(error, tags); - if (tag) { - retargetedErrors.push( - createDiagnostic(tag, error.messageText as string) - ); - } else { - throw new Error( - 'Internal error: Could not associate inferred type error with original jsdoc tag.' - ); - } - } - throw new DiagnosticsError( - retargetedErrors, - `Error analyzing package '${this.packageJson.name}': Please fix errors first` - ); - } - // Find the inferred types and store them in a list - const types: ts.Type[] = []; - for (const modifiedSourceFile of modifiedSourceFiles) { - const sourceFile = this.program.getSourceFile( - modifiedSourceFile.fileName - )!; - visitCustomJSDocTypeInferredTypes( - sourceFile, - (node: ts.VariableDeclaration) => { - types.push(this.checker.getTypeAtLocation(node)); - } - ); - } - // Restore the original source files - this.updateFiles(origSourceFiles); - // Find typed JSDoc tags again and associate them with their type - for (const origSourceFile of origSourceFiles) { - const sourceFile = this.program.getSourceFile(origSourceFile.fileName)!; - let tagIndex = 0; - visitCustomJSDocTypeTags(sourceFile, (tag: ts.JSDocTag) => { - const type = types[tagIndex++]; - this.jsdocTypeMap.set(tag, type); - }); - } - } - } - - /** - * Returns a ts.Symbol for a name in scope at a given location in the AST. - * TODO(kschaaf): There are ~1748 symbols in scope of a typical hello world, - * due to DOM globals. Perf might become an issue here. This is a reason to - * look for a better Type visitor than typedoc: - * https://github.com/lit/lit/issues/3001 - */ - getSymbolForName(name: string, location: ts.Node): ts.Symbol | undefined { - return this.checker - .getSymbolsInScope( - location, - (ts.SymbolFlags as unknown as {All: number}).All - ) - .filter((s) => s.name === name)[0]; - } - - /** - * Returns an analyzer `Type` object for the given jsDoc tag. - * - * Note, the tag type must - */ - getTypeForJSDocTag(tag: ts.JSDocTag): Type { - if (!customJSDocTypeTags.has(tag.tagName.text)) { - throw new DiagnosticsError( - tag, - `Internal error: '${tag.tagName.text}' is not included in customJSDocTypeTags.` - ); - } - const type = this.jsdocTypeMap.get(tag); - if (type === undefined) { - throw new DiagnosticsError( - tag, - `Internal error: no type was pre-processed for this JSDoc tag` - ); - } - return this.getTypeForType(type, tag); - } - - /** - * Returns an analyzer `Type` object for the given AST node. - */ - getTypeForNode(node: ts.Node): Type { - // Since getTypeAtLocation will return `any` for an untyped node, to support - // jsdoc @type for JS (TBD), we look at the jsdoc type first. - const jsdocType = ts.getJSDocType(node); - return this.getTypeForType( - jsdocType - ? this.checker.getTypeFromTypeNode(jsdocType) - : this.checker.getTypeAtLocation(node), - node - ); - } - - /** - * Returns the module specifier for a declaration if it was imported, - * or `undefined` if the declaration was not imported. - */ - getImportModuleSpecifier(declaration: ts.Node): string | undefined { - // TODO(kschaaf) support the various import syntaxes, e.g. `import {foo as bar} from 'baz'` - if ( - ts.isImportSpecifier(declaration) && - ts.isNamedImports(declaration.parent) && - ts.isImportClause(declaration.parent.parent) && - ts.isImportDeclaration(declaration.parent.parent.parent) - ) { - const module = declaration.parent.parent.parent.moduleSpecifier - .getText() - // Remove quotes - .slice(1, -1); - return module; - } - return undefined; - } - - /** - * Returns an analyzer `Reference` object for the given symbol. - * - * If the symbol's declaration was imported, the Reference will be based on - * the import's module specifier; otherwise the Reference will point to the - * current module being analyzed. - */ - getReferenceForSymbol(symbol: ts.Symbol, location: ts.Node): Reference { - const {name} = symbol; - if (this.currentModule === undefined) { - throw new Error(`Internal error: expected currentModule to be set`); - } - // TODO(kschaaf): Do we need to check other declarations? The assumption is - // that even with multiple declarations (e.g. because of class interface + - // constructor), the reference would point to the same location for all, - // or else (in the case of e.g. namespace augmentation) it will be global - // and not need a specific module specifier. - const declaration = symbol?.declarations?.[0]; - if (declaration === undefined) { - throw new DiagnosticsError( - location, - `Could not find declaration for symbol '${name}'` - ); - } - // There are 6 cases to cover: - // 1. A global symbol that wasn't imported; in this case, its declaration - // will exist in a different source file than where we got the symbol - // from. For all other cases, the symbol's declaration node will be in - // this file, either as an ImportModuleSpecifier or a normal declaration. - // 2. A symbol imported from a URL. The declaration will be an - // ImportModuleSpecifier and its module path will be parsable as a URL. - // 3. A symbol imported from a relative file within this package. The - // declaration will be an ImportModuleSpecifier and its module path will - // start with a '.' - // 4. A symbol imported from an absolute path. The declaration will be an - // ImportModuleSpecifier and its module path will start with a '/' This - // is a weird case to cover in the analyzer because it isn't portable. - // 5. A symbol imported from an external package. The declaration will be an - // ImportModuleSpecifier and its module path will not start with a '.' - // 6. A symbol declared in this file. The declaration will be one of many - // declaration types (just not an ImportModuleSpecifier). - if (declaration.getSourceFile() !== location.getSourceFile()) { - // If the reference declaration doesn't exist in this module, it must have - // been a global (whose declaration is in an ambient .d.ts file) - // TODO(kschaaf): We might want to further differentiate e.g. DOM globals - // (that don't have any e.g. source to link to) from other ambient - // declarations where we could at least point to a declaration file - return new Reference({ - name, - isGlobal: true, - }); - } else { - const module = this.getImportModuleSpecifier(declaration); - if (module !== undefined) { - // The symbol was imported; check whether it is a URL, absolute, package - // local, or external - try { - new URL(module); - // If this didn't throw, module was a valid URL; no package, just - // use the URL as the module - return new Reference({ - name, - package: '', - module: module, - }); - } catch { - if (module[0] === '.') { - // Relative import from this package: use the current package and - // module path relative to this module - return new Reference({ - name, - package: this.packageJson.name!, - module: path.join( - path.dirname(this.currentModule.jsPath), - module - ), - }); - } else if (module[0] === '/') { - // Absolute import; no package, just use the entire path as the - // module - return new Reference({ - name, - package: '', - module: module, - }); - } else { - // External import: extract the npm package (taking care to respect - // npm orgs) and module specifier (if any) - const info = module.match(npmModule); - if (!info || !info.groups) { - throw new DiagnosticsError( - declaration, - `External npm package could not be parsed from module specifier '${module}'.` - ); - } - return new Reference({ - name, - package: info.groups.package, - module: info.groups.module, - }); - } - } - } else { - // Declared in this file: use the current package and module - return new Reference({ - name, - package: this.packageJson.name!, - module: this.currentModule.jsPath, - }); - } - } - } - - /** - * Converts a ts.Type into an analyzer Type object (which wraps - * the ts.Type, but also provides analyzer Reference objects). - */ - getTypeForType(type: ts.Type, location: ts.Node): Type { - // Ensure we treat inferred `foo = 'hi'` as 'string' not '"hi"' - type = this.checker.getBaseTypeOfLiteralType(type); - const text = this.checker.typeToString(type); - const typeNode = this.checker.typeToTypeNode( - type, - location, - ts.NodeBuilderFlags.IgnoreErrors - ); - if (typeNode === undefined) { - throw new DiagnosticsError( - location, - `Internal error: could not convert type to type node` - ); - } - const references: Reference[] = []; - const visit = (node: ts.Node) => { - if (ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node)) { - const name = getRootName( - ts.isTypeReferenceNode(node) ? node.typeName : node.qualifier - ); - // TODO(kschaaf): we'd like to just do - // `checker.getSymbolAtLocation(node)` to get the symbol, but it appears - // that nodes created with `checker.typeToTypeNode()` do not have - // associated symbols, so we need to look up by name via - // `checker.getSymbolsInScope()` - const symbol = this.getSymbolForName(name, location); - if (symbol === undefined) { - throw new DiagnosticsError( - location, - `Could not get symbol for '${name}'.` - ); - } - references.push(this.getReferenceForSymbol(symbol, location)); - } - ts.forEachChild(node, visit); - }; - visit(typeNode); - - return new Type(type, text, references); - } -} - -const customJSDocTypeTags = new Set(['fires']); - -/** - * Visitor that calls callback for each ts.JSDocUnknownTag containing a `{...}` - * type string - */ -const visitCustomJSDocTypeTags = ( - node: ts.Node, - callback: (tag: ts.JSDocTag, typeString: string) => void -) => { - const jsDocTags = ts.getJSDocTags(node); - if (jsDocTags?.length > 0) { - for (const tag of jsDocTags) { - if ( - ts.isJSDocUnknownTag(tag) && - customJSDocTypeTags.has(tag.tagName.text) && - typeof tag.comment === 'string' - ) { - const match = tag.comment.match(/{(?.*)}/); - if (match) { - callback(tag, match.groups!.type); - } - } - } - } - ts.forEachChild(node, (child: ts.Node) => - visitCustomJSDocTypeTags(child, callback) - ); -}; - -/** - * Gets the left-most name of a (possibly qualified) identifier, i.e. - * 'Foo' returns 'Foo', but 'ts.SyntaxKind' returns 'ts'. This is the - * symbol that would need to be imported into a given scope. - */ -const getRootName = ( - name: ts.Identifier | ts.QualifiedName | undefined -): string => { - if (name === undefined) { - return ''; - } - if (ts.isQualifiedName(name)) { - return getRootName(name.left); - } else { - return name.text; - } -}; - -const customJSDocTypeInferPrefix = '__$$customJsDOCType'; - -/** - * Visitor that calls callback for each dummy type declaration created to - * infer types from jsDoc strings. - */ -const visitCustomJSDocTypeInferredTypes = ( - node: ts.Node, - callback: (node: ts.VariableDeclaration) => void -) => { - if ( - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.name.text.startsWith(customJSDocTypeInferPrefix) - ) { - callback(node); - } - ts.forEachChild(node, (child) => - visitCustomJSDocTypeInferredTypes(child, callback) - ); -}; - -/** - * Returns the ts.JSDocTag tag for a given diagnostic error. - * - * Each type declaration includes an index in its identifier, which is extracted - * and used to identify the jsDoc tag. - */ -const getTagForError = ( - error: ts.Diagnostic, - tags: ts.JSDocTag[] -): ts.JSDocTag | undefined => { - if (error.file === undefined) { - return; - } - const beforeError = error.file.text.slice(0, error.start); - const symbolStart = beforeError.lastIndexOf(customJSDocTypeInferPrefix); - const index = parseInt( - beforeError.slice(symbolStart + customJSDocTypeInferPrefix.length), - 10 - ); - return tags[index]; -}; diff --git a/packages/labs/analyzer/src/lib/references.ts b/packages/labs/analyzer/src/lib/references.ts new file mode 100644 index 0000000000..046dbad338 --- /dev/null +++ b/packages/labs/analyzer/src/lib/references.ts @@ -0,0 +1,415 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import ts from 'typescript'; +import {DiagnosticsError} from './errors.js'; +import {AnalyzerInterface, LocalNameOrReference, Reference} from './model.js'; +import { + getResolvedExportFromSourcePath, + getPathForModuleSpecifier, + getModuleInfo, +} from './javascript/modules.js'; +import {AbsolutePath} from './paths.js'; + +const npmModule = /^(?(@[^/]+\/[^/]+)|[^/]+)\/?(?.*)$/; + +/** + * Returns a ts.Symbol for a name in scope at a given location in the AST. + * TODO(kschaaf): There are ~1748 symbols in scope of a typical hello world, + * due to DOM globals. Perf might become an issue here. + */ +export const getSymbolForName = ( + name: string, + location: ts.Node, + analyzer: AnalyzerInterface +): ts.Symbol | undefined => { + return analyzer.program + .getTypeChecker() + .getSymbolsInScope( + location, + (ts.SymbolFlags as unknown as {All: number}).All + ) + .filter((s) => s.name === name)[0]; +}; + +/** + * Returns if the given declaration is exported from the module or not. + */ +export const hasExportKeyword = (node: ts.Statement) => + !!node.modifiers?.find((m) => m.kind === ts.SyntaxKind.ExportKeyword); + +interface ModuleSpecifierInfo { + specifier: string; + location: ts.Node; + name: string; +} + +/** + * Returns the module specifier expression for a declaration if it was imported, + * or `undefined` if the declaration was not imported. + */ +const getImportSpecifierInfo = ( + declaration: ts.Node +): ModuleSpecifierInfo | undefined => { + // TODO(kschaaf) support the various import syntaxes, e.g. `import {foo as bar} from 'baz'` + if ( + ts.isImportSpecifier(declaration) && + ts.isNamedImports(declaration.parent) && + ts.isImportClause(declaration.parent.parent) && + ts.isImportDeclaration(declaration.parent.parent.parent) + ) { + const specifierExpression = + declaration.parent.parent.parent.moduleSpecifier; + const specifier = getSpecifierString(specifierExpression); + return { + specifier, + location: specifierExpression, + name: declaration.propertyName?.text ?? declaration.name.text, + }; + } + return undefined; +}; + +/** + * Returns an analyzer `Reference` object for the given identifier. + * + * If the symbol's declaration was imported, the Reference will be based on + * the import's module specifier; otherwise the Reference will point to the + * current module being analyzed. + */ +export const getReferenceForIdentifier = ( + identifier: ts.Identifier, + analyzer: AnalyzerInterface +) => { + const symbol = analyzer.program + .getTypeChecker() + .getSymbolAtLocation(identifier); + if (symbol === undefined) { + throw new DiagnosticsError( + identifier, + 'Internal error: Could not get symbol for identifier.' + ); + } + return getReferenceForSymbol(symbol, identifier, analyzer); +}; + +/** + * Returns an analyzer `Reference` model for the given ts.Symbol. + * + * If the symbol's declaration was imported, the Reference will be based on + * the import's module specifier; otherwise the Reference will point to the + * current module being analyzed. + */ +export function getReferenceForSymbol( + symbol: ts.Symbol, + location: ts.Node, + analyzer: AnalyzerInterface +): Reference { + const {name: symbolName} = symbol; + // TODO(kschaaf): Do we need to check other declarations? The assumption is + // that even with multiple declarations (e.g. because of class interface + + // constructor), the reference would point to the same location for all, + // or else (in the case of e.g. namespace augmentation) it will be global + // and not need a specific module specifier. + const declaration = symbol?.declarations?.[0]; + if (declaration === undefined) { + throw new DiagnosticsError( + location, + `Could not find declaration for symbol '${symbolName}'` + ); + } + const declarationSourceFile = declaration.getSourceFile(); + const locationSourceFile = location.getSourceFile(); + // There are three top-level cases to cover: + // 1. A global symbol that wasn't imported. + // 2. An imported symbol + // 3. A symbol declared in this file. + if (declarationSourceFile !== locationSourceFile) { + // If the reference declaration doesn't exist in this module, it must have + // been a global (whose declaration is in an ambient .d.ts file) + // TODO(kschaaf): We might want to further differentiate e.g. DOM globals + // (that don't have any e.g. source to link to) from other ambient + // declarations where we could at least point to a declaration file + return getGlobalReference(declarationSourceFile, symbolName, analyzer); + } else { + // For all other cases, the symbol's declaration node will be in this file, + // either as an ImportDeclaration or a normal declaration. + const importInfo = getImportSpecifierInfo(declaration); + if (importInfo !== undefined) { + // Declaration was imported + return getImportReference( + importInfo.specifier, + importInfo.location, + importInfo.name, + analyzer + ); + } else { + // Declared in this file: use the current package and module + return getLocalReference(location, symbolName, analyzer); + } + } +} + +/** + * Returns a `Reference` for a global symbol that was not imported. + */ +const getGlobalReference = ( + declarationSourceFile: ts.SourceFile, + name: string, + analyzer: AnalyzerInterface +) => { + return new Reference({ + name, + isGlobal: true, + dereference: () => + getResolvedExportFromSourcePath( + declarationSourceFile.fileName as AbsolutePath, + name, + analyzer + ), + }); +}; + +/** + * Returns a `Reference` for a symbol that was imported. + * + * There are 4 main categories of imports we cover: + * + * 1. A symbol imported from a URL. The declaration will be an + * ImportModuleSpecifier and its module path will be parsable as a URL. + * + * 2. A symbol imported from a relative file within this package. The + * declaration will be an ImportModuleSpecifier and its module path will + * start with a '.' + * + * 3. A symbol imported from an absolute path. The declaration will be an + * ImportModuleSpecifier and its module path will start with a '/' This is a + * weird case to cover in the analyzer because it isn't portable. + * + * 4. A symbol imported from an external package. The declaration will be an + * ImportModuleSpecifier and its module path will not start with a '.' + */ +export const getImportReference = ( + specifier: string, + location: ts.Node, + name: string, + analyzer: AnalyzerInterface +) => { + const {path} = analyzer; + let refPackage; + let refModule; + // Check whether it is a URL, absolute, package local, or external + try { + new URL(specifier); + refPackage = ''; + refModule = specifier; + } catch { + if (specifier[0] === '.') { + // Relative import from this package: use the current package and + // module path relative to this module + const sourceFilePath = location.getSourceFile().fileName as AbsolutePath; + const module = getModuleInfo(sourceFilePath, analyzer); + refPackage = module.packageJson.name; + refModule = path.join(path.dirname(module.jsPath), specifier); + } else if (analyzer.path.isAbsolute(specifier)) { + // Absolute import; no package, just use the entire path as the + // module + refPackage = ''; + refModule = specifier; + } else { + // External import: extract the npm package (taking care to respect + // npm orgs) and module specifier (if any) + const info = specifier.match(npmModule); + if (!info || !info.groups) { + throw new DiagnosticsError( + location, + `External npm package could not be parsed from module specifier '${specifier}'.` + ); + } + refPackage = info.groups.package; + refModule = info.groups.module || undefined; + } + } + return new Reference({ + name, + package: refPackage, + module: refModule, + dereference: () => + getResolvedExportFromSourcePath( + getPathForModuleSpecifier(specifier, location, analyzer), + name, + analyzer + ), + }); +}; + +/** + * Returns a `Reference` for a symbol that was imported. + */ +export const getImportReferenceForSpecifierExpression = ( + specifierExpression: ts.Expression, + name: string, + analyzer: AnalyzerInterface +) => { + const specifier = getSpecifierString(specifierExpression); + return getImportReference(specifier, specifierExpression, name, analyzer); +}; + +/** + * Returns a `Reference` to a symbol declared in the current source file. + */ +const getLocalReference = ( + location: ts.Node, + name: string, + analyzer: AnalyzerInterface +) => { + const module = getModuleInfo( + location.getSourceFile().fileName as AbsolutePath, + analyzer + ); + return new Reference({ + name, + package: module.packageJson.name, + module: module.jsPath, + dereference: () => + getResolvedExportFromSourcePath( + location.getSourceFile().fileName as AbsolutePath, + name, + analyzer + ), + }); +}; + +/** + * For a given export clause and (optional) specifier from an export statement, + * returns an array of objects mapping the export name to a + * LocalNameOrReference, which is a string name that can be looked up directly + * in `getDeclaration()` of the declaring module for local declarations, or a + * `Reference` in the case of re-exported declarations. + * + * For example: + * ``` + * import {a} from 'foo'; + * const b = 'b'; + * const c = 'c'; + * export {a as x, b as y, c}; + * ``` + * This would return (using pseudo-code for Reference objects): + * ``` + * [ + * {name: 'x', reference: new Reference('a', 'foo')}, + * {name: 'y', reference: 'b'}, + * {name: 'c', reference: 'c'}, + * ] + * ``` + * + * This also handles explicit re-export syntax, which all become References: + * ``` + * export {a as x, b as y, c} from 'foo'; + * ``` + * + * Finally, this handles namespace exports, which become a single Reference: + * ``` + * export * as ns from 'foo'; + * ``` + * becomes: + * ``` + * [{name: 'ns', reference: new Reference('*', 'foo')}] + * ``` + */ +export const getExportReferences = ( + exportClause: ts.NamedExportBindings, + moduleSpecifier: ts.Expression | undefined, + analyzer: AnalyzerInterface +): Array<{exportName: string; decNameOrRef: LocalNameOrReference}> => { + const refs: Array<{exportName: string; decNameOrRef: string | Reference}> = + []; + if (ts.isNamedExports(exportClause)) { + for (const el of exportClause.elements) { + const exportName = el.name.getText(); + const localNameNode = el.propertyName ?? el.name; + const localName = localNameNode.getText(); + if (moduleSpecifier !== undefined) { + // This was an explicit re-export (e.g. `export {a} from 'foo'`), so add + // a Reference + const specifier = getSpecifierString(moduleSpecifier); + refs.push({ + exportName, + decNameOrRef: getImportReference( + specifier, + moduleSpecifier, + localName, + analyzer + ), + }); + } else { + // Get the declaration for this symbol, so we can determine if + // it was declared locally or not. Note we use name-based searching + // to find the symbol, because `getSymbolAtLocation()` for + // `export {Foo}` will annoyingly just point back to the export + // line, rather than the location it was actually declared. + const symbol = getSymbolForName(localName, localNameNode, analyzer); + const decl = symbol?.declarations?.[0]; + if (symbol === undefined || decl === undefined) { + throw new DiagnosticsError( + el, + `Could not find declaration for symbol` + ); + } + if (ts.isImportSpecifier(decl)) { + // If the declaration was an import specifier, this means it's being + // re-exported, so add a Reference + refs.push({ + exportName, + decNameOrRef: getReferenceForSymbol(symbol, decl, analyzer), + }); + } else { + // Otherwise, the declaration is local, so just add its name; this + // can be looked up directly in `getDeclaration` for the module + refs.push({exportName, decNameOrRef: localName}); + } + } + } + } else if ( + // e.g. `export * as ns from 'foo'`; + ts.isNamespaceExport(exportClause) && + moduleSpecifier !== undefined + ) { + const specifier = getSpecifierString(moduleSpecifier); + refs.push({ + exportName: exportClause.name.getText(), + decNameOrRef: getImportReference( + specifier, + moduleSpecifier, + '*', + analyzer + ), + }); + } else { + throw new DiagnosticsError( + exportClause, + `Unhandled form of ExportDeclaration` + ); + } + return refs; +}; + +/** + * Returns the specifier string from a specifier expression. + * + * For a given import statement: + * ``` + * import {foo} from 'foo/bar.js'; + * ``` + * The specifierExpression is the string literal 'foo/bar.js' whose getText() + * includes the quotes. This function returns the string value without the + * quotes. + */ +export const getSpecifierString = (specifierExpression: ts.Expression) => { + // A specifier expression is always expected to be a quoted string literal. + // Slice off the quotes and return the text. + return specifierExpression.getText().slice(1, -1); +}; diff --git a/packages/labs/analyzer/src/lib/types.ts b/packages/labs/analyzer/src/lib/types.ts new file mode 100644 index 0000000000..29c27bc54c --- /dev/null +++ b/packages/labs/analyzer/src/lib/types.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import ts from 'typescript'; +import {DiagnosticsError} from './errors.js'; +import {getPackageInfo} from './javascript/packages.js'; +import {Type, Reference, AnalyzerInterface} from './model.js'; +import { + AbsolutePath, + absoluteToPackage, + PackagePath, + resolveExtension, +} from './paths.js'; +import { + getImportReference, + getReferenceForSymbol, + getSymbolForName, +} from './references.js'; + +/** + * Returns an analyzer `Type` object for the given jsDoc tag. + * + * Note, the tag type must + */ +export const getTypeForJSDocTag = ( + tag: ts.JSDocTag, + analyzer: AnalyzerInterface +): Type | undefined => { + const typeString = + ts.isJSDocUnknownTag(tag) && typeof tag.comment === 'string' + ? tag.comment?.match(/{(?.*)}/)?.groups?.type + : undefined; + if (typeString !== undefined) { + const typeNode = parseType(typeString); + if (typeNode == undefined) { + throw new DiagnosticsError( + tag, + `Internal error: failed to parse type from JSDoc comment.` + ); + } + const type = analyzer.program + .getTypeChecker() + .getTypeFromTypeNode(typeNode); + return new Type({ + type, + text: typeString, + getReferences: () => getReferencesForTypeNode(typeNode, tag, analyzer), + }); + } else { + return undefined; + } +}; + +/** + * Returns an analyzer `Type` object for the given AST node. + */ +export const getTypeForNode = ( + node: ts.Node, + analyzer: AnalyzerInterface +): Type => { + // Since getTypeAtLocation will return `any` for an untyped node, to support + // jsdoc @type for JS (TBD), we look at the jsdoc type first. + const jsdocType = ts.getJSDocType(node); + return getTypeForType( + jsdocType + ? analyzer.program.getTypeChecker().getTypeFromTypeNode(jsdocType) + : analyzer.program.getTypeChecker().getTypeAtLocation(node), + node, + analyzer + ); +}; + +/** + * Converts a ts.Type into an analyzer Type object (which wraps + * the ts.Type, but also provides analyzer Reference objects). + */ +const getTypeForType = ( + type: ts.Type, + location: ts.Node, + analyzer: AnalyzerInterface +): Type => { + const checker = analyzer.program.getTypeChecker(); + // Ensure we treat inferred `foo = 'hi'` as 'string' not '"hi"' + type = checker.getBaseTypeOfLiteralType(type); + const text = checker.typeToString(type); + const typeNode = checker.typeToTypeNode( + type, + location, + ts.NodeBuilderFlags.IgnoreErrors + ); + if (typeNode === undefined) { + throw new DiagnosticsError( + location, + `Internal error: could not convert type to type node` + ); + } + return new Type({ + type, + text, + getReferences: () => getReferencesForTypeNode(typeNode, location, analyzer), + }); +}; + +/** + * For a given TypeNode syntax tree, walk the AST and extract Reference + * model objects for any TypeReferenceNode or ImportTypeNode's. + */ +const getReferencesForTypeNode = ( + typeNode: ts.TypeNode, + location: ts.Node, + analyzer: AnalyzerInterface +): Reference[] => { + const references: Reference[] = []; + const visit = (node: ts.Node) => { + if (ts.isTypeReferenceNode(node)) { + const name = getRootName(node.typeName); + // TODO(kschaaf): we'd like to just do + // `checker.getSymbolAtLocation(node)` to get the symbol, but it appears + // that nodes created with `checker.typeToTypeNode()` do not have + // associated symbols, so we need to look up by name via + // `checker.getSymbolsInScope()` + const symbol = getSymbolForName(name, location, analyzer); + if (symbol === undefined) { + throw new DiagnosticsError( + location, + `Could not get symbol for '${name}'.` + ); + } + references.push(getReferenceForSymbol(symbol, location, analyzer)); + } else if (ts.isImportTypeNode(node)) { + if (!ts.isLiteralTypeNode(node.argument)) { + throw new DiagnosticsError(node, 'Expected a string literal.'); + } + const name = getRootName(node.qualifier); + if (!ts.isStringLiteral(node.argument.literal)) { + throw new DiagnosticsError( + location, + `Expected import specifier to be a string literal` + ); + } + const specifier = getSpecifierFromTypeImport( + node.argument.literal.text, + analyzer + ); + // TODO(kschaaf): This may have been an inferred type from a transitive + // dependency; in this case we should include version information in the + // reference model + references.push( + getImportReference(specifier, node.argument.literal, name, analyzer) + ); + } + ts.forEachChild(node, visit); + }; + visit(typeNode); + return references; +}; + +/** + * If the given specifier is an absolute path, turns it into an npm import + * specifier by looking for its package.json and using package information found + * there. + * + * If the path was not absolute, it returns the specifier as-is. + */ +const getSpecifierFromTypeImport = ( + specifier: string, + analyzer: AnalyzerInterface +) => { + specifier = analyzer.path.normalize( + resolveExtension(specifier as AbsolutePath, analyzer) + ); + if (analyzer.path.isAbsolute(specifier)) { + const { + rootDir, + name, + packageJson: {main, module}, + } = getPackageInfo(specifier as AbsolutePath, analyzer); + let modulePath = absoluteToPackage(specifier as AbsolutePath, rootDir); + const packageMain = module ?? main; + if (packageMain !== undefined && modulePath === packageMain) { + modulePath = '' as PackagePath; + } + specifier = name + (modulePath ? `/${modulePath}` : ''); + } + return specifier; +}; + +/** + * Gets the left-most name of a (possibly qualified) identifier, i.e. + * 'Foo' returns 'Foo', but 'ts.SyntaxKind' returns 'ts'. This is the + * symbol that would need to be imported into a given scope. + */ +const getRootName = ( + name: ts.Identifier | ts.QualifiedName | undefined +): string => { + if (name === undefined) { + return ''; + } + if (ts.isQualifiedName(name)) { + return getRootName(name.left); + } else { + return name.text; + } +}; + +/** + * Below is a minimal LanguageService that allows quickly parsing snippets of TS + * syntax into an AST. There's one file `contents.ts` in the program that we + * update with snippets to parse. + * + * This allows extracting a type string from a custom JSDoc comment (like + * `@event`), parsing it into syntax, and than walking it to extract references. + * + * Note, the program may have semantic errors (e.g. it may reference imports + * that aren't included in the snippet), but that's ok because we only care + * about parsing the syntax. When extracting references, we can use + * `getSymbolByName` to re-associate symbols by name from a disassociated syntax + * tree and the actual program. + * + * TODO(kschaaf): Providing diagnostic errors for semantically incorrect custom + * JSDoc types would be nice, but that's pretty difficult in analyzers + * where we don't control the TS program creation such that we can re-write + * source files, etc. (as is the case when using in plugins). + */ + +let contents = ''; +let contentsId = 0; + +const service = ts.createLanguageService( + { + getScriptFileNames: () => ['contents.ts'], + getScriptVersion: () => contentsId.toString(), + getScriptSnapshot: () => ts.ScriptSnapshot.fromString(contents), + getCurrentDirectory: () => '', + getCompilationSettings: () => ({include: ['contents.ts']}), + getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), + fileExists: () => true, + readFile: () => undefined, + readDirectory: () => [], + directoryExists: () => true, + getDirectories: () => [], + }, + ts.createDocumentRegistry() +); + +/** + * Uses the stub LSH above to parse a type string and return a syntax-only + * TypeNode (there will be no valid symbol information retrievable directly from + * these nodes). + */ +const parseType = (typeString: string): ts.TypeNode | undefined => { + // Update the file + contents = `export type typeToParse = ${typeString}`; + contentsId++; + // Get a new program & parsed source file + const sourceFile = service + .getProgram() + ?.getSourceFileByPath('contents.ts' as ts.Path); + if (sourceFile === undefined) { + return undefined; + } + // Find the type alias node and return it + let typeNode: ts.TypeNode | undefined = undefined; + ts.forEachChild(sourceFile, (node) => { + if (ts.isTypeAliasDeclaration(node)) { + typeNode = node.type; + } + }); + return typeNode; +}; diff --git a/packages/labs/analyzer/src/test/analyzer_test.ts b/packages/labs/analyzer/src/test/analyzer_test.ts index 955e77e616..d5012454cb 100644 --- a/packages/labs/analyzer/src/test/analyzer_test.ts +++ b/packages/labs/analyzer/src/test/analyzer_test.ts @@ -9,45 +9,50 @@ import {suite} from 'uvu'; import * as assert from 'uvu/assert'; import * as path from 'path'; import {fileURLToPath} from 'url'; +import {getOutputFilename, getSourceFilename, languages} from './utils.js'; -import {Analyzer, AbsolutePath} from '../index.js'; - -const test = suite<{analyzer: Analyzer; packagePath: AbsolutePath}>( - 'Basic Analyzer tests' -); - -test.before((ctx) => { - try { - const packagePath = (ctx.packagePath = fileURLToPath( - new URL('../test-files/basic-elements', import.meta.url).href - ) as AbsolutePath); - ctx.analyzer = new Analyzer(packagePath); - } catch (error) { - // Uvu has a bug where it silently ignores failures in before and after, - // see https://github.com/lukeed/uvu/issues/191. - console.error('uvu before error', error); - process.exit(1); - } -}); - -test('Reads project files', ({analyzer, packagePath}) => { - const rootFileNames = analyzer.programContext.program.getRootFileNames(); - assert.equal(rootFileNames.length, 5); - - const elementAPath = path.resolve(packagePath, 'src', 'element-a.ts'); - const sourceFile = - analyzer.programContext.program.getSourceFile(elementAPath); - assert.ok(sourceFile); -}); - -test('Analyzer finds class declarations', ({analyzer}) => { - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/class-a.ts') +import {createPackageAnalyzer, Analyzer, AbsolutePath} from '../index.js'; + +for (const lang of languages) { + const test = suite<{analyzer: Analyzer; packagePath: AbsolutePath}>( + `Basic Analyzer tests (${lang})` ); - assert.equal(elementAModule?.jsPath, path.normalize('out/class-a.js')); - assert.equal(elementAModule?.declarations.length, 1); - assert.equal(elementAModule?.declarations[0].name, 'ClassA'); -}); -test.run(); + test.before((ctx) => { + try { + const packagePath = (ctx.packagePath = fileURLToPath( + new URL(`../test-files/${lang}/basic-elements`, import.meta.url).href + ) as AbsolutePath); + ctx.analyzer = createPackageAnalyzer(packagePath); + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('Reads project files', ({analyzer, packagePath}) => { + const rootFileNames = analyzer.program.getRootFileNames(); + assert.equal(rootFileNames.length, 6); + + const elementAPath = path.resolve( + packagePath, + getSourceFilename('element-a', lang) + ); + const sourceFile = analyzer.program.getSourceFile(elementAPath); + assert.ok(sourceFile); + }); + + test('Analyzer finds class declarations', ({analyzer}) => { + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('class-a', lang) + ); + assert.equal(elementAModule?.jsPath, getOutputFilename('class-a', lang)); + assert.equal(elementAModule?.declarations.length, 1); + assert.equal(elementAModule?.declarations[0].name, 'ClassA'); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/javascript/exports_test.ts b/packages/labs/analyzer/src/test/javascript/exports_test.ts new file mode 100644 index 0000000000..6dee50f01a --- /dev/null +++ b/packages/labs/analyzer/src/test/javascript/exports_test.ts @@ -0,0 +1,390 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {suite} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import {AbsolutePath} from '../../lib/paths.js'; +import {getSourceFilename, InMemoryAnalyzer, languages} from '../utils.js'; + +for (const lang of languages) { + const test = suite<{ + analyzer: InMemoryAnalyzer; + }>(`Exports tests (${lang})`); + + test.before.each((ctx) => { + ctx.analyzer = new InMemoryAnalyzer(lang, { + '/package.json': JSON.stringify({name: '@lit-internal/in-memory-test'}), + }); + }); + + test('local declaration export via export keyword', ({analyzer}) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/a', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, undefined); + }); + + test('local declaration export via export statement', ({analyzer}) => { + analyzer.setFile( + '/a', + ` + const a = 'a'; + export {a}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/a', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, undefined); + }); + + test('local declaration export via export statement, renamed', ({ + analyzer, + }) => { + analyzer.setFile( + '/a', + ` + const aInternal = 'a'; + export {aInternal as a}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/a', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'aInternal'); + assert.equal(a.module, undefined); + }); + + test('local declaration export via export statement, multiple', ({ + analyzer, + }) => { + analyzer.setFile( + '/a', + ` + const aInternal = 'a'; + const b = 'b'; + export {aInternal as a, b, aInternal as c}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/a', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a', 'b', 'c']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'aInternal'); + assert.equal(a.module, undefined); + const b = module.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(b.module, undefined); + const c = module.getExportReference('c'); + assert.equal(c.name, 'aInternal'); + assert.equal(c.module, undefined); + }); + + test('reexport via export statement with specifier', ({analyzer}) => { + analyzer.setFile('/a', `export const a = 'a';`); + analyzer.setFile( + '/b', + ` + export {a} from './a.js'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + }); + + test('reexport via export statement with specifier, renamed', ({ + analyzer, + }) => { + analyzer.setFile('/a', `export const a = 'a';`); + analyzer.setFile( + '/b', + ` + export {a as a2} from './a.js'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a2']); + const a = module.getExportReference('a2'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + }); + + test('reexport via export statement with specifier, multiple', ({ + analyzer, + }) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + export const b = 'b'; + ` + ); + analyzer.setFile( + '/b', + ` + export {a as a2, b} from './a.js'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a2', 'b']); + const a = module.getExportReference('a2'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + const b = module.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(b.module, 'a.js'); + }); + + test('reexport via export statement of imported symbol', ({analyzer}) => { + analyzer.setFile('/a', `export const a = 'a';`); + analyzer.setFile( + '/b', + ` + import {a} from './a.js' + export {a}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + }); + + test('reexport via export statement of renamed imported symbol', ({ + analyzer, + }) => { + analyzer.setFile('/a', `export const a = 'a';`); + analyzer.setFile( + '/b', + ` + import {a as a2} from './a.js' + export {a2}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a2']); + const a = module.getExportReference('a2'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + }); + + test('reexport via export statement of renamed imported symbol, renamed', ({ + analyzer, + }) => { + analyzer.setFile('/a', `export const a = 'a';`); + analyzer.setFile( + '/b', + ` + import {a as a2} from './a.js' + export {a2 as a3}; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a3']); + const a = module.getExportReference('a3'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + }); + + test('reexport via export statement of imported symbols, multiple', ({ + analyzer, + }) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + export const b = 'b'; + export const c = 'c'; + ` + ); + analyzer.setFile( + '/b', + ` + import {a} from './a.js' + import {b, c as c2} from './a.js' + export {a, b, c2 as c}; + export {c2} + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a', 'b', 'c', 'c2']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + const b = module.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(b.module, 'a.js'); + const c = module.getExportReference('c'); + assert.equal(c.name, 'c'); + assert.equal(c.module, 'a.js'); + const c2 = module.getExportReference('c2'); + assert.equal(c2.name, 'c'); + assert.equal(c2.module, 'a.js'); + }); + + test('export {x as default}', ({analyzer}) => { + analyzer.setFile( + '/a', + ` + const a = 'a'; + const b = 'b'; + export {a as default, b} + ` + ); + const module = analyzer.getModule( + getSourceFilename('/a', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['b', 'default']); + const a = module.getExportReference('default'); + assert.equal(a.name, 'a'); + assert.equal(a.module, undefined); + const b = module.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(b.module, undefined); + }); + + test(`export * from 'module'`, ({analyzer}) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + export const b = 'b'; + ` + ); + analyzer.setFile( + '/b', + ` + export * from './a.js'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['a', 'b']); + const a = module.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'a.js'); + const b = module.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(a.module, 'a.js'); + }); + + test(`export * from 'module', transitively`, ({analyzer}) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + export const b = 'b'; + ` + ); + analyzer.setFile( + '/b', + ` + export const c = 'c'; + export const d = 'd'; + export * from './a.js'; + ` + ); + analyzer.setFile( + '/c', + ` + export * from './b.js'; + ` + ); + const moduleC = analyzer.getModule( + getSourceFilename('/c', lang) as AbsolutePath + ); + assert.equal(moduleC.exportNames.sort(), ['a', 'b', 'c', 'd']); + const a = moduleC.getExportReference('a'); + assert.equal(a.name, 'a'); + assert.equal(a.module, 'b.js'); + const b = moduleC.getExportReference('b'); + assert.equal(b.name, 'b'); + assert.equal(b.module, 'b.js'); + const c = moduleC.getExportReference('c'); + assert.equal(c.name, 'c'); + assert.equal(c.module, 'b.js'); + const d = moduleC.getExportReference('d'); + assert.equal(d.name, 'd'); + assert.equal(d.module, 'b.js'); + + const moduleB = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(moduleB.exportNames.sort(), ['a', 'b', 'c', 'd']); + const a2 = moduleB.getExportReference('a'); + assert.equal(a2.name, 'a'); + assert.equal(a2.module, 'a.js'); + const b2 = moduleB.getExportReference('b'); + assert.equal(b2.name, 'b'); + assert.equal(a2.module, 'a.js'); + const c2 = moduleB.getExportReference('c'); + assert.equal(c2.name, 'c'); + assert.equal(c2.module, undefined); + const d2 = moduleB.getExportReference('d'); + assert.equal(d2.name, 'd'); + assert.equal(d2.module, undefined); + }); + + test('export * as ns', ({analyzer}) => { + analyzer.setFile( + '/a', + ` + export const a = 'a'; + export const b = 'b'; + ` + ); + analyzer.setFile( + '/b', + ` + export * as ns from './a.js'; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/b', lang) as AbsolutePath + ); + assert.equal(module.exportNames.sort(), ['ns']); + const ns = module.getExportReference('ns'); + assert.equal(ns.name, '*'); + assert.equal(ns.module, 'a.js'); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/javascript/modules_test.ts b/packages/labs/analyzer/src/test/javascript/modules_test.ts new file mode 100644 index 0000000000..aef49451d0 --- /dev/null +++ b/packages/labs/analyzer/src/test/javascript/modules_test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {suite} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import path from 'path'; +import {fileURLToPath} from 'url'; +import {getSourceFilename, InMemoryAnalyzer, languages} from '../utils.js'; + +import { + createPackageAnalyzer, + Analyzer, + AbsolutePath, + Module, +} from '../../index.js'; + +for (const lang of languages) { + const test = suite<{ + analyzer: Analyzer; + packagePath: AbsolutePath; + module: Module; + }>(`Module tests (${lang})`); + + test.before((ctx) => { + try { + const packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/modules`, import.meta.url).href + ) as AbsolutePath; + const analyzer = createPackageAnalyzer(packagePath); + + const result = analyzer.getPackage(); + const file = getSourceFilename('module-a', lang); + const module = result.modules.find((m) => m.sourcePath === file); + if (module === undefined) { + throw new Error(`Analyzer did not analyze file '${file}'`); + } + + ctx.packagePath = packagePath; + ctx.analyzer = analyzer; + ctx.module = module; + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('Dependencies correctly analyzed', ({module}) => { + const getMonorepoSubpath = (f: string) => + f?.slice(f.lastIndexOf('packages' + path.sep)); + const expectedDeps = new Set([ + // This import will either be to a .ts file or a .js file depending on + // language, since it's in the program + getSourceFilename( + `packages/labs/analyzer/test-files/${lang}/modules/module-b`, + lang + ), + // The Lit import will always be a .d.ts file regardless of language since + // it's outside the program and has declarations + path.normalize(`packages/lit/index.d.ts`), + ]); + assert.equal(expectedDeps.size, module.dependencies.size); + for (const d of module.dependencies) { + assert.ok( + expectedDeps.has(getMonorepoSubpath(d)), + `${getMonorepoSubpath(d)} not in\n ${Array.from(expectedDeps).join( + '\n' + )}` + ); + } + }); + + test.run(); + + const cachingTest = suite<{ + analyzer: InMemoryAnalyzer; + }>(`Module caching tests (${lang})`); + + cachingTest.before.each((ctx) => { + ctx.analyzer = new InMemoryAnalyzer(lang, { + '/package.json': JSON.stringify({name: '@lit-internal/in-memory-test'}), + }); + }); + + cachingTest( + 'getModule returns same model when unchanged, different when changed', + ({analyzer}) => { + analyzer.setFile('/module', `export const foo = 'foo';`); + // Read initial model for module + const module1 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal(module1.declarations.length, 1); + // Read again with no change + const module2 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module2); + // Change dependency and we expect a different model + analyzer.setFile('/module', `export class Foo {}; export class Bar {};`); + const module3 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module2 !== module3); + assert.equal(module3.declarations.length, 2); + } + ); + + cachingTest( + 'getModule returns same model when direct dependency unchanged, different when changed', + ({analyzer}) => { + analyzer.setFile('/dep1', `export class Bar { bar = 1; }`); + analyzer.setFile( + '/module', + `import {Bar} from './dep1.js'; + export class Foo extends Bar {};` + ); + // Read initial model for module + const module1 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + // Read again with no change, we expect the cached model to be returned + const module2 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module2); + // Change dependency; we still expect the same model because nothing + // has caused the dependency model to be created yet + analyzer.setFile('/dep1', `export class Bar { bar = 2; }`); + const module3 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module3); + // Now cause the dependency model to be created and cached; we still + // expect the same model since nothing has been invalidated + const dep1 = analyzer.getModule( + getSourceFilename('/dep1', lang) as AbsolutePath + ); + const module4 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module4); + // Now change the dependency; since module depends on dep1, and since the + // dep1 model has been created/cached (and is now invalid), the module's + // model should be created anew, ensuring it has no stale information + // cached from dep1 + analyzer.setFile('/dep1', `export class Bar { bar = 2; }`); + const module5 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module4 !== module5); + // We should also see that the dependency model is re-created if we ask + // for it + const dep2 = analyzer.getModule( + getSourceFilename('/dep1', lang) as AbsolutePath + ); + assert.ok(dep1 !== dep2); + } + ); + + cachingTest( + 'getModule returns same model when transitive dependency unchanged, different when changed', + ({analyzer}) => { + analyzer.setFile('/dep2', `export class Baz { baz = 1; }`); + analyzer.setFile( + '/dep1', + `import {Baz} from './dep2.js'; + export class Bar extends Baz {}` + ); + analyzer.setFile( + '/module', + `import {Bar} from './dep1.js'; + export class Foo extends Bar {};` + ); + // Read initial models for module and deps + const module1 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + analyzer.getModule(getSourceFilename('/dep1', lang) as AbsolutePath); + analyzer.getModule(getSourceFilename('/dep2', lang) as AbsolutePath); + // Read again with no change + const module2 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module2); + // Change transitive dependency and we expect a different model + analyzer.setFile('/dep2', `export class Baz { baz = 2; }`); + const module3 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module2 !== module3); + } + ); + + cachingTest( + 'getModule returns same model when unrelated file changed', + ({analyzer}) => { + analyzer.setFile('/unrelated', `export class Unrelated { n = 1; }`); + analyzer.setFile('/dep2', `export class Baz { }`); + analyzer.setFile( + '/dep1', + `import {Baz} from './dep2.js'; + export class Bar extends Baz {}` + ); + analyzer.setFile( + '/module', + `import {Bar} from './dep1.js'; + export class Foo extends Bar {};` + ); + // Read initial models for module and deps + const module1 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + analyzer.getModule(getSourceFilename('/dep1', lang) as AbsolutePath); + analyzer.getModule(getSourceFilename('/dep2', lang) as AbsolutePath); + analyzer.getModule(getSourceFilename('/unrelated', lang) as AbsolutePath); + // Change unrelated module and we expect the same module + analyzer.setFile('/unrelated', `export class Unrelated { n = 2; }`); + const module2 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module1 === module2); + // Change transitive dependency and we expect a different model + analyzer.setFile('/dep2', `export class Baz { baz = 2; }`); + const module3 = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.ok(module2 !== module3); + } + ); + + cachingTest.run(); +} diff --git a/packages/labs/analyzer/src/test/lit-element/events_test.ts b/packages/labs/analyzer/src/test/lit-element/events_test.ts index 4f2139e387..5177b146a6 100644 --- a/packages/labs/analyzer/src/test/lit-element/events_test.ts +++ b/packages/labs/analyzer/src/test/lit-element/events_test.ts @@ -7,137 +7,156 @@ import {suite} from 'uvu'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; -import * as path from 'path'; import {fileURLToPath} from 'url'; - -import {Analyzer, AbsolutePath, LitElementDeclaration} from '../../index.js'; - -const test = suite<{ - analyzer: Analyzer; - packagePath: AbsolutePath; - element: LitElementDeclaration; -}>('LitElement event tests'); - -test.before((ctx) => { - try { - const packagePath = fileURLToPath( - new URL('../../test-files/events', import.meta.url).href - ) as AbsolutePath; - const analyzer = new Analyzer(packagePath); - - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/element-a.ts') +import {getSourceFilename, languages} from '../utils.js'; + +import { + createPackageAnalyzer, + Analyzer, + AbsolutePath, + LitElementDeclaration, +} from '../../index.js'; + +for (const lang of languages) { + const test = suite<{ + analyzer: Analyzer; + packagePath: AbsolutePath; + element: LitElementDeclaration; + }>(`LitElement event tests (${lang})`); + + test.before((ctx) => { + try { + const packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/events`, import.meta.url).href + ) as AbsolutePath; + const analyzer = createPackageAnalyzer(packagePath); + + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-a', lang) + ); + const element = elementAModule!.declarations.filter((d) => + d.isLitElementDeclaration() + )[0] as LitElementDeclaration; + + ctx.packagePath = packagePath; + ctx.analyzer = analyzer; + ctx.element = element; + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('Correct number of events found', ({element}) => { + assert.equal(element.events.size, 10); + }); + + test('Just event name', ({element}) => { + const event = element.events.get('event'); + assert.ok(event); + assert.equal(event.name, 'event'); + assert.equal(event.description, undefined); + }); + + test('Event with description', ({element}) => { + const event = element.events.get('event-two'); + assert.ok(event); + assert.equal(event.name, 'event-two'); + assert.equal(event.description, 'This is an event'); + }); + + test('Event with dash-separated description', ({element}) => { + const event = element.events.get('event-three'); + assert.ok(event); + assert.equal(event.name, 'event-three'); + assert.equal(event.description, 'This is another event'); + }); + + test('Event with type', ({element}) => { + const event = element.events.get('typed-event'); + assert.ok(event); + assert.equal(event.name, 'typed-event'); + assert.equal(event.type?.text, 'MouseEvent'); + assert.equal(event.description, undefined); + assert.equal(event.type?.references[0].name, 'MouseEvent'); + assert.equal(event.type?.references[0].isGlobal, true); + }); + + test('Event with type and description', ({element}) => { + const event = element.events.get('typed-event-two'); + assert.ok(event); + assert.equal(event.name, 'typed-event-two'); + assert.equal(event.type?.text, 'MouseEvent'); + assert.equal(event.description, 'This is a typed event'); + }); + + test('Event with type and dash-separated description', ({element}) => { + const event = element.events.get('typed-event-three'); + assert.ok(event); + assert.equal(event.name, 'typed-event-three'); + assert.equal(event.type?.text, 'MouseEvent'); + assert.equal(event.description, 'This is another typed event'); + }); + + test('Event with local custom event type', ({element}) => { + const event = element.events.get('local-custom-event'); + assert.ok(event); + assert.equal(event.type?.text, 'LocalCustomEvent'); + assert.equal( + event.type?.references[0].package, + '@lit-internal/test-events' + ); + assert.equal(event.type?.references[0].module, 'element-a.js'); + assert.equal(event.type?.references[0].name, 'LocalCustomEvent'); + }); + + test('Event with imported custom event type', ({element}) => { + const event = element.events.get('external-custom-event'); + assert.ok(event); + assert.equal(event.type?.text, 'ExternalCustomEvent'); + assert.equal( + event.type?.references[0].package, + '@lit-internal/test-events' ); - const element = elementAModule!.declarations.filter((d) => - d.isLitElementDeclaration() - )[0] as LitElementDeclaration; - - ctx.packagePath = packagePath; - ctx.analyzer = analyzer; - ctx.element = element; - } catch (error) { - // Uvu has a bug where it silently ignores failures in before and after, - // see https://github.com/lukeed/uvu/issues/191. - console.error('uvu before error', error); - process.exit(1); - } -}); - -test('Correct number of events found', ({element}) => { - assert.equal(element.events.size, 10); -}); - -test('Just event name', ({element}) => { - const event = element.events.get('event'); - assert.ok(event); - assert.equal(event.name, 'event'); - assert.equal(event.description, undefined); -}); - -test('Event with description', ({element}) => { - const event = element.events.get('event-two'); - assert.ok(event); - assert.equal(event.name, 'event-two'); - assert.equal(event.description, 'This is an event'); -}); - -test('Event with dash-separated description', ({element}) => { - const event = element.events.get('event-three'); - assert.ok(event); - assert.equal(event.name, 'event-three'); - assert.equal(event.description, 'This is another event'); -}); - -test('Event with type', ({element}) => { - const event = element.events.get('typed-event'); - assert.ok(event); - assert.equal(event.name, 'typed-event'); - assert.equal(event.type?.text, 'MouseEvent'); - assert.equal(event.description, undefined); - assert.equal(event.type?.references[0].name, 'MouseEvent'); - assert.equal(event.type?.references[0].isGlobal, true); -}); - -test('Event with type and description', ({element}) => { - const event = element.events.get('typed-event-two'); - assert.ok(event); - assert.equal(event.name, 'typed-event-two'); - assert.equal(event.type?.text, 'MouseEvent'); - assert.equal(event.description, 'This is a typed event'); -}); - -test('Event with type and dash-separated description', ({element}) => { - const event = element.events.get('typed-event-three'); - assert.ok(event); - assert.equal(event.name, 'typed-event-three'); - assert.equal(event.type?.text, 'MouseEvent'); - assert.equal(event.description, 'This is another typed event'); -}); - -test('Event with local custom event type', ({element}) => { - const event = element.events.get('local-custom-event'); - assert.ok(event); - assert.equal(event.type?.text, 'LocalCustomEvent'); - assert.equal(event.type?.references[0].package, '@lit-internal/test-events'); - assert.equal(event.type?.references[0].module, 'element-a.js'); - assert.equal(event.type?.references[0].name, 'LocalCustomEvent'); -}); - -test('Event with imported custom event type', ({element}) => { - const event = element.events.get('external-custom-event'); - assert.ok(event); - assert.equal(event.type?.text, 'ExternalCustomEvent'); - assert.equal(event.type?.references[0].package, '@lit-internal/test-events'); - assert.equal(event.type?.references[0].module, 'custom-event.js'); - assert.equal(event.type?.references[0].name, 'ExternalCustomEvent'); -}); - -test('Event with generic custom event type', ({element}) => { - const event = element.events.get('generic-custom-event'); - assert.ok(event); - assert.equal(event.type?.text, 'CustomEvent'); - assert.equal(event.type?.references[0].name, 'CustomEvent'); - assert.equal(event.type?.references[0].isGlobal, true); - assert.equal(event.type?.references[1].package, '@lit-internal/test-events'); - assert.equal(event.type?.references[1].module, 'custom-event.js'); - assert.equal(event.type?.references[1].name, 'ExternalClass'); -}); - -test('Event with custom event type with inline detail', ({element}) => { - const event = element.events.get('inline-detail-custom-event'); - assert.ok(event); - assert.equal( - event.type?.text, - 'CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>' - ); - assert.equal(event.type?.references[0].name, 'CustomEvent'); - assert.equal(event.type?.references[0].isGlobal, true); - assert.equal(event.type?.references[1].name, 'MouseEvent'); - assert.equal(event.type?.references[1].isGlobal, true); - assert.equal(event.type?.references[2].package, '@lit-internal/test-events'); - assert.equal(event.type?.references[2].module, 'custom-event.js'); - assert.equal(event.type?.references[2].name, 'ExternalClass'); -}); - -test.run(); + assert.equal(event.type?.references[0].module, 'custom-event.js'); + assert.equal(event.type?.references[0].name, 'ExternalCustomEvent'); + }); + + test('Event with generic custom event type', ({element}) => { + const event = element.events.get('generic-custom-event'); + assert.ok(event); + assert.equal(event.type?.text, 'CustomEvent'); + assert.equal(event.type?.references[0].name, 'CustomEvent'); + assert.equal(event.type?.references[0].isGlobal, true); + assert.equal( + event.type?.references[1].package, + '@lit-internal/test-events' + ); + assert.equal(event.type?.references[1].module, 'custom-event.js'); + assert.equal(event.type?.references[1].name, 'ExternalClass'); + }); + + test('Event with custom event type with inline detail', ({element}) => { + const event = element.events.get('inline-detail-custom-event'); + assert.ok(event); + assert.equal( + event.type?.text, + 'CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>' + ); + assert.equal(event.type?.references[0].name, 'CustomEvent'); + assert.equal(event.type?.references[0].isGlobal, true); + assert.equal(event.type?.references[1].name, 'MouseEvent'); + assert.equal(event.type?.references[1].isGlobal, true); + assert.equal( + event.type?.references[2].package, + '@lit-internal/test-events' + ); + assert.equal(event.type?.references[2].module, 'custom-event.js'); + assert.equal(event.type?.references[2].name, 'ExternalClass'); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts b/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts new file mode 100644 index 0000000000..da5fcc9187 --- /dev/null +++ b/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {suite} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import {fileURLToPath} from 'url'; +import {getSourceFilename, languages} from '../utils.js'; + +import {createPackageAnalyzer, Module, AbsolutePath} from '../../index.js'; + +for (const lang of languages) { + const test = suite<{ + getModule: (name: string) => Module; + }>(`LitElement event tests (${lang})`); + + test.before((ctx) => { + try { + const packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/jsdoc`, import.meta.url).href + ) as AbsolutePath; + const analyzer = createPackageAnalyzer(packagePath); + ctx.getModule = (name: string) => + analyzer.getModule( + getSourceFilename( + analyzer.path.join(packagePath, name), + lang + ) as AbsolutePath + ); + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + // slots + + test('slots - Correct number found', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + assert.equal(element.slots.size, 5); + }); + + test('slots - basic', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const slot = element.slots.get('basic'); + assert.ok(slot); + assert.equal(slot.summary, undefined); + assert.equal(slot.description, undefined); + }); + + test('slots - with-summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const slot = element.slots.get('with-summary'); + assert.ok(slot); + assert.equal(slot.summary, 'Summary for with-summary'); + assert.equal(slot.description, undefined); + }); + + test('slots - with-summary-dash', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const slot = element.slots.get('with-summary-dash'); + assert.ok(slot); + assert.equal(slot.summary, 'Summary for with-summary-dash'); + assert.equal(slot.description, undefined); + }); + + test('slots - with-summary-colon', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const slot = element.slots.get('with-summary-colon'); + assert.ok(slot); + assert.equal(slot.summary, 'Summary for with-summary-colon'); + assert.equal(slot.description, undefined); + }); + + test('slots - with-description', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const slot = element.slots.get('with-description'); + assert.ok(slot); + assert.equal(slot.summary, 'Summary for with-description'); + assert.equal( + slot.description, + 'Description for with-description\nMore description for with-description\n\nEven more description for with-description' + ); + }); + + // cssParts + + test('cssParts - Correct number found', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + assert.equal(element.cssParts.size, 5); + }); + + test('cssParts - basic', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const part = element.cssParts.get('basic'); + assert.ok(part); + assert.equal(part.summary, undefined); + assert.equal(part.description, undefined); + }); + + test('cssParts - with-summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const part = element.cssParts.get('with-summary'); + assert.ok(part); + assert.equal(part.summary, 'Summary for :part(with-summary)'); + assert.equal(part.description, undefined); + }); + + test('cssParts - with-summary-dash', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const part = element.cssParts.get('with-summary-dash'); + assert.ok(part); + assert.equal(part.summary, 'Summary for :part(with-summary-dash)'); + assert.equal(part.description, undefined); + }); + + test('cssParts - with-summary-colon', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const part = element.cssParts.get('with-summary-colon'); + assert.ok(part); + assert.equal(part.summary, 'Summary for :part(with-summary-colon)'); + assert.equal(part.description, undefined); + }); + + test('cssParts - with-description', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const part = element.cssParts.get('with-description'); + assert.ok(part); + assert.equal(part.summary, 'Summary for :part(with-description)'); + assert.equal( + part.description, + 'Description for :part(with-description)\nMore description for :part(with-description)\n\nEven more description for :part(with-description)' + ); + }); + + // cssProperties + + test('cssProperties - Correct number found', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + assert.equal(element.cssProperties.size, 10); + }); + + test('cssProperties - basic', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--basic'); + assert.ok(prop); + assert.equal(prop.summary, undefined); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - with-summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--with-summary'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --with-summary'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - with-summary-colon', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--with-summary-colon'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --with-summary-colon'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - with-summary-dash', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--with-summary-dash'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --with-summary-dash'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - with-description', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--with-description'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --with-description'); + assert.equal( + prop.description, + 'Description for --with-description\nMore description for --with-description\n\nEven more description for --with-description' + ); + }); + + test('cssProperties - short-basic', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--short-basic'); + assert.ok(prop); + assert.equal(prop.summary, undefined); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - short-with-summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--short-with-summary'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --short-with-summary'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - short-with-summary-colon', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--short-with-summary-colon'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --short-with-summary-colon'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - short-with-summary-dash', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--short-with-summary-dash'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --short-with-summary-dash'); + assert.equal(prop.description, undefined); + }); + + test('cssProperties - short-with-description', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isLitElementDeclaration()); + const prop = element.cssProperties.get('--short-with-description'); + assert.ok(prop); + assert.equal(prop.summary, 'Summary for --short-with-description'); + assert.equal( + prop.description, + 'Description for --short-with-description\nMore description for --short-with-description\n\nEven more description for --short-with-description' + ); + }); + + // description, summary, deprecated + + test('tagged description and summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration('TaggedDescription'); + assert.ok(element.isLitElementDeclaration()); + assert.equal( + element.description, + `TaggedDescription description. Lorem ipsum dolor sit amet, consectetur +adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna +aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris +nisi ut aliquip ex ea commodo consequat.` + ); + assert.equal(element.summary, `TaggedDescription summary.`); + assert.equal(element.deprecated, `TaggedDescription deprecated message.`); + }); + + test('untagged description', ({getModule}) => { + const element = getModule('element-a').getDeclaration( + 'UntaggedDescription' + ); + assert.ok(element.isLitElementDeclaration()); + assert.equal( + element.description, + `UntaggedDescription description. Lorem ipsum dolor sit amet, consectetur +adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna +aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris +nisi ut aliquip ex ea commodo consequat.` + ); + assert.equal(element.summary, `UntaggedDescription summary.`); + assert.equal(element.deprecated, `UntaggedDescription deprecated message.`); + }); + + test('untagged description and summary', ({getModule}) => { + const element = getModule('element-a').getDeclaration( + 'UntaggedDescSummary' + ); + assert.ok(element.isLitElementDeclaration()); + assert.equal( + element.description, + `UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur +adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna +aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris +nisi ut aliquip ex ea commodo consequat.` + ); + assert.equal(element.summary, `UntaggedDescSummary summary.`); + assert.equal(element.deprecated, true); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/lit-element/lit-element_test.ts b/packages/labs/analyzer/src/test/lit-element/lit-element_test.ts index e7fdc69c8b..cd670188f2 100644 --- a/packages/labs/analyzer/src/test/lit-element/lit-element_test.ts +++ b/packages/labs/analyzer/src/test/lit-element/lit-element_test.ts @@ -7,81 +7,152 @@ import {suite} from 'uvu'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; -import * as path from 'path'; import {fileURLToPath} from 'url'; +import {getSourceFilename, languages} from '../utils.js'; -import {Analyzer, AbsolutePath, LitElementDeclaration} from '../../index.js'; - -const test = suite<{analyzer: Analyzer; packagePath: AbsolutePath}>( - 'LitElement tests' -); - -test.before((ctx) => { - try { - const packagePath = (ctx.packagePath = fileURLToPath( - new URL('../../test-files/basic-elements', import.meta.url).href - ) as AbsolutePath); - ctx.analyzer = new Analyzer(packagePath); - } catch (error) { - // Uvu has a bug where it silently ignores failures in before and after, - // see https://github.com/lukeed/uvu/issues/191. - console.error('uvu before error', error); - process.exit(1); - } -}); - -test('isLitElementDeclaration returns false for non-LitElement', ({ - analyzer, -}) => { - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/not-lit.ts') - ); - const decl = elementAModule!.declarations.find((d) => d.name === 'NotLit')!; - assert.ok(decl); - assert.equal(decl.isLitElementDeclaration(), false); -}); - -test('Analyzer finds LitElement declarations', ({analyzer}) => { - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/element-a.ts') - ); - assert.equal(elementAModule?.declarations.length, 1); - const decl = elementAModule!.declarations[0]; - assert.equal(decl.name, 'ElementA'); - assert.ok(decl.isLitElementDeclaration()); - - // TODO (justinfagnani): test for customElements.define() - assert.equal((decl as LitElementDeclaration).tagname, 'element-a'); -}); - -test('Analyzer finds LitElement properties via decorators', ({analyzer}) => { - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/element-a.ts') +import {createPackageAnalyzer, Analyzer, AbsolutePath} from '../../index.js'; + +// Get actual constructor to test internal ability to assert the type +// of a dereferenced Declaration +import {ClassDeclaration, LitElementDeclaration} from '../../lib/model.js'; + +for (const lang of languages) { + const test = suite<{analyzer: Analyzer; packagePath: AbsolutePath}>( + `LitElement tests (${lang})` ); - const decl = elementAModule!.declarations[0] as LitElementDeclaration; - - // ElementA has `a` and `b` properties - assert.equal(decl.reactiveProperties.size, 2); - - const aProp = decl.reactiveProperties.get('a'); - assert.ok(aProp); - assert.equal(aProp.name, 'a', 'property name'); - assert.equal(aProp.attribute, 'a', 'attribute name'); - assert.equal(aProp.type.text, 'string'); - // TODO (justinfagnani) better assertion - assert.ok(aProp.type); - assert.equal(aProp.reflect, false); - - const bProp = decl.reactiveProperties.get('b'); - assert.ok(bProp); - assert.equal(bProp.name, 'b'); - assert.equal(bProp.attribute, 'bbb'); - // This is inferred - assert.equal(bProp.type.text, 'number'); - assert.equal(bProp.typeOption, 'Number'); -}); - -test.run(); + + test.before((ctx) => { + try { + const packagePath = (ctx.packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/basic-elements`, import.meta.url).href + ) as AbsolutePath); + ctx.analyzer = createPackageAnalyzer(packagePath); + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('isLitElementDeclaration returns false for non-LitElement', ({ + analyzer, + }) => { + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('not-lit', lang) + ); + const decl = elementAModule?.getDeclaration('NotLit'); + assert.ok(decl); + assert.equal(decl.isLitElementDeclaration(), false); + }); + + test('Analyzer finds LitElement declarations', ({analyzer}) => { + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-a', lang) + ); + assert.equal(elementAModule?.declarations.length, 1); + const decl = elementAModule!.declarations[0]; + assert.equal(decl.name, 'ElementA'); + assert.ok(decl.isLitElementDeclaration()); + assert.equal(decl.tagname, 'element-a'); + }); + + test('Analyzer finds LitElement properties ', ({analyzer}) => { + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-a', lang) + ); + const decl = elementAModule?.getDeclaration('ElementA'); + assert.ok(decl?.isLitElementDeclaration()); + + // ElementA has `a` and `b` properties + assert.equal(decl.reactiveProperties.size, 3); + + const aProp = decl.reactiveProperties.get('a'); + assert.ok(aProp); + assert.equal(aProp.name, 'a', 'property name'); + assert.equal(aProp.attribute, 'a', 'attribute name'); + assert.equal(aProp.type?.text, 'string'); + // TODO (justinfagnani) better assertion + assert.ok(aProp.type); + assert.equal(aProp.reflect, false); + + const bProp = decl.reactiveProperties.get('b'); + assert.ok(bProp); + assert.equal(bProp.name, 'b'); + assert.equal(bProp.attribute, 'bbb'); + assert.equal(bProp.type?.text, 'number'); + assert.equal(bProp.typeOption, 'Number'); + + const cProp = decl.reactiveProperties.get('c'); + assert.ok(cProp); + assert.equal(cProp.name, 'c'); + assert.equal(cProp.attribute, 'c'); + assert.equal(cProp.type?.text, lang === 'ts' ? 'any' : undefined); + }); + + test('Analyzer finds LitElement properties from static getter', ({ + analyzer, + }) => { + const result = analyzer.getPackage(); + const elementBModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-b', lang) + ); + const decl = elementBModule?.getDeclaration('ElementB'); + assert.ok(decl?.isLitElementDeclaration()); + + // ElementB has `foo` and `bar` properties defined in a static properties getter + assert.equal(decl.reactiveProperties.size, 2); + + const fooProp = decl.reactiveProperties.get('foo'); + assert.ok(fooProp); + assert.equal(fooProp.name, 'foo', 'property name'); + assert.equal(fooProp.attribute, 'foo', 'attribute name'); + assert.equal(fooProp.type?.text, 'string'); + assert.equal(fooProp.reflect, false); + + const bProp = decl.reactiveProperties.get('bar'); + assert.ok(bProp); + assert.equal(bProp.name, 'bar'); + assert.equal(bProp.attribute, 'bar'); + + // This is inferred + assert.equal(bProp.type?.text, 'number'); + assert.equal(bProp.typeOption, 'Number'); + }); + + test('Analyezr finds subclass of LitElement', ({analyzer}) => { + const result = analyzer.getPackage(); + const module = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-c', lang) + ); + const elementC = module?.getDeclaration('ElementC'); + assert.ok(elementC?.isLitElementDeclaration()); + + assert.equal(elementC.reactiveProperties.size, 1); + const bazProp = elementC.reactiveProperties.get('baz'); + assert.ok(bazProp); + + const elementBRef = elementC.heritage.superClass; + assert.ok(elementBRef); + const elementB = elementBRef.dereference(LitElementDeclaration); + assert.ok(elementB.isLitElementDeclaration()); + assert.ok(elementB.name, 'ElementB'); + assert.equal(elementB.reactiveProperties.size, 2); + const fooProp = elementB.reactiveProperties.get('foo'); + assert.ok(fooProp); + const barProp = elementB.reactiveProperties.get('bar'); + assert.ok(barProp); + + const litElementRef = elementB.heritage.superClass; + assert.ok(litElementRef); + const litElement = litElementRef.dereference(ClassDeclaration); + assert.ok(litElement.isClassDeclaration()); + assert.not.ok(litElement.isLitElementDeclaration()); + assert.ok(litElement.name, 'LitElement'); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/lit-element/properties_test.ts b/packages/labs/analyzer/src/test/lit-element/properties_test.ts index da14539f1f..29983512aa 100644 --- a/packages/labs/analyzer/src/test/lit-element/properties_test.ts +++ b/packages/labs/analyzer/src/test/lit-element/properties_test.ts @@ -7,193 +7,209 @@ import {suite} from 'uvu'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; -import * as path from 'path'; import {fileURLToPath} from 'url'; -import ts from 'typescript'; - -import {Analyzer, AbsolutePath, LitElementDeclaration} from '../../index.js'; - -const test = suite<{ - analyzer: Analyzer; - packagePath: AbsolutePath; - element: LitElementDeclaration; -}>('LitElement property tests'); - -test.before((ctx) => { - try { - const packagePath = fileURLToPath( - new URL('../../test-files/decorators-properties', import.meta.url).href - ) as AbsolutePath; - const analyzer = new Analyzer(packagePath); - - const result = analyzer.analyzePackage(); - const elementAModule = result.modules.find( - (m) => m.sourcePath === path.normalize('src/element-a.ts') +import {getSourceFilename, languages} from '../utils.js'; + +import { + createPackageAnalyzer, + Analyzer, + AbsolutePath, + LitElementDeclaration, +} from '../../index.js'; + +for (const lang of languages) { + const test = suite<{ + analyzer: Analyzer; + packagePath: AbsolutePath; + element: LitElementDeclaration; + }>(`LitElement property tests (${lang})`); + + test.before((ctx) => { + try { + const packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/properties`, import.meta.url).href + ) as AbsolutePath; + const analyzer = createPackageAnalyzer(packagePath); + + const result = analyzer.getPackage(); + const elementAModule = result.modules.find( + (m) => m.sourcePath === getSourceFilename('element-a', lang) + ); + const element = elementAModule?.getDeclaration('ElementA'); + assert.ok(element?.isLitElementDeclaration()); + + ctx.packagePath = packagePath; + ctx.analyzer = analyzer; + ctx.element = element; + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('non-decorated fields are not reactive', ({element}) => { + // TODO(justinfagnani): we might want to change the representation + // so we have a collection of all fields, some of which are reactive. + assert.equal(element.reactiveProperties.has('notDecorated'), false); + }); + + test('string property with no options', ({element}) => { + const property = element.reactiveProperties.get('noOptionsString'); + assert.ok(property); + assert.equal(property.name, 'noOptionsString'); + assert.equal(property.attribute, 'nooptionsstring'); + assert.equal(property.type?.text, 'string'); + assert.equal(property.type?.references.length, 0); + assert.ok(property.type); + assert.equal(property.reflect, false); + assert.equal(property.converter, undefined); + }); + + test('number property with no options', ({element}) => { + const property = element.reactiveProperties.get('noOptionsNumber')!; + assert.equal(property.name, 'noOptionsNumber'); + assert.equal(property.attribute, 'nooptionsnumber'); + assert.equal(property.type?.text, 'number'); + assert.equal(property.type?.references.length, 0); + assert.ok(property.type); + }); + + test('string property with type', ({element}) => { + const property = element.reactiveProperties.get('typeString')!; + assert.equal(property.type?.text, 'string'); + assert.equal(property.type?.references.length, 0); + assert.ok(property.type); + }); + + test('number property with type', ({element}) => { + const property = element.reactiveProperties.get('typeNumber')!; + assert.equal(property.type?.text, 'number'); + assert.equal(property.type?.references.length, 0); + assert.ok(property.type); + }); + + test('boolean property with type', ({element}) => { + const property = element.reactiveProperties.get('typeBoolean')!; + assert.equal(property.type?.text, 'boolean'); + assert.equal(property.type?.references.length, 0); + assert.ok(property.type); + }); + + test('property typed with local class', ({element}) => { + const property = element.reactiveProperties.get('localClass')!; + assert.equal(property.type?.text, 'LocalClass'); + assert.equal(property.type?.references.length, 1); + assert.equal(property.type?.references[0].name, 'LocalClass'); + assert.equal( + property.type?.references[0].package, + '@lit-internal/test-properties' ); - const element = elementAModule!.declarations.filter((d) => - d.isLitElementDeclaration() - )[0] as LitElementDeclaration; - - ctx.packagePath = packagePath; - ctx.analyzer = analyzer; - ctx.element = element; - } catch (error) { - // Uvu has a bug where it silently ignores failures in before and after, - // see https://github.com/lukeed/uvu/issues/191. - console.error('uvu before error', error); - process.exit(1); - } -}); - -test('non-decorated fields are not reactive', ({element}) => { - // TODO(justinfagnani): we might want to change the representation - // so we have a collection of all fields, some of which are reactive. - assert.equal(element.reactiveProperties.has('notDecorated'), false); -}); - -test('string property with no options', ({element}) => { - const property = element.reactiveProperties.get('noOptionsString'); - assert.ok(property); - assert.equal(property.name, 'noOptionsString'); - assert.equal(property.attribute, 'nooptionsstring'); - assert.equal(property.type.text, 'string'); - assert.equal(property.type.references.length, 0); - assert.ok(property.type); - assert.equal(property.reflect, false); - assert.equal(property.converter, undefined); -}); - -test('number property with no options', ({element}) => { - const property = element.reactiveProperties.get('noOptionsNumber')!; - assert.equal(property.name, 'noOptionsNumber'); - assert.equal(property.attribute, 'nooptionsnumber'); - assert.equal(property.type.text, 'number'); - assert.equal(property.type.references.length, 0); - assert.ok(property.type); -}); - -test('string property with type', ({element}) => { - const property = element.reactiveProperties.get('typeString')!; - assert.equal(property.type.text, 'string'); - assert.equal(property.type.references.length, 0); - assert.ok(property.type); -}); - -test('number property with type', ({element}) => { - const property = element.reactiveProperties.get('typeNumber')!; - assert.equal(property.type.text, 'number'); - assert.equal(property.type.references.length, 0); - assert.ok(property.type); -}); - -test('boolean property with type', ({element}) => { - const property = element.reactiveProperties.get('typeBoolean')!; - assert.equal(property.type.text, 'boolean'); - assert.equal(property.type.references.length, 0); - assert.ok(property.type); -}); - -test('property typed with local class', ({element}) => { - const property = element.reactiveProperties.get('localClass')!; - assert.equal(property.type.text, 'LocalClass'); - assert.equal(property.type.references.length, 1); - assert.equal(property.type.references[0].name, 'LocalClass'); - assert.equal( - property.type.references[0].package, - '@lit-internal/test-decorators-properties' - ); - assert.equal(property.type.references[0].module, 'element-a.js'); -}); - -test('property typed with imported class', ({element}) => { - const property = element.reactiveProperties.get('importedClass')!; - assert.equal(property.type.text, 'ImportedClass'); - assert.equal(property.type.references.length, 1); - assert.equal(property.type.references[0].name, 'ImportedClass'); - assert.equal( - property.type.references[0].package, - '@lit-internal/test-decorators-properties' - ); - assert.equal(property.type.references[0].module, 'external.js'); -}); - -test('property typed with global class', ({element}) => { - const property = element.reactiveProperties.get('globalClass')!; - assert.equal(property.type.text, 'HTMLElement'); - assert.equal(property.type.references.length, 1); - assert.equal(property.type.references[0].name, 'HTMLElement'); - assert.equal(property.type.references[0].isGlobal, true); -}); - -test('property typed with union', ({element}) => { - const property = element.reactiveProperties.get('union')!; - ts.isUnionTypeNode(property.node); - assert.equal(property.type.references.length, 3); - // The order is not necessarily reliable. It changed between TypeScript - // versions once. - - const localClass = property.type.references.find( - (node) => node.name === 'LocalClass' - ); - assert.ok(localClass); - assert.equal(localClass.package, '@lit-internal/test-decorators-properties'); - assert.equal(localClass.module, 'element-a.js'); - - const htmlElement = property.type.references.find( - (node) => node.name === 'HTMLElement' - ); - assert.ok(htmlElement); - assert.equal(htmlElement.isGlobal, true); - - const importedClass = property.type.references.find( - (node) => node.name === 'ImportedClass' - ); - assert.ok(importedClass); - assert.equal( - importedClass.package, - '@lit-internal/test-decorators-properties' - ); - assert.equal(importedClass.module, 'external.js'); -}); - -test('reflect: true', ({element}) => { - const property = element.reactiveProperties.get('reflectTrue')!; - assert.equal(property.reflect, true); -}); - -test('reflect: false', ({element}) => { - const property = element.reactiveProperties.get('reflectFalse')!; - assert.equal(property.reflect, false); -}); - -test('reflect: undefined', ({element}) => { - const property = element.reactiveProperties.get('reflectUndefined')!; - assert.equal(property.reflect, false); -}); - -test('attribute: true', ({element}) => { - const property = element.reactiveProperties.get('attributeTrue')!; - assert.equal(property.attribute, 'attributetrue'); -}); - -test('attribute: false', ({element}) => { - const property = element.reactiveProperties.get('attributeFalse')!; - assert.equal(property.attribute, undefined); -}); - -test('attribute: undefined', ({element}) => { - const property = element.reactiveProperties.get('attributeUndefined')!; - assert.equal(property.attribute, 'attributeundefined'); -}); - -test('attribute: string', ({element}) => { - const property = element.reactiveProperties.get('attributeString')!; - assert.equal(property.attribute, 'abc'); -}); - -test('custom converter', ({element}) => { - const property = element.reactiveProperties.get('customConverter')!; - assert.ok(property.converter); -}); - -test.run(); + assert.equal(property.type?.references[0].module, 'element-a.js'); + }); + + test('property typed with imported class', ({element}) => { + const property = element.reactiveProperties.get('importedClass')!; + assert.equal(property.type?.text, 'ImportedClass'); + assert.equal(property.type?.references.length, 1); + assert.equal(property.type?.references[0].name, 'ImportedClass'); + assert.equal( + property.type?.references[0].package, + '@lit-internal/test-properties' + ); + assert.equal(property.type?.references[0].module, 'external.js'); + }); + + test('property typed with global class', ({element}) => { + const property = element.reactiveProperties.get('globalClass')!; + assert.equal(property.type?.text, 'HTMLElement'); + assert.equal(property.type?.references.length, 1); + assert.equal(property.type?.references[0].name, 'HTMLElement'); + assert.equal(property.type?.references[0].isGlobal, true); + }); + + test('property typed with union', ({element}) => { + // TODO(kschaaf): TS seems to have some support for inferring union types + // from JS initializers, but if there are n possible types (e.g. `this.foo = + // new A() || new B() || new C()`), it seems to only generate a union type + // with n-1 types in it (e.g. A | B). For now let's just skip it. + if (lang === 'js') { + return; + } + const property = element.reactiveProperties.get('union')!; + assert.equal(property.type?.references.length, 3); + // The order is not necessarily reliable. It changed between TypeScript + // versions once. + + const localClass = property.type?.references.find( + (node) => node.name === 'LocalClass' + ); + assert.ok(localClass); + assert.equal(localClass.package, '@lit-internal/test-properties'); + assert.equal(localClass.module, 'element-a.js'); + + const htmlElement = property.type?.references.find( + (node) => node.name === 'HTMLElement' + ); + assert.ok(htmlElement); + assert.equal(htmlElement.isGlobal, true); + + const importedClass = property.type?.references.find( + (node) => node.name === 'ImportedClass' + ); + assert.ok(importedClass); + assert.equal(importedClass.package, '@lit-internal/test-properties'); + assert.equal(importedClass.module, 'external.js'); + }); + + test('reflect: true', ({element}) => { + const property = element.reactiveProperties.get('reflectTrue')!; + assert.equal(property.reflect, true); + }); + + test('reflect: false', ({element}) => { + const property = element.reactiveProperties.get('reflectFalse')!; + assert.equal(property.reflect, false); + }); + + test('reflect: undefined', ({element}) => { + const property = element.reactiveProperties.get('reflectUndefined')!; + assert.equal(property.reflect, false); + }); + + test('attribute: true', ({element}) => { + const property = element.reactiveProperties.get('attributeTrue')!; + assert.equal(property.attribute, 'attributetrue'); + }); + + test('attribute: false', ({element}) => { + const property = element.reactiveProperties.get('attributeFalse')!; + assert.equal(property.attribute, undefined); + }); + + test('attribute: undefined', ({element}) => { + const property = element.reactiveProperties.get('attributeUndefined')!; + assert.equal(property.attribute, 'attributeundefined'); + }); + + test('attribute: string', ({element}) => { + const property = element.reactiveProperties.get('attributeString')!; + assert.equal(property.attribute, 'abc'); + }); + + test('custom converter', ({element}) => { + const property = element.reactiveProperties.get('customConverter')!; + assert.ok(property.converter); + }); + + test('property defined in static properties block', ({element}) => { + const property = element.reactiveProperties.get('staticProp')!; + assert.equal(property.type?.text, 'number'); + assert.equal(property.type?.references.length, 0); + assert.equal(property.typeOption, 'Number'); + assert.equal(property.attribute, 'static-prop'); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/types_test.ts b/packages/labs/analyzer/src/test/types_test.ts index 7b25656003..53d3e5388d 100644 --- a/packages/labs/analyzer/src/test/types_test.ts +++ b/packages/labs/analyzer/src/test/types_test.ts @@ -4,17 +4,15 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import 'source-map-support/register.js'; import {suite} from 'uvu'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; import {fileURLToPath} from 'url'; import { - Analyzer, + createPackageAnalyzer, AbsolutePath, Module, - VariableDeclaration, getImportsStringForReferences, } from '../index.js'; @@ -25,10 +23,10 @@ const test = suite<{module: Module; packagePath: AbsolutePath}>('Types tests'); test.before((ctx) => { try { const packagePath = (ctx.packagePath = fileURLToPath( - new URL('../test-files/types', import.meta.url).href + new URL('../test-files/ts/types', import.meta.url).href ) as AbsolutePath); - const analyzer = new Analyzer(packagePath); - const pkg = analyzer.analyzePackage(); + const analyzer = createPackageAnalyzer(packagePath); + const pkg = analyzer.getPackage(); ctx.module = pkg.modules.filter((m) => m.jsPath === 'module.js')[0]; } catch (e) { // Uvu has a bug where it silently ignores failures in before and after, @@ -39,9 +37,10 @@ test.before((ctx) => { }); const typeForVariable = (module: Module, name: string) => { - const dec = module.declarations.filter((dec) => dec.name === name)[0]; + const dec = module.getDeclaration(name); + assert.ok(dec.isVariableDeclaration()); assert.ok(dec, `Could not find symbol named ${name}`); - const type = (dec as VariableDeclaration).type; + const type = dec.type; assert.ok(type); return type; }; @@ -214,7 +213,7 @@ test('complexType', ({module}) => { assert.equal(type.references[1].isGlobal, true); assert.equal(type.references[2].name, 'LitElement'); assert.equal(type.references[2].package, 'lit'); - assert.equal(type.references[2].module, ''); + assert.equal(type.references[2].module, undefined); assert.equal(type.references[2].isGlobal, false); assert.equal(type.references[3].name, 'ImportedClass'); assert.equal(type.references[3].package, '@lit-internal/test-types'); @@ -238,7 +237,7 @@ test('destructObjNested', ({module}) => { assert.equal(type.references.length, 1); assert.equal(type.references[0].name, 'LitElement'); assert.equal(type.references[0].package, 'lit'); - assert.equal(type.references[0].module, ''); + assert.equal(type.references[0].module, undefined); assert.equal(type.references[0].isGlobal, false); }); @@ -268,7 +267,7 @@ test('separatelyExportedDestructObjNested', ({module}) => { assert.equal(type.references.length, 1); assert.equal(type.references[0].name, 'LitElement'); assert.equal(type.references[0].package, 'lit'); - assert.equal(type.references[0].module, ''); + assert.equal(type.references[0].module, undefined); assert.equal(type.references[0].isGlobal, false); }); @@ -288,10 +287,24 @@ test('separatelyExportedDestructArrNested', ({module}) => { assert.equal(type.references.length, 1); assert.equal(type.references[0].name, 'LitElement'); assert.equal(type.references[0].package, 'lit'); - assert.equal(type.references[0].module, ''); + assert.equal(type.references[0].module, undefined); assert.equal(type.references[0].isGlobal, false); }); +test('importedType', ({module}) => { + const type = typeForVariable(module, 'importedType'); + //assert.equal(type.text, 'TemplateResult<1>'); + assert.equal(type.references.length, 2); + assert.equal(type.references[0].name, 'ImportedClass'); + assert.equal(type.references[0].package, '@lit-internal/test-types'); + assert.equal(type.references[0].module, 'external.js'); + assert.equal(type.references[0].isGlobal, false); + assert.equal(type.references[1].name, 'TemplateResult'); + assert.equal(type.references[1].package, 'lit-html'); + assert.equal(type.references[1].module, undefined); + assert.equal(type.references[1].isGlobal, false); +}); + test('getImportsStringForReferences', ({module}) => { const type = typeForVariable(module, 'complexType'); assert.equal( diff --git a/packages/labs/analyzer/src/test/utils.ts b/packages/labs/analyzer/src/test/utils.ts new file mode 100644 index 0000000000..18fbeb0742 --- /dev/null +++ b/packages/labs/analyzer/src/test/utils.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import ts from 'typescript'; +import {AbsolutePath, Analyzer} from '../index.js'; + +type Language = 'ts' | 'js'; + +export const languages: Language[] = ['ts', 'js']; + +// Note these functions assume a specific setup in tsconfig.json for tests of TS +// projects using these helpers, namely that the `rootDir` is 'src' and the +// `outDir` is 'out' + +export const getSourceFilename = (f: string, lang: Language) => + path.normalize( + lang === 'ts' + ? path.join(path.dirname(f), 'src', path.basename(f) + '.ts') + : f + '.js' + ); + +export const getOutputFilename = (f: string, lang: Language) => + path.normalize( + lang === 'ts' + ? path.join(path.dirname(f), 'out', path.basename(f) + '.js') + : path.normalize(f + '.js') + ); + +// The following code implements an InMemoryAnalyzer that uses a language +// service host backed by an updatable in-memory file cache, to allow for easy +// program invalidation in tests. + +// Note that because some paths come into the language host filesystem +// abstractions in posix format and others may come in in OS-native +// format, we normalize all paths going in/out of the in-memory cache to +// posix format. There is apparently no path lib method to do this. +// https://stackoverflow.com/questions/53799385/how-can-i-convert-a-windows-path-to-posix-path-using-node-path +const normalize = (p: string) => p.split(path.sep).join(path.posix.sep); + +/** + * Map of filenames -> content + */ +export interface Files { + [index: string]: string; +} + +/** + * Map of filenames -> versioned content + */ +interface Cache { + [index: string]: {content: string; version: number}; +} + +/** + * Common compiler options between TS & JS + */ +const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + lib: ['es2020', 'DOM'], + module: ts.ModuleKind.ES2020, + outDir: './', + moduleResolution: ts.ModuleResolutionKind.NodeJs, + experimentalDecorators: true, + skipDefaultLibCheck: true, + skipLibCheck: true, + noEmit: true, +}; + +/** + * JS-specific config + */ +const tsconfigJS = { + ...compilerOptions, + allowJs: true, +}; + +/** + * TS-specific config + */ +const tsconfigTS = { + ...compilerOptions, + rootDir: '/src', + outDir: '/', + configFilePath: '/', +}; + +/** + * Returns true if the file is a standard TS library. These are the only + * files we read off of disk; everything else comes from the in-memory cache. + */ +const isLib = (fileName: string) => + normalize(fileName).includes('node_modules/typescript/lib'); + +/** + * Simulates "reading" a directory from the in-memory cache. The cache stores a + * flat list of filenames as keys, so this filters the list using a regex that + * matches the given path, and returns a list of path segments immediately + * following the directory (deduped using a Set, since if there are multiple + * files in a subdirectory under this directory, the subdirectory name would + * show up multiple times) + */ +const readDirectory = (cache: Cache, dir: AbsolutePath) => { + const sep = dir.endsWith('/') ? '' : '\\/'; + const matcher = new RegExp(`^${dir}${sep}([^/]+)`); + return Array.from( + new Set( + Object.keys(cache) + .map((f) => f.match(matcher)?.[1]) + .filter((f) => !!f) as string[] + ) + ); +}; + +/** + * Creates a ts.LanguageServiceHost that reads from an in-memory cache for all + * files except TS standard libs, which are read off of disk. + */ +const createHost = (cache: Cache, lang: Language) => { + return { + getScriptFileNames: () => + Object.keys(cache).filter((s) => s.endsWith('.ts') || s.endsWith('.js')), + getScriptVersion: (fileName: string) => + cache[normalize(fileName)].version.toString(), + getScriptSnapshot: (fileName: string) => { + if (isLib(fileName)) { + return fs.existsSync(fileName) + ? ts.ScriptSnapshot.fromString( + fs.readFileSync(path.normalize(fileName), 'utf-8') + ) + : undefined; + } else { + return fileName in cache + ? ts.ScriptSnapshot.fromString(cache[normalize(fileName)].content) + : undefined; + } + }, + getCurrentDirectory: () => '/', + getCompilationSettings: () => (lang === 'ts' ? tsconfigTS : tsconfigJS), + getDefaultLibFileName: (options: ts.CompilerOptions) => + ts.getDefaultLibFilePath(options), + fileExists: (fileName: string) => + isLib(fileName) + ? ts.sys.fileExists(path.normalize(fileName)) + : normalize(fileName) in cache, + readFile: (fileName: string) => cache[normalize(fileName)].content, + readDirectory: (fileName: string) => + readDirectory(cache, normalize(fileName) as AbsolutePath), + directoryExists: (dir: string) => + readDirectory(cache, normalize(dir) as AbsolutePath).length > 0, + getDirectories: () => [], + }; +}; + +/** + * An Analyzer that analyzes an in-memory set of files passed into the + * constructor, which may be updated after the fact using the `setFile()` API. + */ +export class InMemoryAnalyzer extends Analyzer { + private _cache: Cache; + private _dirty = false; + private _lang: Language; + + constructor(lang: Language, files: Files = {}) { + const cache: Cache = Object.fromEntries( + Object.entries(files).map(([name, content]) => [ + normalize(name), + {content, version: 0}, + ]) + ); + const host = createHost(cache, lang); + const service = ts.createLanguageService(host, ts.createDocumentRegistry()); + let program: ts.Program; + super({ + getProgram: () => { + if (program === undefined || this._dirty) { + this._dirty = false; + program = service.getProgram()!; + } + return program; + }, + fs: { + ...host, + useCaseSensitiveFileNames: false, + }, + path, + }); + this._cache = cache; + this._lang = lang; + } + + setFile(name: string, content: string) { + const fileName = normalize(getSourceFilename(name, this._lang)); + const prev = this._cache[fileName] ?? {version: -1}; + this._cache[fileName] = {content, version: prev.version + 1}; + this._dirty = true; + } +} diff --git a/packages/labs/analyzer/test-files/decorators-properties/package.json b/packages/labs/analyzer/test-files/decorators-properties/package.json deleted file mode 100644 index 2f3cae1fb2..0000000000 --- a/packages/labs/analyzer/test-files/decorators-properties/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@lit-internal/test-decorators-properties", - "dependencies": { - "lit": "^2.0.0" - } -} diff --git a/packages/labs/analyzer/test-files/basic-elements/src/class-a.ts b/packages/labs/analyzer/test-files/js/basic-elements/class-a.js similarity index 100% rename from packages/labs/analyzer/test-files/basic-elements/src/class-a.ts rename to packages/labs/analyzer/test-files/js/basic-elements/class-a.js diff --git a/packages/labs/analyzer/test-files/basic-elements/src/default-element.ts b/packages/labs/analyzer/test-files/js/basic-elements/default-element.js similarity index 100% rename from packages/labs/analyzer/test-files/basic-elements/src/default-element.ts rename to packages/labs/analyzer/test-files/js/basic-elements/default-element.js diff --git a/packages/labs/analyzer/test-files/js/basic-elements/element-a.js b/packages/labs/analyzer/test-files/js/basic-elements/element-a.js new file mode 100644 index 0000000000..7cb4368702 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/basic-elements/element-a.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html, css} from 'lit'; + +export class ElementA extends LitElement { + static styles = css` + :host { + display: block; + } + `; + + static properties = { + a: {}, + b: {reflect: true, type: Number, attribute: 'bbb'}, + c: {}, + }; + + static c = 'should not be inferred as type for c'; + + constructor() { + super(); + this.a = ''; + this.b = 1; + } + + render() { + return html`

${this.a}

`; + } +} +customElements.define('element-a', ElementA); diff --git a/packages/labs/analyzer/test-files/basic-elements/src/element-b.ts b/packages/labs/analyzer/test-files/js/basic-elements/element-b.js similarity index 64% rename from packages/labs/analyzer/test-files/basic-elements/src/element-b.ts rename to packages/labs/analyzer/test-files/js/basic-elements/element-b.js index 51f696037d..b93102d27c 100644 --- a/packages/labs/analyzer/test-files/basic-elements/src/element-b.ts +++ b/packages/labs/analyzer/test-files/js/basic-elements/element-b.js @@ -5,9 +5,7 @@ */ import {LitElement, html, css} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; -@customElement('element-b') export class ElementB extends LitElement { static styles = css` :host { @@ -15,8 +13,18 @@ export class ElementB extends LitElement { } `; - @property() - foo?: string; + static get properties() { + return { + foo: {}, + bar: {type: Number}, + }; + } + + constructor() { + super(); + this.foo = 'hi'; + this.bar = 42; + } render() { return html`

${this.foo}

`; diff --git a/packages/labs/analyzer/test-files/js/basic-elements/element-c.js b/packages/labs/analyzer/test-files/js/basic-elements/element-c.js new file mode 100644 index 0000000000..7493c7ce43 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/basic-elements/element-c.js @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {html} from 'lit'; +import {ElementB} from './element-b.js'; + +export class ElementC extends ElementB { + static properties = { + baz: {}, + }; + + constructor() { + super(); + this.baz = 'baz'; + } + + render() { + return html`

${super.render()} ${this.baz}

`; + } +} +customElements.define('element-c', ElementC); diff --git a/packages/labs/analyzer/test-files/basic-elements/src/not-lit.ts b/packages/labs/analyzer/test-files/js/basic-elements/not-lit.js similarity index 100% rename from packages/labs/analyzer/test-files/basic-elements/src/not-lit.ts rename to packages/labs/analyzer/test-files/js/basic-elements/not-lit.js diff --git a/packages/labs/analyzer/test-files/js/basic-elements/package.json b/packages/labs/analyzer/test-files/js/basic-elements/package.json new file mode 100644 index 0000000000..7af9f4969a --- /dev/null +++ b/packages/labs/analyzer/test-files/js/basic-elements/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-basic-elements-js", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/js/events/custom-event.js b/packages/labs/analyzer/test-files/js/events/custom-event.js new file mode 100644 index 0000000000..408a175904 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/events/custom-event.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export class ExternalCustomEvent extends Event { + constructor(message) { + super('external-custom-event'); + this.message = message; + } +} + +export class ExternalClass { + constructor() { + this.someData = 42; + } +} diff --git a/packages/labs/analyzer/test-files/js/events/element-a.js b/packages/labs/analyzer/test-files/js/events/element-a.js new file mode 100644 index 0000000000..7000ae17b3 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/events/element-a.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; +import {ExternalCustomEvent, ExternalClass} from './custom-event.js'; + +export class LocalCustomEvent extends Event { + constructor(message) { + super('local-custom-event'); + this.message = message; + } +} +/** + * A cool custom element. + * + * @fires event + * @fires event-two This is an event + * @fires event-three - This is another event + * @fires typed-event {MouseEvent} + * @fires typed-event-two {MouseEvent} This is a typed event + * @fires typed-event-three {MouseEvent} - This is another typed event + * @fires external-custom-event {ExternalCustomEvent} - External custom event + * @fires local-custom-event {LocalCustomEvent} - Local custom event + * @fires generic-custom-event {CustomEvent} - Local custom event + * @fires inline-detail-custom-event {CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>} + * + * @comment malformed fires tag: + * + * @fires + */ +export class ElementA extends LitElement { + fireExternalEvent() { + this.dispatchEvent(new ExternalCustomEvent('external')); + } + fireLocalEvent() { + this.dispatchEvent(new LocalCustomEvent('local')); + } + fireGenericEvent() { + this.dispatchEvent( + new CustomEvent() < + ExternalClass > + ('generic-custom-event', + { + detail: new ExternalClass(), + }) + ); + } +} +customElements.define('element-a', ElementA); diff --git a/packages/labs/analyzer/test-files/events/package.json b/packages/labs/analyzer/test-files/js/events/package.json similarity index 100% rename from packages/labs/analyzer/test-files/events/package.json rename to packages/labs/analyzer/test-files/js/events/package.json diff --git a/packages/labs/analyzer/test-files/js/jsdoc/element-a.js b/packages/labs/analyzer/test-files/js/jsdoc/element-a.js new file mode 100644 index 0000000000..20e64dcd08 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/jsdoc/element-a.js @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; + +/** + * A cool custom element. + * + * @slot basic + * @slot with-summary Summary for with-summary + * @slot with-summary-dash - Summary for with-summary-dash + * @slot with-summary-colon: Summary for with-summary-colon + * @slot with-description - Summary for with-description + * Description for with-description + * More description for with-description + * + * Even more description for with-description + * + * @cssPart basic + * @cssPart with-summary Summary for :part(with-summary) + * @cssPart with-summary-dash - Summary for :part(with-summary-dash) + * @cssPart with-summary-colon: Summary for :part(with-summary-colon) + * @cssPart with-description - Summary for :part(with-description) + * Description for :part(with-description) + * More description for :part(with-description) + * + * Even more description for :part(with-description) + * + * @cssProperty --basic + * @cssProperty --with-summary Summary for --with-summary + * @cssProperty --with-summary-dash - Summary for --with-summary-dash + * @cssProperty --with-summary-colon: Summary for --with-summary-colon + * @cssProperty --with-description - Summary for --with-description + * Description for --with-description + * More description for --with-description + * + * Even more description for --with-description + * + * @cssProp --short-basic + * @cssProp --short-with-summary Summary for --short-with-summary + * @cssProp --short-with-summary-dash - Summary for --short-with-summary-dash + * @cssProp --short-with-summary-colon: Summary for --short-with-summary-colon + * @cssProp --short-with-description - Summary for --short-with-description + * Description for --short-with-description + * More description for --short-with-description + * + * Even more description for --short-with-description + */ +export class ElementA extends LitElement {} +customElements.define('element-a', ElementA); + +/** + * @description TaggedDescription description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * @summary TaggedDescription summary. + * @deprecated TaggedDescription deprecated message. + */ +export class TaggedDescription extends LitElement {} + +/** + * UntaggedDescription description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * + * @deprecated UntaggedDescription deprecated message. + * @summary UntaggedDescription summary. + */ +export class UntaggedDescription extends LitElement {} + +/** + * UntaggedDescSummary summary. + * + * UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * + * @deprecated + */ +export class UntaggedDescSummary extends LitElement {} diff --git a/packages/labs/analyzer/test-files/js/jsdoc/package.json b/packages/labs/analyzer/test-files/js/jsdoc/package.json new file mode 100644 index 0000000000..0942e4bbb1 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/jsdoc/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-jsdoc", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/js/modules/module-a.js b/packages/labs/analyzer/test-files/js/modules/module-a.js new file mode 100644 index 0000000000..58b7f876b9 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/modules/module-a.js @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {a, b} from './module-b.js'; +import {LitElement} from 'lit'; +import {c} from '../modules/module-b.js'; + +export {a, b, c, LitElement}; diff --git a/packages/labs/analyzer/test-files/js/modules/module-b.js b/packages/labs/analyzer/test-files/js/modules/module-b.js new file mode 100644 index 0000000000..b05c1b4297 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/modules/module-b.js @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export const a = 'a'; +export const b = 'b'; +export const c = 'c'; diff --git a/packages/labs/analyzer/test-files/js/modules/package.json b/packages/labs/analyzer/test-files/js/modules/package.json new file mode 100644 index 0000000000..a900c279aa --- /dev/null +++ b/packages/labs/analyzer/test-files/js/modules/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-modules", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/js/properties/element-a.js b/packages/labs/analyzer/test-files/js/properties/element-a.js new file mode 100644 index 0000000000..47823335c1 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/properties/element-a.js @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; +import {ImportedClass} from './external.js'; + +export class LocalClass { + constructor() { + this.someData = 42; + } +} + +export class ElementA extends LitElement { + static properties = { + noOptionsString: {}, + noOptionsNumber: {}, + typeString: {type: String}, + typeNumber: {type: Number}, + typeBoolean: {type: Boolean}, + reflectTrue: {reflect: true}, + reflectFalse: {reflect: false}, + reflectUndefined: {reflect: undefined}, + attributeTrue: {attribute: true}, + attributeFalse: {attribute: false}, + attributeUndefined: {attribute: undefined}, + attributeString: {attribute: 'abc'}, + customConverter: {converter: {fromAttribute() {}, toAttribute() {}}}, + localClass: {}, + importedClass: {}, + globalClass: {}, + union: {}, + staticProp: {attribute: 'static-prop', type: Number}, + }; + + constructor() { + super(); + this.notDecorated = ''; + this.noOptionsString = ''; + this.noOptionsNumber = 42; + this.typeString = ''; + this.typeNumber = 42; + this.typeBoolean = true; + this.reflectTrue = ''; + this.reflectFalse = ''; + this.reflectUndefined = ''; + this.attributeTrue = ''; + this.attributeFalse = ''; + this.attributeUndefined = ''; + this.attributeString = ''; + this.customConverter = ''; + this.localClass = new LocalClass(); + this.importedClass = new ImportedClass(); + this.globalClass = document.createElement('foo'); + this.staticProp = 42; + } +} +customElements.define('element-a', ElementA); diff --git a/packages/labs/analyzer/test-files/decorators-properties/src/external.ts b/packages/labs/analyzer/test-files/js/properties/external.js similarity index 100% rename from packages/labs/analyzer/test-files/decorators-properties/src/external.ts rename to packages/labs/analyzer/test-files/js/properties/external.js diff --git a/packages/labs/analyzer/test-files/js/properties/package.json b/packages/labs/analyzer/test-files/js/properties/package.json new file mode 100644 index 0000000000..92cc1070a3 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/properties/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-properties", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/basic-elements/package.json b/packages/labs/analyzer/test-files/ts/basic-elements/package.json similarity index 100% rename from packages/labs/analyzer/test-files/basic-elements/package.json rename to packages/labs/analyzer/test-files/ts/basic-elements/package.json diff --git a/packages/labs/analyzer/test-files/ts/basic-elements/src/class-a.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/class-a.ts new file mode 100644 index 0000000000..3894a16c0b --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/class-a.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export class ClassA { + foo = 1; +} diff --git a/packages/labs/analyzer/test-files/ts/basic-elements/src/default-element.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/default-element.ts new file mode 100644 index 0000000000..2550efb8e8 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/default-element.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; + +export default class extends LitElement {} diff --git a/packages/labs/analyzer/test-files/basic-elements/src/element-a.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-a.ts similarity index 86% rename from packages/labs/analyzer/test-files/basic-elements/src/element-a.ts rename to packages/labs/analyzer/test-files/ts/basic-elements/src/element-a.ts index 07cc8cebb6..677b51b542 100644 --- a/packages/labs/analyzer/test-files/basic-elements/src/element-a.ts +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-a.ts @@ -21,6 +21,11 @@ export class ElementA extends LitElement { @property({reflect: true, type: Number, attribute: 'bbb'}) b = 1; + @property() + c; + + static c = 'should not be inferred as type for c'; + render() { return html`

${this.a}

`; } diff --git a/packages/labs/analyzer/test-files/ts/basic-elements/src/element-b.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-b.ts new file mode 100644 index 0000000000..929d85416f --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-b.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html, css} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('element-b') +export class ElementB extends LitElement { + static styles = css` + :host { + display: block; + } + `; + + // Adds a property defined in a static properties block to make sure this + // works in TypeScript as expected + static get properties() { + return { + bar: {type: Number}, + }; + } + + @property() + foo?: string; + + declare bar: number; + + constructor() { + super(); + this.bar = 42; + } + + render() { + return html`

${this.foo}

`; + } +} diff --git a/packages/labs/analyzer/test-files/ts/basic-elements/src/element-c.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-c.ts new file mode 100644 index 0000000000..b5e760d9c4 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/element-c.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {html} from 'lit'; +import {ElementB} from './element-b.js'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('element-c') +export class ElementC extends ElementB { + @property() + baz = 'baz'; + + render() { + return html`

${super.render()} ${this.baz}

`; + } +} diff --git a/packages/labs/analyzer/test-files/ts/basic-elements/src/not-lit.ts b/packages/labs/analyzer/test-files/ts/basic-elements/src/not-lit.ts new file mode 100644 index 0000000000..abb8c47575 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/basic-elements/src/not-lit.ts @@ -0,0 +1,3 @@ +class LitElement {} + +export class NotLit extends LitElement {} diff --git a/packages/labs/analyzer/test-files/basic-elements/tsconfig.json b/packages/labs/analyzer/test-files/ts/basic-elements/tsconfig.json similarity index 100% rename from packages/labs/analyzer/test-files/basic-elements/tsconfig.json rename to packages/labs/analyzer/test-files/ts/basic-elements/tsconfig.json diff --git a/packages/labs/analyzer/test-files/ts/events/package.json b/packages/labs/analyzer/test-files/ts/events/package.json new file mode 100644 index 0000000000..fccb63eaa0 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/events/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-events", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/events/src/custom-event.ts b/packages/labs/analyzer/test-files/ts/events/src/custom-event.ts similarity index 100% rename from packages/labs/analyzer/test-files/events/src/custom-event.ts rename to packages/labs/analyzer/test-files/ts/events/src/custom-event.ts diff --git a/packages/labs/analyzer/test-files/events/src/element-a.ts b/packages/labs/analyzer/test-files/ts/events/src/element-a.ts similarity index 100% rename from packages/labs/analyzer/test-files/events/src/element-a.ts rename to packages/labs/analyzer/test-files/ts/events/src/element-a.ts diff --git a/packages/labs/analyzer/test-files/decorators-properties/tsconfig.json b/packages/labs/analyzer/test-files/ts/events/tsconfig.json similarity index 100% rename from packages/labs/analyzer/test-files/decorators-properties/tsconfig.json rename to packages/labs/analyzer/test-files/ts/events/tsconfig.json diff --git a/packages/labs/analyzer/test-files/ts/jsdoc/package.json b/packages/labs/analyzer/test-files/ts/jsdoc/package.json new file mode 100644 index 0000000000..0942e4bbb1 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/jsdoc/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-jsdoc", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts b/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts new file mode 100644 index 0000000000..5886ebd07a --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +/** + * A cool custom element. + * + * @slot basic + * @slot with-summary Summary for with-summary + * @slot with-summary-dash - Summary for with-summary-dash + * @slot with-summary-colon: Summary for with-summary-colon + * @slot with-description - Summary for with-description + * Description for with-description + * More description for with-description + * + * Even more description for with-description + * + * @cssPart basic + * @cssPart with-summary Summary for :part(with-summary) + * @cssPart with-summary-dash - Summary for :part(with-summary-dash) + * @cssPart with-summary-colon: Summary for :part(with-summary-colon) + * @cssPart with-description - Summary for :part(with-description) + * Description for :part(with-description) + * More description for :part(with-description) + * + * Even more description for :part(with-description) + * + * @cssProperty --basic + * @cssProperty --with-summary Summary for --with-summary + * @cssProperty --with-summary-dash - Summary for --with-summary-dash + * @cssProperty --with-summary-colon: Summary for --with-summary-colon + * @cssProperty --with-description - Summary for --with-description + * Description for --with-description + * More description for --with-description + * + * Even more description for --with-description + * + * @cssProp --short-basic + * @cssProp --short-with-summary Summary for --short-with-summary + * @cssProp --short-with-summary-dash - Summary for --short-with-summary-dash + * @cssProp --short-with-summary-colon: Summary for --short-with-summary-colon + * @cssProp --short-with-description - Summary for --short-with-description + * Description for --short-with-description + * More description for --short-with-description + * + * Even more description for --short-with-description + */ +@customElement('element-a') +export class ElementA extends LitElement {} + +/** + * @description TaggedDescription description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * @summary TaggedDescription summary. + * @deprecated TaggedDescription deprecated message. + */ +export class TaggedDescription extends LitElement {} + +/** + * UntaggedDescription description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * + * @deprecated UntaggedDescription deprecated message. + * @summary UntaggedDescription summary. + */ +export class UntaggedDescription extends LitElement {} + +/** + * UntaggedDescSummary summary. + * + * UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur + * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + * nisi ut aliquip ex ea commodo consequat. + * + * @deprecated + */ +export class UntaggedDescSummary extends LitElement {} diff --git a/packages/labs/analyzer/test-files/events/tsconfig.json b/packages/labs/analyzer/test-files/ts/jsdoc/tsconfig.json similarity index 100% rename from packages/labs/analyzer/test-files/events/tsconfig.json rename to packages/labs/analyzer/test-files/ts/jsdoc/tsconfig.json diff --git a/packages/labs/analyzer/test-files/ts/modules/package.json b/packages/labs/analyzer/test-files/ts/modules/package.json new file mode 100644 index 0000000000..a900c279aa --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/modules/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-modules", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/ts/modules/src/module-a.ts b/packages/labs/analyzer/test-files/ts/modules/src/module-a.ts new file mode 100644 index 0000000000..50be8be97e --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/modules/src/module-a.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {a, b, c} from './module-b.js'; +import {LitElement} from 'lit'; + +export {a, b, c, LitElement}; diff --git a/packages/labs/analyzer/test-files/ts/modules/src/module-b.ts b/packages/labs/analyzer/test-files/ts/modules/src/module-b.ts new file mode 100644 index 0000000000..b05c1b4297 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/modules/src/module-b.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export const a = 'a'; +export const b = 'b'; +export const c = 'c'; diff --git a/packages/labs/analyzer/test-files/types/tsconfig.json b/packages/labs/analyzer/test-files/ts/modules/tsconfig.json similarity index 100% rename from packages/labs/analyzer/test-files/types/tsconfig.json rename to packages/labs/analyzer/test-files/ts/modules/tsconfig.json diff --git a/packages/labs/analyzer/test-files/ts/properties/package.json b/packages/labs/analyzer/test-files/ts/properties/package.json new file mode 100644 index 0000000000..92cc1070a3 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/properties/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-properties", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/decorators-properties/src/element-a.ts b/packages/labs/analyzer/test-files/ts/properties/src/element-a.ts similarity index 88% rename from packages/labs/analyzer/test-files/decorators-properties/src/element-a.ts rename to packages/labs/analyzer/test-files/ts/properties/src/element-a.ts index f84480b533..4c5f7dda22 100644 --- a/packages/labs/analyzer/test-files/decorators-properties/src/element-a.ts +++ b/packages/labs/analyzer/test-files/ts/properties/src/element-a.ts @@ -17,6 +17,17 @@ export interface LocalInterface { @customElement('element-a') export class ElementA extends LitElement { + static properties = { + staticProp: {attribute: 'static-prop', type: Number}, + }; + + declare staticProp: number; + + constructor() { + super(); + this.staticProp = 42; + } + notDecorated: string; @property() diff --git a/packages/labs/analyzer/test-files/ts/properties/src/external.ts b/packages/labs/analyzer/test-files/ts/properties/src/external.ts new file mode 100644 index 0000000000..82952e0cb2 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/properties/src/external.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export class ImportedClass {} diff --git a/packages/labs/analyzer/test-files/ts/properties/tsconfig.json b/packages/labs/analyzer/test-files/ts/properties/tsconfig.json new file mode 100644 index 0000000000..204701fc83 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/properties/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["es2020", "DOM"], + "module": "ES2020", + "rootDir": "./src", + "outDir": "./", + "moduleResolution": "node", + "experimentalDecorators": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/labs/analyzer/test-files/types/package.json b/packages/labs/analyzer/test-files/ts/types/package.json similarity index 100% rename from packages/labs/analyzer/test-files/types/package.json rename to packages/labs/analyzer/test-files/ts/types/package.json diff --git a/packages/labs/analyzer/test-files/types/src/external.ts b/packages/labs/analyzer/test-files/ts/types/src/external.ts similarity index 74% rename from packages/labs/analyzer/test-files/types/src/external.ts rename to packages/labs/analyzer/test-files/ts/types/src/external.ts index aa87a5f728..0c9793c6d6 100644 --- a/packages/labs/analyzer/test-files/types/src/external.ts +++ b/packages/labs/analyzer/test-files/ts/types/src/external.ts @@ -10,3 +10,7 @@ export class ImportedClass { export interface ImportedInterface { someData: number; } + +export const returnsClass = () => { + return new ImportedClass(); +}; diff --git a/packages/labs/analyzer/test-files/types/src/module.ts b/packages/labs/analyzer/test-files/ts/types/src/module.ts similarity index 92% rename from packages/labs/analyzer/test-files/types/src/module.ts rename to packages/labs/analyzer/test-files/ts/types/src/module.ts index a830f01aa8..79f3921049 100644 --- a/packages/labs/analyzer/test-files/types/src/module.ts +++ b/packages/labs/analyzer/test-files/ts/types/src/module.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {ImportedClass, ImportedInterface} from './external.js'; -import {LitElement} from 'lit'; +import {ImportedClass, ImportedInterface, returnsClass} from './external.js'; +import {LitElement, html} from 'lit'; // eslint-disable-next-line @typescript-eslint/no-inferrable-types export const testString: string = 'hi'; @@ -73,3 +73,5 @@ const [separatelyExportedDestructArr, [separatelyExportedDestructArrNested]] = [ [new LitElement()], ]; export {separatelyExportedDestructArr, separatelyExportedDestructArrNested}; + +export const importedType = Math.random() ? returnsClass() : html``; diff --git a/packages/labs/analyzer/test-files/ts/types/tsconfig.json b/packages/labs/analyzer/test-files/ts/types/tsconfig.json new file mode 100644 index 0000000000..204701fc83 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["es2020", "DOM"], + "module": "ES2020", + "rootDir": "./src", + "outDir": "./", + "moduleResolution": "node", + "experimentalDecorators": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/labs/cli-localize/.gitignore b/packages/labs/cli-localize/.gitignore new file mode 100644 index 0000000000..ca54677a04 --- /dev/null +++ b/packages/labs/cli-localize/.gitignore @@ -0,0 +1,2 @@ +/lib/ +/node_modules/ diff --git a/packages/labs/cli-localize/CHANGELOG.md b/packages/labs/cli-localize/CHANGELOG.md new file mode 100644 index 0000000000..d7aecf675a --- /dev/null +++ b/packages/labs/cli-localize/CHANGELOG.md @@ -0,0 +1,9 @@ +# @lit-labs/cli + +## 0.1.0 + +### Minor Changes + +- [#2936](https://github.com/lit/lit/pull/2936) [`7a9fc0f5`](https://github.com/lit/lit/commit/7a9fc0f57e43c2eab44e9442e5896f951a8c751a) - Locally version and lazily install the localize command. + +## 0.0.1 diff --git a/packages/labs/cli-localize/README.md b/packages/labs/cli-localize/README.md new file mode 100644 index 0000000000..3b9adf7fe4 --- /dev/null +++ b/packages/labs/cli-localize/README.md @@ -0,0 +1,7 @@ +# @lit-labs/cli-localize + +Powers the `lit localize` command. + +Don't use this directly, but install `@lit-labs/cli` and run `lit localize` from it. + +That command will load this package from the nearest node_modules directory, or offer to install it if it's not found. diff --git a/packages/labs/cli-localize/package-lock.json b/packages/labs/cli-localize/package-lock.json new file mode 100644 index 0000000000..7715f04c85 --- /dev/null +++ b/packages/labs/cli-localize/package-lock.json @@ -0,0 +1,660 @@ +{ + "name": "@lit-labs/cli-localize", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@lit-labs/cli-localize", + "version": "0.0.1", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/localize-tools": "^0.6.3" + }, + "devDependencies": { + "typescript": "~4.6.2" + }, + "engines": { + "node": ">=14.8.0" + } + }, + "node_modules/@lit/localize": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lit/localize/-/localize-0.11.3.tgz", + "integrity": "sha512-zWq74Sa/HvSRAtXv9UaBbZd7B1wP82rV/71krYJ+INdexye+nILjPUiGHYHb0G54+v7YpyZi61Fm5yI+kfcJnw==", + "dependencies": { + "@lit/reactive-element": "^1.0.0", + "lit": "^2.0.0" + } + }, + "node_modules/@lit/localize-tools": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@lit/localize-tools/-/localize-tools-0.6.3.tgz", + "integrity": "sha512-cJ5RazZw2GxUoSRqeZt5oIZfIvf217r/VFWXx7DkcNCELDdr29MCF9Bvl4gLK5X6GG0i4WjNccEIn3TO4i/BMg==", + "dependencies": { + "@lit/localize": "^0.11.0", + "@xmldom/xmldom": "^0.7.0", + "fast-glob": "^3.2.7", + "fs-extra": "^10.0.0", + "jsonschema": "^1.4.0", + "lit": "^2.2.0", + "minimist": "^1.2.5", + "parse5": "^6.0.1", + "source-map-support": "^0.5.19", + "typescript": "^4.3.5" + }, + "bin": { + "lit-localize": "bin/lit-localize.js" + } + }, + "node_modules/@lit/reactive-element": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.3.2.tgz", + "integrity": "sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz", + "integrity": "sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonschema": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz", + "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==", + "engines": { + "node": "*" + } + }, + "node_modules/lit": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.2.6.tgz", + "integrity": "sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==", + "dependencies": { + "@lit/reactive-element": "^1.3.0", + "lit-element": "^3.2.0", + "lit-html": "^2.2.0" + } + }, + "node_modules/lit-element": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.0.tgz", + "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "dependencies": { + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.2.0" + } + }, + "node_modules/lit-html": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.2.6.tgz", + "integrity": "sha512-xOKsPmq/RAKJ6dUeOxhmOYFjcjf0Q7aSdfBJgdJkOfCUnkmmJPxNrlZpRBeVe1Gg50oYWMlgm6ccAE/SpJgSdw==", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "engines": { + "node": ">= 10.0.0" + } + } + }, + "dependencies": { + "@lit/localize": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lit/localize/-/localize-0.11.3.tgz", + "integrity": "sha512-zWq74Sa/HvSRAtXv9UaBbZd7B1wP82rV/71krYJ+INdexye+nILjPUiGHYHb0G54+v7YpyZi61Fm5yI+kfcJnw==", + "requires": { + "@lit/reactive-element": "^1.0.0", + "lit": "^2.0.0" + } + }, + "@lit/localize-tools": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@lit/localize-tools/-/localize-tools-0.6.3.tgz", + "integrity": "sha512-cJ5RazZw2GxUoSRqeZt5oIZfIvf217r/VFWXx7DkcNCELDdr29MCF9Bvl4gLK5X6GG0i4WjNccEIn3TO4i/BMg==", + "requires": { + "@lit/localize": "^0.11.0", + "@xmldom/xmldom": "^0.7.0", + "fast-glob": "^3.2.7", + "fs-extra": "^10.0.0", + "jsonschema": "^1.4.0", + "lit": "^2.2.0", + "minimist": "^1.2.5", + "parse5": "^6.0.1", + "source-map-support": "^0.5.19", + "typescript": "^4.3.5" + } + }, + "@lit/reactive-element": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.3.2.tgz", + "integrity": "sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==" + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" + }, + "@xmldom/xmldom": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz", + "integrity": "sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==" + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "requires": { + "reusify": "^1.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + }, + "graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonschema": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz", + "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==" + }, + "lit": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.2.6.tgz", + "integrity": "sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==", + "requires": { + "@lit/reactive-element": "^1.3.0", + "lit-element": "^3.2.0", + "lit-html": "^2.2.0" + } + }, + "lit-element": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.0.tgz", + "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "requires": { + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.2.0" + } + }, + "lit-html": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.2.6.tgz", + "integrity": "sha512-xOKsPmq/RAKJ6dUeOxhmOYFjcjf0Q7aSdfBJgdJkOfCUnkmmJPxNrlZpRBeVe1Gg50oYWMlgm6ccAE/SpJgSdw==", + "requires": { + "@types/trusted-types": "^2.0.2" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==" + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + } + } +} diff --git a/packages/labs/cli-localize/package.json b/packages/labs/cli-localize/package.json new file mode 100644 index 0000000000..a5fcfe4e11 --- /dev/null +++ b/packages/labs/cli-localize/package.json @@ -0,0 +1,83 @@ +{ + "name": "@lit-labs/cli-localize", + "description": "Implements the `lit localize` command. Use from @lit-labs/cli", + "version": "0.1.0", + "publishConfig": { + "access": "public" + }, + "author": "Google LLC", + "license": "BSD-3-Clause", + "bugs": "https://github.com/lit/lit/issues", + "type": "module", + "main": "lib/index.js", + "repository": "lit/lit", + "scripts": { + "build": "wireit", + "test": "wireit", + "test:compile": "wireit", + "build:deps": "wireit", + "build:compile": "wireit" + }, + "wireit": { + "build": { + "dependencies": [ + "build:deps", + "build:compile" + ] + }, + "build:compile": { + "command": "tsc --skipLibCheck || echo ''", + "#comment": "This never fails and always emits output so that we can run tests of code that doesn't type check", + "clean": "if-file-deleted", + "files": [ + "tsconfig.json", + "src/**/*" + ], + "output": [ + "lib", + "test", + "index.{js,js.map,d.ts}" + ] + }, + "build:deps": { + "dependencies": [ + "../../localize-tools:build:ts" + ] + }, + "test": { + "dependencies": [ + "test:compile" + ] + }, + "test:compile": { + "dependencies": [ + "build:deps" + ], + "command": "tsc --pretty --noEmit", + "files": [ + "tsconfig.json", + "src/**/*" + ], + "output": [] + } + }, + "dependencies": { + "@lit/localize-tools": "^0.6.3" + }, + "devDependencies": { + "typescript": "~4.6.2" + }, + "engines": { + "node": ">=14.8.0" + }, + "files": [ + "lib" + ], + "homepage": "https://github.com/lit/lit", + "keywords": [ + "lit", + "localization", + "localize", + "lit-cli" + ] +} diff --git a/packages/labs/cli/src/lib/localize/build.ts b/packages/labs/cli-localize/src/commands.ts similarity index 60% rename from packages/labs/cli/src/lib/localize/build.ts rename to packages/labs/cli-localize/src/commands.ts index 12fdb9ed95..1bed84ff82 100644 --- a/packages/labs/cli/src/lib/localize/build.ts +++ b/packages/labs/cli-localize/src/commands.ts @@ -4,6 +4,17 @@ * SPDX-License-Identifier: BSD-3-Clause */ +/** + * The implementations of our commands exposed in the Lit CLI. + * + * These are in their own module so that we can separate out lightweight + * metadata about the commands (which are used for the --help command, + * and argument parsing), from their actual implementations here, because + * loading all of our deps of the implementation takes time, and loading all + * of the deps of all of the implementations of _every_ command would seriously + * slow down the CLI. + */ + import { readConfigFileAndWriteSchema, Config, @@ -14,8 +25,9 @@ import {TransformLitLocalizer} from '@lit/localize-tools/lib/modes/transform.js' import {RuntimeLitLocalizer} from '@lit/localize-tools/lib/modes/runtime.js'; import {KnownError, unreachable} from '@lit/localize-tools/lib/error.js'; import {LitLocalizer} from '@lit/localize-tools/lib/index.js'; +import {printDiagnostics} from '@lit/localize-tools/lib/typescript.js'; -export const run = async (configPath: string, console: Console) => { +export const build = async (configPath: string, console: Console) => { const config = readConfigFileAndWriteSchema(configPath); const localizer = makeLocalizer(config); console.log('Building'); @@ -35,6 +47,22 @@ export const run = async (configPath: string, console: Console) => { await localizer.build(); }; +export const extract = async (configPath: string, console: Console) => { + const config = readConfigFileAndWriteSchema(configPath); + const localizer = makeLocalizer(config); + // TODO(aomarks) Don't even require the user to have configured their output + // mode if they're just doing extraction. + console.log('Extracting messages'); + const {messages, errors} = localizer.extractSourceMessages(); + if (errors.length > 0) { + printDiagnostics(errors); + throw new KnownError('Error analyzing program'); + } + console.log(`Extracted ${messages.length} messages`); + console.log(`Writing interchange files`); + await localizer.writeInterchangeFiles(); +}; + const makeLocalizer = (config: Config): LitLocalizer => { switch (config.output.mode) { case 'transform': diff --git a/packages/labs/cli-localize/src/index.ts b/packages/labs/cli-localize/src/index.ts new file mode 100644 index 0000000000..fa24d7b1e6 --- /dev/null +++ b/packages/labs/cli-localize/src/index.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Defines our interface within the Lit CLI. + */ +export const getCommand = () => { + return { + kind: 'resolved', + name: 'localize', + description: 'Lit localize', + subcommands: [ + { + kind: 'resolved', + name: 'extract', + description: 'Extracts lit-localize messages', + options: [ + { + name: 'config', + description: 'The path to the localize config file', + defaultValue: './lit-localize.json', + }, + ], + async run({config}: {config: string}, console: Console) { + const commands = await import('./commands.js'); + await commands.extract(config, console); + }, + }, + { + kind: 'resolved', + name: 'build', + description: 'Build lit-localize projects', + options: [ + { + name: 'config', + description: 'The path to the localize config file', + defaultValue: './lit-localize.json', + }, + ], + async run({config}: {config: string}, console: Console) { + const commands = await import('./commands.js'); + await commands.build(config, console); + }, + }, + ], + async run(_options: unknown, console: Console) { + console.error( + 'Use one of the localize subcommands, like `lit localize build` or ' + + '`lit localize extract`. Run `lit help localize` for more help.' + ); + return { + exitCode: 1, + }; + }, + }; +}; diff --git a/packages/labs/cli-localize/tsconfig.json b/packages/labs/cli-localize/tsconfig.json new file mode 100644 index 0000000000..91a03f01d9 --- /dev/null +++ b/packages/labs/cli-localize/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "es2022", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "lib": ["es2020"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "preserveConstEnums": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "src/", + "outDir": "lib/", + "declaration": true, + "sourceMap": true, + "incremental": true, + "tsBuildInfoFile": "./lib/.tsbuildinfo", + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": [] +} diff --git a/packages/labs/cli/.prettierignore b/packages/labs/cli/.prettierignore new file mode 100644 index 0000000000..dd13a6f370 --- /dev/null +++ b/packages/labs/cli/.prettierignore @@ -0,0 +1 @@ +/test-goldens/ \ No newline at end of file diff --git a/packages/labs/cli/CHANGELOG.md b/packages/labs/cli/CHANGELOG.md index cb356847fb..7c89d22b65 100644 --- a/packages/labs/cli/CHANGELOG.md +++ b/packages/labs/cli/CHANGELOG.md @@ -1,5 +1,25 @@ # @lit-labs/cli +## 0.2.1 + +### Patch Changes + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 + +## 0.2.0 + +### Minor Changes + +- [#3304](https://github.com/lit/lit/pull/3304) [`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a) - Added support for analyzing JavaScript files. + +- [#2936](https://github.com/lit/lit/pull/2936) [`7a9fc0f5`](https://github.com/lit/lit/commit/7a9fc0f57e43c2eab44e9442e5896f951a8c751a) - Locally version and lazily install the localize command. + +### Patch Changes + +- Updated dependencies [[`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a), [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e), [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9)]: + - @lit-labs/analyzer@0.3.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/labs/cli/bin/lit.js b/packages/labs/cli/bin/lit.js index dfc30b0cb4..c322726d92 100755 --- a/packages/labs/cli/bin/lit.js +++ b/packages/labs/cli/bin/lit.js @@ -20,7 +20,9 @@ process.on('unhandledRejection', (error) => { // eslint-disable-next-line no-undef const args = process.argv.slice(2); -const cli = new LitCli(args); +// eslint-disable-next-line no-undef +const cwd = process.cwd(); +const cli = new LitCli(args, {cwd}); const result = await cli.run(); // eslint-disable-next-line no-undef process.exit(result?.exitCode ?? 0); diff --git a/packages/labs/cli/package.json b/packages/labs/cli/package.json index 405601dab9..571f6b866e 100644 --- a/packages/labs/cli/package.json +++ b/packages/labs/cli/package.json @@ -1,7 +1,7 @@ { "name": "@lit-labs/cli", "description": "Tooling for Lit development", - "version": "0.1.0", + "version": "0.2.1", "publishConfig": { "access": "public" }, @@ -41,7 +41,7 @@ }, "build:deps": { "dependencies": [ - "../../localize-tools:build:ts", + "../cli-localize:build", "../../tests:build", "../analyzer:build", "../gen-wrapper-react:build", @@ -62,13 +62,14 @@ "command": "tsc --pretty --noEmit", "files": [ "tsconfig.json", - "src/**/*" + "src/**/*", + "test-goldens/**/*" ], "output": [] }, "test:actual": { "#comment": "The quotes around the file regex must be double quotes on windows!", - "command": "uvu test \"_test\\.js$\"", + "command": "cross-env NODE_OPTIONS=--enable-source-maps uvu test \"_test\\.js$\"", "dependencies": [ "build" ], @@ -77,7 +78,7 @@ } }, "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit/localize-tools": "^0.6.1", "chalk": "^5.0.1", diff --git a/packages/labs/cli/src/lib/commands/help.ts b/packages/labs/cli/src/lib/commands/help.ts index 001d0a8619..2ca1a8e820 100644 --- a/packages/labs/cli/src/lib/commands/help.ts +++ b/packages/labs/cli/src/lib/commands/help.ts @@ -88,7 +88,13 @@ export const makeHelpCommand = (cli: LitCli): ResolvedCommand => { ], async run(options: CommandOptions, console: Console) { - const commandNames = options['command'] as Array | null; + let commandNames = options['command'] as Array | string | null; + + if (typeof commandNames === 'string') { + commandNames = commandNames?.split(' ') ?? []; + } + + commandNames = commandNames?.map((c) => c.trim()) ?? null; if (commandNames == null) { console.debug('no command given, printing general help...', {options}); diff --git a/packages/labs/cli/src/lib/commands/init.ts b/packages/labs/cli/src/lib/commands/init.ts new file mode 100644 index 0000000000..a9a5667844 --- /dev/null +++ b/packages/labs/cli/src/lib/commands/init.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {Command, CommandOptions} from '../command.js'; +import {run} from '../init/element-starter/index.js'; +import {LitCli} from '../lit-cli.js'; + +export type Language = 'ts' | 'js'; +export interface InitCommandOptions { + lang: Language; + name: string; + dir: string; +} + +export const makeInitCommand = (cli: LitCli): Command => { + return { + kind: 'resolved', + name: 'init', + description: 'Initialize a Lit project', + subcommands: [ + { + kind: 'resolved', + name: 'element', + description: 'Generate a shareable element starter package', + options: [ + { + name: 'lang', + defaultValue: 'js', + description: + 'Which language to use for the element. Supported languages: js, ts', + }, + { + name: 'name', + defaultValue: 'my-element', + description: + 'Tag name of the Element to generate (must include a hyphen).', + }, + { + name: 'dir', + defaultValue: '.', + description: 'Directory in which to generate the element package.', + }, + ], + async run(options: CommandOptions, console: Console) { + const name = options.name as string; + /* + * This is a basic check to ensure that the name is a valid custom + * element name. Will make sure you you start off with a character and + * at least one hyphen plus more characters. Will not check for the + * following invalid use cases: + * - starting with a digit + * + * Will not allow the following valid use cases: + * - including a unicode character as not the first character + */ + const customElementMatch = name.match(/\w+(-\w+)+/g); + if (!customElementMatch || customElementMatch[0] !== name) { + throw new Error( + `"${name}" is not a valid custom-element name. (Must include a hyphen and ascii characters)` + ); + } + return await run( + options as unknown as InitCommandOptions, + console, + cli + ); + }, + }, + ], + async run(_options: CommandOptions, console: Console) { + // by default run the element command + return await run( + {lang: 'js', name: 'my-element', dir: '.'}, + console, + cli + ); + }, + }; +}; diff --git a/packages/labs/cli/src/lib/commands/labs.ts b/packages/labs/cli/src/lib/commands/labs.ts index 572b954148..b7dc97b7d8 100644 --- a/packages/labs/cli/src/lib/commands/labs.ts +++ b/packages/labs/cli/src/lib/commands/labs.ts @@ -23,7 +23,7 @@ export const makeLabsCommand = (cli: LitCli): Command => { multiple: true, defaultValue: './', description: - 'Folder containing a package to generate wrappers for.', + 'Folder containing a package to generate wrappers for. For TypeScript projects, if the package folder does not contain a tsconfig.json, this option may also specify a specific tsconfig.json to use.', }, { name: 'framework', @@ -31,6 +31,12 @@ export const makeLabsCommand = (cli: LitCli): Command => { description: 'Framework to generate wrappers for. Supported frameworks: react, vue.', }, + { + name: 'manifest', + type: Boolean, + description: + 'Generate a custom-elements.json manifest for this package.', + }, { name: 'out', defaultValue: './gen', @@ -41,16 +47,18 @@ export const makeLabsCommand = (cli: LitCli): Command => { { package: packages, framework: frameworks, + manifest, out: outDir, }: { package: string[]; framework: string[]; + manifest: boolean; out: string; }, console: Console ) { const gen = await import('../generate/generate.js'); - await gen.run({cli, packages, frameworks, outDir}, console); + await gen.run({cli, packages, frameworks, manifest, outDir}, console); }, }, ], diff --git a/packages/labs/cli/src/lib/commands/localize.ts b/packages/labs/cli/src/lib/commands/localize.ts index f060870119..af8fe19bc3 100644 --- a/packages/labs/cli/src/lib/commands/localize.ts +++ b/packages/labs/cli/src/lib/commands/localize.ts @@ -4,53 +4,12 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {ResolvedCommand, CommandOptions} from '../command.js'; +import {ReferenceToCommand} from '../command.js'; -export const localize: ResolvedCommand = { - kind: 'resolved', +export const localize: ReferenceToCommand = { + kind: 'reference', name: 'localize', description: 'Lit localize', - subcommands: [ - { - kind: 'resolved', - name: 'extract', - description: 'Extracts lit-localize messages', - options: [ - { - name: 'config', - description: 'The path to the localize config file', - defaultValue: './lit-localize.json', - }, - ], - async run({config}: {config: string}, console: Console) { - const extract = await import('../localize/extract.js'); - await extract.run(config, console); - }, - }, - { - kind: 'resolved', - name: 'build', - description: 'Build lit-localize projects', - options: [ - { - name: 'config', - description: 'The path to the localize config file', - defaultValue: './lit-localize.json', - }, - ], - async run({config}: {config: string}, console: Console) { - const build = await import('../localize/build.js'); - await build.run(config, console); - }, - }, - ], - async run(_options: CommandOptions, console: Console) { - console.error( - 'Use one of the localize subcommands, like `lit localize build` or ' + - '`lit localize extract`. Run `lit help localize` for more help.' - ); - return { - exitCode: 1, - }; - }, + importSpecifier: '@lit-labs/cli-localize', + installFrom: '@lit-labs/cli-localize', }; diff --git a/packages/labs/cli/src/lib/generate/generate.ts b/packages/labs/cli/src/lib/generate/generate.ts index 11ea9a0372..0b2fdef86e 100644 --- a/packages/labs/cli/src/lib/generate/generate.ts +++ b/packages/labs/cli/src/lib/generate/generate.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {Analyzer} from '@lit-labs/analyzer'; +import {createPackageAnalyzer} from '@lit-labs/analyzer'; import {AbsolutePath} from '@lit-labs/analyzer/lib/paths.js'; import {FileTree, writeFileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; import {LitCli} from '../lit-cli.js'; @@ -28,9 +28,17 @@ const vueCommand: Command = { importSpecifier: '@lit-labs/gen-wrapper-vue/index.js', }; +const manifestCommand: Command = { + name: 'manifest', + description: 'Generate custom-elements.json manifest.', + kind: 'reference', + installFrom: '@lit-labs/gen-manifest', + importSpecifier: '@lit-labs/gen-manifest/index.js', +}; + // A generate command has a generate method instead of a run method. interface GenerateCommand extends Omit { - generate(options: {analysis: Package}, console: Console): Promise; + generate(options: {package: Package}, console: Console): Promise; } const frameworkCommands = { @@ -45,29 +53,39 @@ export const run = async ( cli, packages, frameworks: frameworkNames, + manifest, outDir, - }: {packages: string[]; frameworks: string[]; outDir: string; cli: LitCli}, + }: { + packages: string[]; + frameworks: string[]; + manifest: boolean; + outDir: string; + cli: LitCli; + }, console: Console ) => { for (const packageRoot of packages) { // Ensure separators in input paths are normalized and resolved to absolute const root = path.normalize(path.resolve(packageRoot)) as AbsolutePath; const out = path.normalize(path.resolve(outDir)) as AbsolutePath; - const analyzer = new Analyzer(root); - const analysis = analyzer.analyzePackage(); - if (!analysis.packageJson.name) { + const analyzer = createPackageAnalyzer(root); + const pkg = analyzer.getPackage(); + if (!pkg.packageJson.name) { throw new Error( `Package at '${packageRoot}' did not have a name in package.json. The 'gen' command requires that packages have a name.` ); } const generatorReferences = []; - for (const name of frameworkNames as FrameworkName[]) { + for (const name of (frameworkNames ?? []) as FrameworkName[]) { const framework = frameworkCommands[name]; if (framework == null) { throw new Error(`No generator exists for framework '${framework}'`); } generatorReferences.push(framework); } + if (manifest) { + generatorReferences.push(manifestCommand); + } // Optimistically try to import all generators in parallel. // If any aren't installed we need to ask for permission to install it // below, but in the common happy case this will do all the loading work. @@ -92,7 +110,7 @@ export const run = async ( generators.push(resolved as unknown as GenerateCommand); } const options = { - analysis, + package: pkg, }; const results = await Promise.allSettled( generators.map(async (generator) => { diff --git a/packages/labs/cli/src/lib/init/element-starter/index.ts b/packages/labs/cli/src/lib/init/element-starter/index.ts new file mode 100644 index 0000000000..482135a10f --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/index.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree, writeFileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import {generateTsconfig} from './templates/tsconfig.json.js'; +import {generatePackageJson} from './templates/package.json.js'; +import {generateIndex} from './templates/demo/index.html.js'; +import {generateGitignore} from './templates/gitignore.js'; +import {generateNpmignore} from './templates/npmignore.js'; +import {generateElement} from './templates/lib/element.js'; +import {CommandResult} from '../../command.js'; +import {InitCommandOptions} from '../../commands/init.js'; +import {LitCli} from '../../lit-cli.js'; +import path from 'path'; + +export const generateLitElementStarter = async ( + options: InitCommandOptions +): Promise => { + const {name, lang} = options; + let files = { + ...generatePackageJson(name, lang), + ...generateIndex(name), + ...generateGitignore(lang), + ...generateNpmignore(), + ...generateElement(name, lang), + }; + if (lang === 'ts') { + files = { + ...files, + ...generateTsconfig(), + }; + } + return files; +}; + +export const run = async ( + options: InitCommandOptions, + console: Console, + cli: LitCli +): Promise => { + const files = await generateLitElementStarter(options); + const outPath = path.join(cli.cwd, options.dir, options.name); + await writeFileTree(outPath, files); + const relativePath = path.relative(cli.cwd, outPath); + console.log(`Created sharable element in ${relativePath}/.`); + return { + exitCode: 0, + }; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/demo/index.html.ts b/packages/labs/cli/src/lib/init/element-starter/templates/demo/index.html.ts new file mode 100644 index 0000000000..4e40ac0659 --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/demo/index.html.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import {html} from '@lit-labs/gen-utils/lib/str-utils.js'; + +export const generateIndex = (elementName: string): FileTree => { + return { + demo: { + 'index.html': html` + + + ${elementName} demo + + + + <${elementName}> + +`, + }, + }; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/gitignore.ts b/packages/labs/cli/src/lib/init/element-starter/templates/gitignore.ts new file mode 100644 index 0000000000..04383247dd --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/gitignore.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import {Language} from '../../../commands/init.js'; + +export const generateGitignore = (lang: Language): FileTree => { + return { + '.gitignore': `node_modules${ + lang !== 'ts' + ? '' + : ` +lib` + }`, + }; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/lib/element.ts b/packages/labs/cli/src/lib/init/element-starter/templates/lib/element.ts new file mode 100644 index 0000000000..2fbe758f8b --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/lib/element.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import { + javascript, + kabobToPascalCase, +} from '@lit-labs/gen-utils/lib/str-utils.js'; +import {Language} from '../../../../commands/init.js'; + +export const generateElement = ( + elementName: string, + language: Language +): FileTree => { + const directory = language === 'js' ? 'lib' : 'src'; + return { + [directory]: { + [`${elementName}.${language}`]: + language === 'js' + ? generateTemplate(elementName, language) + : generateTemplate(elementName, language), + }, + }; +}; + +const generateTemplate = (elementName: string, lang: Language) => { + const className = kabobToPascalCase(elementName); + return javascript`import {LitElement, html, css} from 'lit';${ + lang === 'js' + ? '' + : ` +import {property, customElement} from 'lit/decorators.js';` + } + +/** + * An example element. + * + * @fires count-changed - Indicates when the count changes + * @slot - This element has a slot + * @csspart button - The button + * @cssprop --${elementName}-font-size - The button's font size + */${ + lang === 'js' + ? '' + : ` +@customElement('${elementName}')` + } +export class ${className} extends LitElement { + static styles = css\` + :host { + display: block; + border: solid 1px gray; + padding: 16px; + } + + button { + font-size: var(--${elementName}-font-size, 16px); + } + \`; + + ${ + lang === 'js' + ? `static properties = { + /** + * The number of times the button has been clicked. + * @type {number} + */ + count: {type: Number}, + } + + constructor() { + super(); + this.count = 0; + }` + : `/** + * The number of times the button has been clicked. + */ + @property({type: Number}) + count = 0;` + } + + render() { + return html\` +

Hello World

+ + + \`; + } + + ${lang === 'js' ? '' : 'protected '}_onClick() { + this.count++; + const event = new Event('count-changed', {bubbles: true}); + this.dispatchEvent(event); + } +} +${ + lang === 'js' + ? ` +customElements.define('${elementName}', ${className});` + : `` +}`; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/npmignore.ts b/packages/labs/cli/src/lib/init/element-starter/templates/npmignore.ts new file mode 100644 index 0000000000..2a4f0dc799 --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/npmignore.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; + +export const generateNpmignore = (): FileTree => { + return { + '.npmignore': `node_modules +.vscode +README.md +index.html +src`, + }; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/package.json.ts b/packages/labs/cli/src/lib/init/element-starter/templates/package.json.ts new file mode 100644 index 0000000000..5f558c2176 --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/package.json.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import {Language} from '../../../commands/init.js'; +import {litVersion} from '../../../lit-version.js'; + +export const generatePackageJson = ( + elementName: string, + language: Language +): FileTree => { + return { + 'package.json': `{ + "name": "${elementName}", + "version": "0.0.1", + "description": "A Minimal Lit Element starter kit", + "type": "module", + "main": "lib/${elementName}.js", + "scripts": { + "serve": "wds --node-resolve -nwo demo"${ + language !== 'ts' + ? '' + : `, + "build": "tsc", + "build:watch": "tsc --watch", + "dev": "npm run serve & npm run build:watch"` + } + }, + "keywords": [ + "web-component", + "lit-element", + "lit" + ], + "dependencies": { + "lit": "^${litVersion}" + }, + "devDependencies": { + "@web/dev-server": "^0.1.32"${ + language !== 'ts' + ? '' + : `, + "typescript": "^4.7.4"` + } + }, + "exports": { + "./lib/${elementName}.js": { + "default": "./lib/${elementName}.js" + } + } +}`, + }; +}; diff --git a/packages/labs/cli/src/lib/init/element-starter/templates/tsconfig.json.ts b/packages/labs/cli/src/lib/init/element-starter/templates/tsconfig.json.ts new file mode 100644 index 0000000000..f9fff23b81 --- /dev/null +++ b/packages/labs/cli/src/lib/init/element-starter/templates/tsconfig.json.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; + +export const generateTsconfig = (): FileTree => { + return { + 'tsconfig.json': `{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "rootDir": "src", + "outDir": "lib", + "strict": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts"] +}`, + }; +}; diff --git a/packages/labs/cli/src/lib/lit-cli.ts b/packages/labs/cli/src/lib/lit-cli.ts index 18b00d0587..ae3cc5af9c 100644 --- a/packages/labs/cli/src/lib/lit-cli.ts +++ b/packages/labs/cli/src/lib/lit-cli.ts @@ -19,12 +19,14 @@ import {globalOptions, mergeOptions} from './options.js'; import {makeHelpCommand} from './commands/help.js'; import {localize} from './commands/localize.js'; import {makeLabsCommand} from './commands/labs.js'; +import {makeInitCommand} from './commands/init.js'; import {createRequire} from 'module'; import * as childProcess from 'child_process'; export interface Options { + // Mandatory, so that all tests must specify it. + cwd: string; console?: LitConsole; - cwd?: string; stdin?: NodeJS.ReadableStream; } @@ -33,12 +35,12 @@ export class LitCli { readonly args: readonly string[]; readonly console: LitConsole; /** The current working directory. */ - private readonly cwd: string; + readonly cwd: string; private readonly stdin: NodeJS.ReadableStream; - constructor(args: string[], options?: Options) { - this.stdin = options?.stdin ?? process.stdin; - this.cwd = options?.cwd ?? process.cwd(); + constructor(args: string[], options: Options) { + this.cwd = options.cwd; + this.stdin = options.stdin ?? process.stdin; this.console = options?.console ?? new LitConsole(process.stdout, process.stderr); this.console.logLevel = 'info'; @@ -62,6 +64,7 @@ export class LitCli { this.addCommand(localize); this.addCommand(makeLabsCommand(this)); this.addCommand(makeHelpCommand(this)); + this.addCommand(makeInitCommand(this)); } addCommand(command: Command) { @@ -92,7 +95,10 @@ export class LitCli { const result = await this.getCommand(this.commands, this.args); if ('invalidCommand' in result) { - return helpCommand.run({command: [result.invalidCommand]}, this.console); + return await helpCommand.run( + {command: [result.invalidCommand]}, + this.console + ); } else if ('commandNotInstalled' in result) { this.console.error(`Command not installed.`); return {exitCode: 1}; @@ -123,11 +129,11 @@ export class LitCli { this.console.debug( `'--help' option found, running 'help' for given command...` ); - return helpCommand.run({command: commandName}, this.console); + return await helpCommand.run({command: commandName}, this.console); } this.console.debug('Running command...'); - return command.run(commandOptions, this.console); + return await command.run(commandOptions, this.console); } } @@ -281,6 +287,7 @@ export class LitCli { return false; } const installFrom = reference.installFrom ?? reference.importSpecifier; + this.console.log(`Installing ${installFrom}...`); const child = childProcess.spawn( // https://stackoverflow.com/questions/43230346/error-spawn-npm-enoent /^win/.test(process.platform) ? 'npm.cmd' : 'npm', diff --git a/packages/labs/cli/src/lib/lit-version.ts b/packages/labs/cli/src/lib/lit-version.ts new file mode 100644 index 0000000000..d8b86acb93 --- /dev/null +++ b/packages/labs/cli/src/lib/lit-version.ts @@ -0,0 +1 @@ +export const litVersion = '2.2.8'; diff --git a/packages/labs/cli/src/lib/localize/extract.ts b/packages/labs/cli/src/lib/localize/extract.ts deleted file mode 100644 index 111ed2fb6f..0000000000 --- a/packages/labs/cli/src/lib/localize/extract.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ - -import { - readConfigFileAndWriteSchema, - Config, - TransformOutputConfig, - RuntimeOutputConfig, -} from '@lit/localize-tools/lib/config.js'; -import {TransformLitLocalizer} from '@lit/localize-tools/lib/modes/transform.js'; -import {RuntimeLitLocalizer} from '@lit/localize-tools/lib/modes/runtime.js'; -import {printDiagnostics} from '@lit/localize-tools/lib/typescript.js'; -import {KnownError, unreachable} from '@lit/localize-tools/lib/error.js'; -import {LitLocalizer} from '@lit/localize-tools/lib/index.js'; - -export const run = async (configPath: string, console: Console) => { - const config = readConfigFileAndWriteSchema(configPath); - const localizer = makeLocalizer(config); - // TODO(aomarks) Don't even require the user to have configured their output - // mode if they're just doing extraction. - console.log('Extracting messages'); - const {messages, errors} = localizer.extractSourceMessages(); - if (errors.length > 0) { - printDiagnostics(errors); - throw new KnownError('Error analyzing program'); - } - console.log(`Extracted ${messages.length} messages`); - console.log(`Writing interchange files`); - await localizer.writeInterchangeFiles(); -}; - -const makeLocalizer = (config: Config): LitLocalizer => { - switch (config.output.mode) { - case 'transform': - return new TransformLitLocalizer( - // TODO(aomarks) Unfortunate that TypeScript doesn't automatically do - // this type narrowing. Because the union is on a nested property? - config as Config & {output: TransformOutputConfig} - ); - case 'runtime': - return new RuntimeLitLocalizer( - config as Config & {output: RuntimeOutputConfig} - ); - default: - throw new KnownError( - `Internal error: unknown mode ${ - (unreachable(config.output as never) as Config['output']).mode - }` - ); - } -}; diff --git a/packages/labs/cli/src/test/cli-test-utils.ts b/packages/labs/cli/src/test/cli-test-utils.ts index 95c556da98..0187c74e43 100644 --- a/packages/labs/cli/src/test/cli-test-utils.ts +++ b/packages/labs/cli/src/test/cli-test-utils.ts @@ -89,6 +89,10 @@ export const symlinkAllCommands = async (rig: FilesystemTestRig) => { relPath: ['..', '..', '..', 'gen-wrapper-vue'], packageName: ['@lit-labs', 'gen-wrapper-vue'], }, + { + relPath: ['..', '..', '..', 'cli-localize'], + packageName: ['@lit-labs', 'cli-localize'], + }, ]; await Promise.all( symLinks.map(async ({packageName, relPath}) => { diff --git a/packages/labs/cli/src/test/gen/react_test.ts b/packages/labs/cli/src/test/gen/react_test.ts index 8f27ead544..e9d39fa48e 100644 --- a/packages/labs/cli/src/test/gen/react_test.ts +++ b/packages/labs/cli/src/test/gen/react_test.ts @@ -55,7 +55,7 @@ test('basic wrapper generation', async ({rig, testConsole}) => { testConsole.alsoLogToGlobalConsole = true; await cli.run(); - assert.equal(testConsole.errorStream.buffer.join(''), ''); + assert.snapshot(testConsole.errorStream.text, ''); // Note, this is only a very basic test that wrapper generation succeeds when // executed via the CLI. For detailed tests, see tests in diff --git a/packages/labs/cli/src/test/help_test.ts b/packages/labs/cli/src/test/help_test.ts index ab186af1c1..33cf78a23d 100644 --- a/packages/labs/cli/src/test/help_test.ts +++ b/packages/labs/cli/src/test/help_test.ts @@ -4,12 +4,11 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import 'source-map-support/register.js'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; import {LitCli} from '../lib/lit-cli.js'; import {LitConsole} from '../lib/console.js'; -import {BufferedWritable} from './cli-test-utils.js'; +import {BufferedWritable, symlinkAllCommands} from './cli-test-utils.js'; import {ReferenceToCommand} from '../lib/command.js'; import {ConsoleConstructorOptions} from 'console'; import * as stream from 'stream'; @@ -50,41 +49,47 @@ test.after.each(async (ctx) => { await ctx.fs.cleanup(); }); -test('help with no command', async ({console, stdin}) => { - const cli = new LitCli(['help'], {console, stdin: stdin}); +test('help with no command', async ({fs, console, stdin}) => { + const cli = new LitCli(['help'], {console, stdin: stdin, cwd: fs.rootDir}); await cli.run(); const output = console.outputStream.text; - assert.equal(console.errorStream.buffer.length, 0); + assert.snapshot(console.errorStream.text, ''); assert.match(output, 'Lit CLI'); assert.match(output, 'Available Commands'); assert.match(output, 'localize'); }); -test('help with localize command', async ({console, stdin}) => { - const cli = new LitCli(['help', 'localize'], {console, stdin}); +test('help with localize command', async ({fs, console, stdin}) => { + await symlinkAllCommands(fs); + const cli = new LitCli(['help', 'localize'], { + console, + stdin, + cwd: fs.rootDir, + }); await cli.run(); const output = console.outputStream.text; - assert.equal(console.errorStream.buffer.length, 0); assert.match(output, 'lit localize'); assert.match(output, 'Sub-Commands'); assert.match(output, 'extract'); assert.match(output, 'build'); }); -test('help with localize extract command', async ({console, stdin}) => { +test('help with localize extract command', async ({fs, console, stdin}) => { + await symlinkAllCommands(fs); const cli = new LitCli(['help', 'localize', 'extract'], { console, stdin, + cwd: fs.rootDir, }); await cli.run(); const output = console.outputStream.text; - assert.equal(console.errorStream.buffer.length, 0); + assert.snapshot(console.errorStream.text, ''); assert.match(output, 'lit localize extract'); assert.match(output, '--config'); }); @@ -109,7 +114,7 @@ test('help includes unresolved external command descriptions', async ({ cli.addCommand(fooCommandReference); await cli.run(); const output = console.outputStream.text; - assert.equal(console.errorStream.buffer.length, 0); + assert.snapshot(console.errorStream.text, ''); assert.match( output, /\s+foo\s+This is the description in the `foo` reference./ @@ -183,7 +188,7 @@ test(`help for a resolved external command`, async ({console, fs, stdin}) => { cli.addCommand(fooCommandReference); await cli.run(); output = console.outputStream.text; - assert.equal(console.errorStream.buffer.length, 0); + assert.snapshot(console.errorStream.text, ''); assert.match( output, /\s+foo\s+this is the resolved foo command from the node_modules directory/ @@ -224,7 +229,7 @@ test('we install a referenced command with permission', async ({ }); cli.addCommand({...fooCommandReference, installFrom: '../foo-package'}); await cli.run(); - assert.equal(console.errorStream.text, ''); + assert.snapshot(console.errorStream.text, ''); // The npm install happend. assert.match(console.outputStream.text, 'added 1 package'); // After installation, we were able to resolve the command. diff --git a/packages/labs/cli/src/test/init/element_test.ts b/packages/labs/cli/src/test/init/element_test.ts new file mode 100644 index 0000000000..14254391b8 --- /dev/null +++ b/packages/labs/cli/src/test/init/element_test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import {LitCli} from '../../lib/lit-cli.js'; +import {suite} from '../uvu-wrapper.js'; +import {FilesystemTestRig} from '@lit-internal/tests/utils/filesystem-test-rig.js'; +import {symlinkAllCommands, TestConsole} from '../cli-test-utils.js'; +import {assertGoldensMatch} from '@lit-internal/tests/utils/assert-goldens.js'; +import path from 'path'; + +interface TestContext { + testConsole: TestConsole; + rig: FilesystemTestRig; +} + +const test = suite(); + +test.before.each(async (ctx) => { + const rig = new FilesystemTestRig(); + await rig.setup(); + ctx.rig = rig; + ctx.testConsole = new TestConsole(); +}); + +test.after.each(async ({rig}) => { + await rig.cleanup(); +}); + +test('element generation default', async ({rig, testConsole}) => { + await symlinkAllCommands(rig); + const cli = new LitCli(['init', 'element'], { + cwd: rig.rootDir, + console: testConsole, + }); + testConsole.alsoLogToGlobalConsole = true; + await cli.run(); + + assert.equal(testConsole.errorStream.buffer.join(''), ''); + + await assertGoldensMatch( + path.join(rig.rootDir, 'my-element'), + path.join('test-goldens/init', 'js'), + { + noFormat: true, + } + ); +}); + +test('lit init command runs lit init element with defaults', async ({ + rig, + testConsole, +}) => { + await symlinkAllCommands(rig); + const cli = new LitCli(['init'], { + cwd: rig.rootDir, + console: testConsole, + }); + testConsole.alsoLogToGlobalConsole = true; + await cli.run(); + + assert.equal(testConsole.errorStream.buffer.join(''), ''); + + await assertGoldensMatch( + path.join(rig.rootDir, 'my-element'), + path.join('test-goldens/init', 'js'), + { + noFormat: true, + } + ); +}); + +test('element generation named', async ({rig, testConsole}) => { + await symlinkAllCommands(rig); + const cli = new LitCli(['init', 'element', '--name', 'le-element'], { + cwd: rig.rootDir, + console: testConsole, + }); + testConsole.alsoLogToGlobalConsole = true; + await cli.run(); + + assert.equal(testConsole.errorStream.buffer.join(''), ''); + + await assertGoldensMatch( + path.join(rig.rootDir, 'le-element'), + path.join('test-goldens/init', 'js-named'), + { + noFormat: true, + } + ); +}); + +test('element generation invalid name', async ({rig, testConsole}) => { + await symlinkAllCommands(rig); + const cli = new LitCli(['init', 'element', '--name', 'element'], { + cwd: rig.rootDir, + console: testConsole, + }); + testConsole.alsoLogToGlobalConsole = true; + let errorThrown = false; + + try { + await cli.run(); + } catch { + // Expected + errorThrown = true; + } + + assert.ok(errorThrown, 'No invalid name error thrown'); +}); + +test('element generation TS named', async ({rig, testConsole}) => { + await symlinkAllCommands(rig); + const cli = new LitCli( + ['init', 'element', '--name', 'el-element', '--lang', 'ts'], + { + cwd: rig.rootDir, + console: testConsole, + } + ); + testConsole.alsoLogToGlobalConsole = true; + await cli.run(); + + assert.equal(testConsole.errorStream.buffer.join(''), ''); + + await assertGoldensMatch( + path.join(rig.rootDir, 'el-element'), + path.join('test-goldens/init', 'ts-named'), + { + noFormat: true, + } + ); +}); + +test('default config in subdirectory', async ({rig, testConsole}) => { + await symlinkAllCommands(rig); + const cli = new LitCli(['init', 'element', '--dir', 'subdir'], { + cwd: rig.rootDir, + console: testConsole, + }); + testConsole.alsoLogToGlobalConsole = true; + await cli.run(); + + assert.equal(testConsole.errorStream.buffer.join(''), ''); + + await assertGoldensMatch( + path.join(rig.rootDir, 'subdir', 'my-element'), + path.join('test-goldens/init', 'js'), + { + noFormat: true, + } + ); +}); + +test.run(); diff --git a/packages/labs/cli/src/test/uvu-wrapper.ts b/packages/labs/cli/src/test/uvu-wrapper.ts index 14df06c00d..8343029781 100644 --- a/packages/labs/cli/src/test/uvu-wrapper.ts +++ b/packages/labs/cli/src/test/uvu-wrapper.ts @@ -37,19 +37,21 @@ function timeout( test: uvu.Callback ): uvu.Callback { return async (ctx) => { - let timeoutId: ReturnType; - const result = Promise.race([ - test(ctx), - new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error(`Test timed out: ${JSON.stringify(name)}`)), - ms - ); - }), - ]); - result.finally(() => { - clearTimeout(timeoutId); - }); - return result; + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + test(ctx), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Test timed out: ${JSON.stringify(name)}`)), + ms + ); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } }; } diff --git a/packages/labs/cli/test-goldens/init/js-named/.gitignore b/packages/labs/cli/test-goldens/init/js-named/.gitignore new file mode 100644 index 0000000000..b512c09d47 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js-named/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/js-named/.npmignore b/packages/labs/cli/test-goldens/init/js-named/.npmignore new file mode 100644 index 0000000000..535a7f97ec --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js-named/.npmignore @@ -0,0 +1,5 @@ +node_modules +.vscode +README.md +index.html +src \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/js-named/demo/index.html b/packages/labs/cli/test-goldens/init/js-named/demo/index.html new file mode 100644 index 0000000000..30490ad199 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js-named/demo/index.html @@ -0,0 +1,10 @@ + + + + le-element demo + + + + + + diff --git a/packages/labs/cli/test-goldens/init/js-named/lib/le-element.js b/packages/labs/cli/test-goldens/init/js-named/lib/le-element.js new file mode 100644 index 0000000000..4cbca10550 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js-named/lib/le-element.js @@ -0,0 +1,54 @@ +import {LitElement, html, css} from 'lit'; + +/** + * An example element. + * + * @fires count-changed - Indicates when the count changes + * @slot - This element has a slot + * @csspart button - The button + * @cssprop --le-element-font-size - The button's font size + */ +export class LeElement extends LitElement { + static styles = css` + :host { + display: block; + border: solid 1px gray; + padding: 16px; + } + + button { + font-size: var(--le-element-font-size, 16px); + } + `; + + static properties = { + /** + * The number of times the button has been clicked. + * @type {number} + */ + count: {type: Number}, + } + + constructor() { + super(); + this.count = 0; + } + + render() { + return html` +

Hello World

+ + + `; + } + + _onClick() { + this.count++; + const event = new Event('count-changed', {bubbles: true}); + this.dispatchEvent(event); + } +} + +customElements.define('le-element', LeElement); diff --git a/packages/labs/cli/test-goldens/init/js-named/package.json b/packages/labs/cli/test-goldens/init/js-named/package.json new file mode 100644 index 0000000000..0f290243eb --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js-named/package.json @@ -0,0 +1,26 @@ +{ + "name": "le-element", + "version": "0.0.1", + "description": "A Minimal Lit Element starter kit", + "type": "module", + "main": "lib/le-element.js", + "scripts": { + "serve": "wds --node-resolve -nwo demo" + }, + "keywords": [ + "web-component", + "lit-element", + "lit" + ], + "dependencies": { + "lit": "^2.2.8" + }, + "devDependencies": { + "@web/dev-server": "^0.1.32" + }, + "exports": { + "./lib/le-element.js": { + "default": "./lib/le-element.js" + } + } +} diff --git a/packages/labs/cli/test-goldens/init/js/.gitignore b/packages/labs/cli/test-goldens/init/js/.gitignore new file mode 100644 index 0000000000..b512c09d47 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/js/.npmignore b/packages/labs/cli/test-goldens/init/js/.npmignore new file mode 100644 index 0000000000..535a7f97ec --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js/.npmignore @@ -0,0 +1,5 @@ +node_modules +.vscode +README.md +index.html +src \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/js/demo/index.html b/packages/labs/cli/test-goldens/init/js/demo/index.html new file mode 100644 index 0000000000..9ef5976353 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js/demo/index.html @@ -0,0 +1,10 @@ + + + + my-element demo + + + + + + diff --git a/packages/labs/cli/test-goldens/init/js/lib/my-element.js b/packages/labs/cli/test-goldens/init/js/lib/my-element.js new file mode 100644 index 0000000000..8861fc64a1 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js/lib/my-element.js @@ -0,0 +1,54 @@ +import {LitElement, html, css} from 'lit'; + +/** + * An example element. + * + * @fires count-changed - Indicates when the count changes + * @slot - This element has a slot + * @csspart button - The button + * @cssprop --my-element-font-size - The button's font size + */ +export class MyElement extends LitElement { + static styles = css` + :host { + display: block; + border: solid 1px gray; + padding: 16px; + } + + button { + font-size: var(--my-element-font-size, 16px); + } + `; + + static properties = { + /** + * The number of times the button has been clicked. + * @type {number} + */ + count: {type: Number}, + } + + constructor() { + super(); + this.count = 0; + } + + render() { + return html` +

Hello World

+ + + `; + } + + _onClick() { + this.count++; + const event = new Event('count-changed', {bubbles: true}); + this.dispatchEvent(event); + } +} + +customElements.define('my-element', MyElement); diff --git a/packages/labs/cli/test-goldens/init/js/package.json b/packages/labs/cli/test-goldens/init/js/package.json new file mode 100644 index 0000000000..cd3ff42100 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/js/package.json @@ -0,0 +1,26 @@ +{ + "name": "my-element", + "version": "0.0.1", + "description": "A Minimal Lit Element starter kit", + "type": "module", + "main": "lib/my-element.js", + "scripts": { + "serve": "wds --node-resolve -nwo demo" + }, + "keywords": [ + "web-component", + "lit-element", + "lit" + ], + "dependencies": { + "lit": "^2.2.8" + }, + "devDependencies": { + "@web/dev-server": "^0.1.32" + }, + "exports": { + "./lib/my-element.js": { + "default": "./lib/my-element.js" + } + } +} diff --git a/packages/labs/cli/test-goldens/init/ts-named/.gitignore b/packages/labs/cli/test-goldens/init/ts-named/.gitignore new file mode 100644 index 0000000000..9b26ed04f1 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/.gitignore @@ -0,0 +1,2 @@ +node_modules +lib \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/ts-named/.npmignore b/packages/labs/cli/test-goldens/init/ts-named/.npmignore new file mode 100644 index 0000000000..535a7f97ec --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/.npmignore @@ -0,0 +1,5 @@ +node_modules +.vscode +README.md +index.html +src \ No newline at end of file diff --git a/packages/labs/cli/test-goldens/init/ts-named/demo/index.html b/packages/labs/cli/test-goldens/init/ts-named/demo/index.html new file mode 100644 index 0000000000..32d3637b6e --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/demo/index.html @@ -0,0 +1,10 @@ + + + + el-element demo + + + + + + diff --git a/packages/labs/cli/test-goldens/init/ts-named/package.json b/packages/labs/cli/test-goldens/init/ts-named/package.json new file mode 100644 index 0000000000..b36c4aefeb --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/package.json @@ -0,0 +1,30 @@ +{ + "name": "el-element", + "version": "0.0.1", + "description": "A Minimal Lit Element starter kit", + "type": "module", + "main": "lib/el-element.js", + "scripts": { + "serve": "wds --node-resolve -nwo demo", + "build": "tsc", + "build:watch": "tsc --watch", + "dev": "npm run serve & npm run build:watch" + }, + "keywords": [ + "web-component", + "lit-element", + "lit" + ], + "dependencies": { + "lit": "^2.2.8" + }, + "devDependencies": { + "@web/dev-server": "^0.1.32", + "typescript": "^4.7.4" + }, + "exports": { + "./lib/el-element.js": { + "default": "./lib/el-element.js" + } + } +} diff --git a/packages/labs/cli/test-goldens/init/ts-named/src/el-element.ts b/packages/labs/cli/test-goldens/init/ts-named/src/el-element.ts new file mode 100644 index 0000000000..fd82cd1a0b --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/src/el-element.ts @@ -0,0 +1,47 @@ +import {LitElement, html, css} from 'lit'; +import {property, customElement} from 'lit/decorators.js'; + +/** + * An example element. + * + * @fires count-changed - Indicates when the count changes + * @slot - This element has a slot + * @csspart button - The button + * @cssprop --el-element-font-size - The button's font size + */ +@customElement('el-element') +export class ElElement extends LitElement { + static styles = css` + :host { + display: block; + border: solid 1px gray; + padding: 16px; + } + + button { + font-size: var(--el-element-font-size, 16px); + } + `; + + /** + * The number of times the button has been clicked. + */ + @property({type: Number}) + count = 0; + + render() { + return html` +

Hello World

+ + + `; + } + + protected _onClick() { + this.count++; + const event = new Event('count-changed', {bubbles: true}); + this.dispatchEvent(event); + } +} diff --git a/packages/labs/cli/test-goldens/init/ts-named/tsconfig.json b/packages/labs/cli/test-goldens/init/ts-named/tsconfig.json new file mode 100644 index 0000000000..102bdf01d6 --- /dev/null +++ b/packages/labs/cli/test-goldens/init/ts-named/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "rootDir": "src", + "outDir": "lib", + "strict": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/labs/context/README.md b/packages/labs/context/README.md index 76929b6cb0..8d3076fb6b 100644 --- a/packages/labs/context/README.md +++ b/packages/labs/context/README.md @@ -32,18 +32,18 @@ export const loggerContext = createContext('logger'); Now we can define a consumer for this context - some component in our app needs the logger. -Here we're using the `@contextProvided` property decorator to make a `ContextConsumer` controller +Here we're using the `@consume` property decorator to make a `ContextConsumer` controller and update its value when the context changes: #### **`my-element.ts`**: ```ts -import {contextRequest} from '@lit-labs/context'; import {LitElement, property} from 'lit'; +import {consume} from '@lit-labs/context'; import {Logger, loggerContext} from './logger.js'; export class MyElement extends LitElement { - @contextProvided({context: loggerContext, subscribe: true}) + @consume({context: loggerContext, subscribe: true}) @property({attribute: false}) public logger?: Logger; @@ -80,18 +80,18 @@ export class MyElement extends LitElement { Finally we want to be able to provide this context from somewhere higher in the DOM. -Here we're using a `@contextProvider` property decorator to make a `ContextProvider` +Here we're using a `@provide` property decorator to make a `ContextProvider` controller and update its value when the property value changes. #### **`my-app.ts`**: ```ts import {LitElement} from 'lit'; -import {contextProvider} from '@lit-labs/context'; -import {loggerContext, Logger} from './my-logger.js'; +import {provide} from '@lit-labs/context'; +import {loggerContext, Logger} from './logger.js'; export class MyApp extends LitElement { - @contextProvider({context: loggerContext}) + @provide({context: loggerContext}) @property({attribute: false}) public logger: Logger = { log: (msg) => { @@ -112,7 +112,7 @@ We can also use the `ContextProvider` controller directly: ```ts import {LitElement} from 'lit'; import {ContextProvider} from '@lit-labs/context'; -import {loggerContext, Logger} from './my-logger.js'; +import {loggerContext, Logger} from './logger.js'; export class MyApp extends LitElement { // create a provider controller and a default logger diff --git a/packages/labs/context/src/index.ts b/packages/labs/context/src/index.ts index 8584b7fd47..d3a2646a24 100644 --- a/packages/labs/context/src/index.ts +++ b/packages/labs/context/src/index.ts @@ -9,11 +9,29 @@ export { ContextRequestEvent as ContextEvent, } from './lib/context-request-event.js'; -export {ContextKey, ContextType, createContext} from './lib/context-key.js'; +export { + Context, + ContextKey, + ContextType, + createContext, +} from './lib/create-context.js'; export {ContextConsumer} from './lib/controllers/context-consumer.js'; export {ContextProvider} from './lib/controllers/context-provider.js'; export {ContextRoot} from './lib/context-root.js'; -export {contextProvider} from './lib/decorators/context-provider.js'; -export {contextProvided} from './lib/decorators/context-provided.js'; +export {provide} from './lib/decorators/provide.js'; +export {consume} from './lib/decorators/consume.js'; + +import {provide} from './lib/decorators/provide.js'; +import {consume} from './lib/decorators/consume.js'; + +/** + * @deprecated use `provide` instead + */ +export const contextProvider = provide; + +/** + * @deprecated use `consume` instead + */ +export const contextProvided = consume; diff --git a/packages/labs/context/src/lib/context-key.ts b/packages/labs/context/src/lib/context-key.ts deleted file mode 100644 index e816878f58..0000000000 --- a/packages/labs/context/src/lib/context-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ - -/** - * The ContextKey type defines a type brand to associate a key value with the context value type - */ -export type ContextKey = KeyType & {__context__: ValueType}; - -/** - * A helper type which can extract a Context value type from a Context type - */ -export type ContextType> = - Key extends ContextKey ? ValueType : never; - -/** - * A helper method for creating a context key with the appropriate type - * - * @param key a context key value - * @returns the context key value with the correct type - */ -export function createContext(key: unknown) { - return key as ContextKey; -} diff --git a/packages/labs/context/src/lib/context-request-event.ts b/packages/labs/context/src/lib/context-request-event.ts index 95c290d53c..ad4f9d904c 100644 --- a/packages/labs/context/src/lib/context-request-event.ts +++ b/packages/labs/context/src/lib/context-request-event.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {ContextType, ContextKey} from './context-key.js'; +import {ContextType, Context} from './create-context.js'; declare global { interface HTMLElementEventMap { @@ -12,7 +12,7 @@ declare global { * A 'context-request' event can be emitted by any element which desires * a context value to be injected by an external provider. */ - 'context-request': ContextRequestEvent>; + 'context-request': ContextRequestEvent>; } } @@ -28,9 +28,9 @@ export type ContextCallback = ( /** * Interface definition for a ContextRequest */ -export interface ContextRequest> { - readonly context: Context; - readonly callback: ContextCallback>; +export interface ContextRequest> { + readonly context: C; + readonly callback: ContextCallback>; readonly subscribe?: boolean; } @@ -47,9 +47,9 @@ export interface ContextRequest> { * If no `subscribe` value is present in the event, then the provider can assume that this is a 'one time' * request for the context and can therefore not track the consumer. */ -export class ContextRequestEvent> +export class ContextRequestEvent> extends Event - implements ContextRequest + implements ContextRequest { /** * @@ -58,8 +58,8 @@ export class ContextRequestEvent> * @param subscribe an optional argument, if true indicates we want to subscribe to future updates */ public constructor( - public readonly context: Context, - public readonly callback: ContextCallback>, + public readonly context: C, + public readonly callback: ContextCallback>, public readonly subscribe?: boolean ) { super('context-request', {bubbles: true, composed: true}); diff --git a/packages/labs/context/src/lib/context-root.ts b/packages/labs/context/src/lib/context-root.ts index 2bba275c12..acb428e58d 100644 --- a/packages/labs/context/src/lib/context-root.ts +++ b/packages/labs/context/src/lib/context-root.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {ContextKey} from './context-key.js'; +import {Context} from './create-context.js'; import {ContextRequest, ContextRequestEvent} from './context-request-event.js'; import {ContextProviderEvent} from './controllers/context-provider.js'; -type UnknownContextKey = ContextKey; +type UnknownContextKey = Context; /** * A context request, with associated source element, with all objects as weak references. @@ -50,7 +50,7 @@ export class ContextRoot { } private onContextProvider = ( - ev: ContextProviderEvent> + ev: ContextProviderEvent> ) => { const pendingRequests = this.pendingContextRequests.get(ev.context); if (!pendingRequests) { @@ -74,7 +74,7 @@ export class ContextRoot { }; private onContextRequest = ( - ev: ContextRequestEvent> + ev: ContextRequestEvent> ) => { // events that are not subscribing should not be captured if (!ev.subscribe) { diff --git a/packages/labs/context/src/lib/controllers/context-consumer.ts b/packages/labs/context/src/lib/controllers/context-consumer.ts index f7f6feeeaa..9775b53c83 100644 --- a/packages/labs/context/src/lib/controllers/context-consumer.ts +++ b/packages/labs/context/src/lib/controllers/context-consumer.ts @@ -5,7 +5,7 @@ */ import {ContextRequestEvent} from '../context-request-event.js'; -import {ContextKey, ContextType} from '../context-key.js'; +import {Context, ContextType} from '../create-context.js'; import {ReactiveController, ReactiveElement} from 'lit'; /** @@ -17,21 +17,18 @@ import {ReactiveController, ReactiveElement} from 'lit'; * disconnected. */ export class ContextConsumer< - Context extends ContextKey, + C extends Context, HostElement extends ReactiveElement > implements ReactiveController { private provided = false; - public value?: ContextType = undefined; + public value?: ContextType = undefined; constructor( protected host: HostElement, - private context: Context, - private callback?: ( - value: ContextType, - dispose?: () => void - ) => void, + private context: C, + private callback?: (value: ContextType, dispose?: () => void) => void, private subscribe: boolean = false ) { this.host.addController(this); diff --git a/packages/labs/context/src/lib/controllers/context-provider.ts b/packages/labs/context/src/lib/controllers/context-provider.ts index e1c3a8262e..10cd5693df 100644 --- a/packages/labs/context/src/lib/controllers/context-provider.ts +++ b/packages/labs/context/src/lib/controllers/context-provider.ts @@ -5,7 +5,7 @@ */ import {ContextRequestEvent} from '../context-request-event.js'; -import {ContextKey, ContextType} from '../context-key.js'; +import {Context, ContextType} from '../create-context.js'; import {ValueNotifier} from '../value-notifier.js'; import {ReactiveController, ReactiveElement} from 'lit'; @@ -15,18 +15,18 @@ declare global { * A 'context-provider' event can be emitted by any element which hosts * a context provider to indicate it is available for use. */ - 'context-provider': ContextProviderEvent>; + 'context-provider': ContextProviderEvent>; } } export class ContextProviderEvent< - Context extends ContextKey + C extends Context > extends Event { /** * * @param context the context which this provider can provide */ - public constructor(public readonly context: Context) { + public constructor(public readonly context: C) { super('context-provider', {bubbles: true, composed: true}); } } @@ -39,7 +39,7 @@ export class ContextProviderEvent< * the host is connected to the DOM and registers the received callbacks * against its observable Context implementation. */ -export class ContextProvider> +export class ContextProvider> extends ValueNotifier> implements ReactiveController { @@ -49,12 +49,12 @@ export class ContextProvider> initialValue?: ContextType ) { super(initialValue); - this.host.addController(this); this.attachListeners(); + this.host.addController(this); } public onContextRequest = ( - ev: ContextRequestEvent> + ev: ContextRequestEvent> ): void => { // Only call the callback if the context matches. // Also, in case an element is a consumer AND a provider diff --git a/packages/labs/context/src/lib/create-context.ts b/packages/labs/context/src/lib/create-context.ts new file mode 100644 index 0000000000..5e8840ba49 --- /dev/null +++ b/packages/labs/context/src/lib/create-context.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * The Context type defines a type brand to associate a key value with the context value type + */ +export type Context = KeyType & {__context__: ValueType}; + +/** + * @deprecated use Context instead + */ +export type ContextKey = Context; + +/** + * A helper type which can extract a Context value type from a Context type + */ +export type ContextType> = + Key extends Context ? ValueType : never; + +/** + * Creates a typed Context. + * + * Contexts are compared with with strict equality. + * + * If you want two separate `createContext()` calls to referer to the same + * context, then use a key that will by equal under strict equality like a + * string for `Symbol.for()`: + * + * ```ts + * // true + * createContext('my-context') === createContext('my-context') + * // true + * createContext(Symbol.for('my-context')) === createContext(Symbol.for('my-context')) + * ``` + * + * If you want a context to be unique so that it's guaranteed to not collide + * with other contexts, use a key that's unique under strict equality, like + * a `Symbol()` or object.: + * + * ``` + * // false + * createContext({}) === createContext({}) + * // false + * createContext(Symbol('my-context')) === createContext(Symbol('my-context')) + * ``` + * + * @param key a context key value + * @template ValueType the type of value that can be provided by this context. + * @returns the context key value with the correct type + */ +export function createContext(key: unknown) { + return key as Context; +} diff --git a/packages/labs/context/src/lib/decorators/context-provided.ts b/packages/labs/context/src/lib/decorators/consume.ts similarity index 84% rename from packages/labs/context/src/lib/decorators/context-provided.ts rename to packages/labs/context/src/lib/decorators/consume.ts index aa922d99df..b544363370 100644 --- a/packages/labs/context/src/lib/decorators/context-provided.ts +++ b/packages/labs/context/src/lib/decorators/consume.ts @@ -7,7 +7,7 @@ import {ReactiveElement} from '@lit/reactive-element'; import {decorateProperty} from '@lit/reactive-element/decorators/base.js'; import {ContextConsumer} from '../controllers/context-consumer.js'; -import {ContextKey} from '../context-key.js'; +import {Context} from '../create-context.js'; /* * IMPORTANT: For compatibility with tsickle and the Closure JS compiler, all @@ -27,10 +27,11 @@ import {ContextKey} from '../context-key.js'; * @example * * ```ts + * import {consume} from '@lit-labs/context'; * import {loggerContext, Logger} from 'community-protocols/logger'; * * class MyElement { - * @contextProvided({context: loggerContext}) + * @consume({context: loggerContext}) * logger?: Logger; * * doThing() { @@ -40,14 +41,15 @@ import {ContextKey} from '../context-key.js'; * ``` * @category Decorator */ -export function contextProvided({ +export function consume({ context: context, subscribe, }: { - context: ContextKey; + context: Context; subscribe?: boolean; }): ( - protoOrDescriptor: ReactiveElement & Record, + // Partial<> allows for providing the value to an optional field + protoOrDescriptor: ReactiveElement & Partial>, name?: K // Note TypeScript requires the return type to be `void|any` // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/labs/context/src/lib/decorators/context-provider.ts b/packages/labs/context/src/lib/decorators/provide.ts similarity index 92% rename from packages/labs/context/src/lib/decorators/context-provider.ts rename to packages/labs/context/src/lib/decorators/provide.ts index 60cab8af31..01feecdd34 100644 --- a/packages/labs/context/src/lib/decorators/context-provider.ts +++ b/packages/labs/context/src/lib/decorators/provide.ts @@ -6,7 +6,7 @@ import {ReactiveElement} from '@lit/reactive-element'; import {decorateProperty} from '@lit/reactive-element/decorators/base.js'; -import {ContextKey} from '../context-key.js'; +import {Context} from '../create-context.js'; import {ContextProvider} from '../controllers/context-provider.js'; /* @@ -29,10 +29,11 @@ import {ContextProvider} from '../controllers/context-provider.js'; * @example * * ```ts + * import {consume} from '@lit-labs/context'; * import {loggerContext} from 'community-protocols/logger'; * * class MyElement { - * @contextProvided(loggerContext) + * @provide(loggerContext) * logger; * * doThing() { @@ -42,10 +43,10 @@ import {ContextProvider} from '../controllers/context-provider.js'; * ``` * @category Decorator */ -export function contextProvider({ +export function provide({ context: context, }: { - context: ContextKey; + context: Context; }): ( protoOrDescriptor: ReactiveElement & Record, name?: K diff --git a/packages/labs/context/src/test/context-provider_test.ts b/packages/labs/context/src/test/context-provider_test.ts index 0a4031b869..5d069e1818 100644 --- a/packages/labs/context/src/test/context-provider_test.ts +++ b/packages/labs/context/src/test/context-provider_test.ts @@ -7,15 +7,20 @@ import {LitElement, html, TemplateResult} from 'lit'; import {property} from 'lit/decorators/property.js'; -import {ContextKey, contextProvided, contextProvider} from '@lit-labs/context'; +import {Context, consume, provide} from '@lit-labs/context'; import {assert} from '@esm-bundle/chai'; -const simpleContext = 'simple-context' as ContextKey<'simple-context', number>; +const simpleContext = 'simple-context' as Context<'simple-context', number>; class ContextConsumerElement extends LitElement { - @contextProvided({context: simpleContext, subscribe: true}) + @consume({context: simpleContext, subscribe: true}) @property({type: Number}) - public value = 0; + public value?: number; + + // @ts-expect-error Type 'string' is not assignable to type 'number'. + @consume({context: simpleContext, subscribe: true}) + @property({type: Number}) + public value2?: string; protected render(): TemplateResult { return html`Value ${this.value}`; @@ -24,7 +29,7 @@ class ContextConsumerElement extends LitElement { customElements.define('context-consumer', ContextConsumerElement); class ContextProviderElement extends LitElement { - @contextProvider({context: simpleContext}) + @provide({context: simpleContext}) @property({type: Number, reflect: true}) public value = 0; @@ -38,7 +43,7 @@ class ContextProviderElement extends LitElement { } customElements.define('context-provider', ContextProviderElement); -suite('@contextProvided', () => { +suite('@consume', () => { let consumer: ContextConsumerElement; let provider: ContextProviderElement; let container: HTMLElement; @@ -81,7 +86,7 @@ suite('@contextProvided', () => { }); }); -suite('@contextProvided: multiple instances', () => { +suite('@consume: multiple instances', () => { let consumers: ContextConsumerElement[]; let providers: ContextProviderElement[]; let container: HTMLElement; diff --git a/packages/labs/context/src/test/context-request_test.ts b/packages/labs/context/src/test/context-request_test.ts index 0afa993482..4d1f74aef7 100644 --- a/packages/labs/context/src/test/context-request_test.ts +++ b/packages/labs/context/src/test/context-request_test.ts @@ -11,7 +11,7 @@ import { ContextConsumer, ContextProvider, createContext, - contextProvided, + consume, } from '@lit-labs/context'; import {assert} from '@esm-bundle/chai'; @@ -31,12 +31,12 @@ class SimpleContextProvider extends LitElement { class SimpleContextConsumer extends LitElement { // a one-time property fullfilled by context - @contextProvided({context: simpleContext}) + @consume({context: simpleContext}) @property({type: Number}) public onceValue = 0; // a subscribed property fulfilled by context - @contextProvided({context: simpleContext, subscribe: true}) + @consume({context: simpleContext, subscribe: true}) @property({type: Number}) public subscribedValue = 0; diff --git a/packages/labs/context/src/test/late-provider_test.ts b/packages/labs/context/src/test/late-provider_test.ts index e311666e53..ff40e35d53 100644 --- a/packages/labs/context/src/test/late-provider_test.ts +++ b/packages/labs/context/src/test/late-provider_test.ts @@ -5,24 +5,26 @@ */ import {LitElement, html, TemplateResult} from 'lit'; -import {property} from 'lit/decorators/property.js'; +import {customElement, property} from 'lit/decorators.js'; import { - ContextKey, - contextProvided, - contextProvider, + Context, + consume, + provide, ContextRoot, + ContextProvider, } from '@lit-labs/context'; import {assert} from '@esm-bundle/chai'; -const simpleContext = 'simple-context' as ContextKey<'simple-context', number>; +const simpleContext = 'simple-context' as Context<'simple-context', number>; +@customElement('context-consumer') class ContextConsumerElement extends LitElement { - @contextProvided({context: simpleContext, subscribe: true}) + @consume({context: simpleContext, subscribe: true}) @property({type: Number}) public value = 0; - @contextProvided({context: simpleContext}) + @consume({context: simpleContext}) @property({type: Number}) public onceValue = 0; @@ -30,10 +32,9 @@ class ContextConsumerElement extends LitElement { return html`Value ${this.value}`; } } -customElements.define('context-consumer', ContextConsumerElement); class LateContextProviderElement extends LitElement { - @contextProvider({context: simpleContext}) + @provide({context: simpleContext}) @property({type: Number, reflect: true}) public value = 0; @@ -46,59 +47,96 @@ class LateContextProviderElement extends LitElement { } } +@customElement('lazy-context-provider') +export class LazyContextProviderElement extends LitElement { + protected render() { + return html``; + } +} + suite('late context provider', () => { - let consumer: ContextConsumerElement; - let provider: LateContextProviderElement; + // let consumer: ContextConsumerElement; + // let provider: LateContextProviderElement; let container: HTMLElement; + setup(async () => { container = document.createElement('div'); + document.body.append(container); - // add a root context to catch late providers and re-dispatch requests + // Add a root context to catch late providers and re-dispatch requests new ContextRoot().attach(container); + }); + teardown(() => { + container.remove(); + }); + + test(`handles late upgrade properly`, async () => { container.innerHTML = ` - - - - `; - document.body.appendChild(container); + + + + `; - provider = container.querySelector( + const provider = container.querySelector( 'late-context-provider' ) as LateContextProviderElement; - consumer = container.querySelector( + const consumer = container.querySelector( 'context-consumer' ) as ContextConsumerElement; await consumer.updateComplete; - assert.isDefined(consumer); - }); - - teardown(() => { - document.body.removeChild(container); - }); - - test(`handles late upgrade properly`, async () => { - // initially consumer has initial value + // Initially consumer has initial value assert.strictEqual(consumer.value, 0); assert.strictEqual(consumer.onceValue, 0); - // do upgrade + + // Define provider element customElements.define('late-context-provider', LateContextProviderElement); - // await update of provider component + await provider.updateComplete; - // await update of consumer component await consumer.updateComplete; - // should now have provided context + + // `value` should now be provided assert.strictEqual(consumer.value, 1000); + // but only to the subscribed value assert.strictEqual(consumer.onceValue, 0); - // confirm subscription is established + + // Confirm subscription is established provider.value = 500; await consumer.updateComplete; assert.strictEqual(consumer.value, 500); + // and once was not updated assert.strictEqual(consumer.onceValue, 0); }); + + test('lazy added provider', async () => { + container.innerHTML = ` + + + + `; + + const provider = container.querySelector( + 'lazy-context-provider' + ) as LazyContextProviderElement; + + const consumer = container.querySelector( + 'context-consumer' + ) as ContextConsumerElement; + + await consumer.updateComplete; + + // Add a provider after the elements are setup + new ContextProvider(provider, simpleContext, 1000); + + await provider.updateComplete; + await consumer.updateComplete; + + // `value` should now be provided + assert.strictEqual(consumer.value, 1000); + }); }); diff --git a/packages/labs/context/src/test/provider-and-consumer_test.ts b/packages/labs/context/src/test/provider-and-consumer_test.ts index 7340c2a656..e3d7e254ba 100644 --- a/packages/labs/context/src/test/provider-and-consumer_test.ts +++ b/packages/labs/context/src/test/provider-and-consumer_test.ts @@ -7,17 +7,17 @@ import {LitElement, html, TemplateResult} from 'lit'; import {property} from 'lit/decorators/property.js'; -import {ContextKey, contextProvided, contextProvider} from '@lit-labs/context'; +import {Context, consume, provide} from '@lit-labs/context'; import {assert} from '@esm-bundle/chai'; -const simpleContext = 'simple-context' as ContextKey<'simple-context', number>; +const simpleContext = 'simple-context' as Context<'simple-context', number>; class ContextConsumerAndProviderElement extends LitElement { - @contextProvided({context: simpleContext, subscribe: true}) + @consume({context: simpleContext, subscribe: true}) @property({type: Number}) public provided = 0; - @contextProvider({context: simpleContext}) + @provide({context: simpleContext}) @property({type: Number}) public value = 0; diff --git a/packages/labs/context/src/test/provider-consumer_test.ts b/packages/labs/context/src/test/provider-consumer_test.ts index 3fb2ba4f4c..791344565d 100644 --- a/packages/labs/context/src/test/provider-consumer_test.ts +++ b/packages/labs/context/src/test/provider-consumer_test.ts @@ -7,10 +7,10 @@ import {LitElement, html, TemplateResult} from 'lit'; import {property} from 'lit/decorators/property.js'; -import {ContextProvider, ContextKey, ContextConsumer} from '@lit-labs/context'; +import {ContextProvider, Context, ContextConsumer} from '@lit-labs/context'; import {assert} from '@esm-bundle/chai'; -const simpleContext = 'simple-context' as ContextKey<'simple-context', number>; +const simpleContext = 'simple-context' as Context<'simple-context', number>; class SimpleContextProvider extends LitElement { private provider = new ContextProvider(this, simpleContext, 1000); diff --git a/packages/labs/gen-manifest/.gitignore b/packages/labs/gen-manifest/.gitignore new file mode 100644 index 0000000000..d62eb03630 --- /dev/null +++ b/packages/labs/gen-manifest/.gitignore @@ -0,0 +1,6 @@ +/index.js +/index.js.map +/index.d.ts +/index.d.ts.map +/test/ +/gen-output/ diff --git a/packages/labs/gen-manifest/CHANGELOG.md b/packages/labs/gen-manifest/CHANGELOG.md new file mode 100644 index 0000000000..5d1c0490c8 --- /dev/null +++ b/packages/labs/gen-manifest/CHANGELOG.md @@ -0,0 +1,10 @@ +# @lit-labs/gen-manifest + +## 0.0.2 + +### Patch Changes + +- [#2990](https://github.com/lit/lit/pull/2990) [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771) - Added initial implementation of custom elements manifest generator (WIP). + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 diff --git a/packages/labs/gen-manifest/README.md b/packages/labs/gen-manifest/README.md new file mode 100644 index 0000000000..819c588a9d --- /dev/null +++ b/packages/labs/gen-manifest/README.md @@ -0,0 +1,5 @@ +# @lit-labs/gen-manifest + +Utility library for generating a [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest) for Lit components. + +For a command-line interface to using the generator, see `@lit-labs/cli`. diff --git a/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json new file mode 100644 index 0000000000..8d94d86acc --- /dev/null +++ b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json @@ -0,0 +1,477 @@ +{ + "schemaVersion": "1.0.0", + "modules": [ + { + "kind": "javascript-module", + "path": "detail-type.js", + "summary": "TODO", + "description": "TODO", + "declarations": [], + "exports": [], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "element-a.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "ElementA", + "description": "This is a description of my element. It's pretty great. The description has\ntext that spans multiple lines.", + "summary": "My awesome element", + "superclass": {"name": "LitElement", "package": "lit"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false, + "tagName": "element-a", + "attributes": [], + "events": [{"name": "a-changed", "type": {"text": "TODO"}}], + "slots": [ + {"name": "default", "summary": "The default slot"}, + {"name": "stuff", "summary": "A slot for stuff"} + ], + "cssParts": [ + {"name": "header", "summary": "The header"}, + {"name": "footer", "summary": "The footer"} + ], + "cssProperties": [ + {"name": "--foreground-color", "summary": "The foreground color"}, + {"name": "--background-color", "summary": "The background color"} + ], + "demos": [], + "customElement": true + }, + { + "kind": "variable", + "name": "localTypeVar", + "summary": "TODO", + "description": "TODO", + "type": { + "text": "ElementA", + "references": [ + { + "name": "ElementA", + "package": "@lit-internal/test-element-a", + "module": "element-a.js", + "start": 0, + "end": 8 + } + ] + } + }, + { + "kind": "variable", + "name": "packageTypeVar", + "summary": "TODO", + "description": "TODO", + "type": { + "text": "Foo", + "references": [ + { + "name": "Foo", + "package": "@lit-internal/test-element-a", + "module": "package-stuff.js", + "start": 0, + "end": 3 + }, + { + "name": "Bar", + "package": "@lit-internal/test-element-a", + "module": "package-stuff.js", + "start": 4, + "end": 7 + } + ] + } + }, + { + "kind": "variable", + "name": "externalTypeVar", + "summary": "TODO", + "description": "TODO", + "type": { + "text": "LitElement", + "references": [ + {"name": "LitElement", "package": "lit", "start": 0, "end": 10} + ] + } + }, + { + "kind": "variable", + "name": "globalTypeVar", + "summary": "TODO", + "description": "TODO", + "type": { + "text": "HTMLElement", + "references": [ + { + "name": "HTMLElement", + "package": "global:", + "start": 0, + "end": 11 + } + ] + } + } + ], + "exports": [ + {"kind": "js", "name": "ElementA", "declaration": {"name": "ElementA"}}, + { + "kind": "js", + "name": "localTypeVar", + "declaration": {"name": "localTypeVar"} + }, + { + "kind": "js", + "name": "packageTypeVar", + "declaration": {"name": "packageTypeVar"} + }, + { + "kind": "js", + "name": "externalTypeVar", + "declaration": {"name": "externalTypeVar"} + }, + { + "kind": "js", + "name": "globalTypeVar", + "declaration": {"name": "globalTypeVar"} + }, + { + "kind": "js", + "name": "Foo", + "declaration": { + "name": "Foo", + "package": "@lit-internal/test-element-a", + "module": "package-stuff.js" + } + }, + { + "kind": "js", + "name": "Baz", + "declaration": { + "name": "Bar", + "package": "@lit-internal/test-element-a", + "module": "package-stuff.js" + } + }, + { + "kind": "js", + "name": "local", + "declaration": {"name": "localTypeVar"} + }, + { + "kind": "custom-element-definition", + "name": "element-a", + "declaration": {"name": "ElementA"} + } + ], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "element-events.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "EventSubclass", + "superclass": {"name": "Event", "package": "global:"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false + }, + { + "kind": "class", + "name": "ElementEvents", + "description": "My awesome element", + "superclass": {"name": "LitElement", "package": "lit"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false, + "tagName": "element-events", + "attributes": [], + "events": [ + { + "name": "string-custom-event", + "type": { + "text": "CustomEvent", + "references": [ + { + "name": "CustomEvent", + "package": "global:", + "start": 0, + "end": 11 + } + ] + } + }, + { + "name": "number-custom-event", + "type": { + "text": "CustomEvent", + "references": [ + { + "name": "CustomEvent", + "package": "global:", + "start": 0, + "end": 11 + } + ] + } + }, + { + "name": "my-detail-custom-event", + "type": { + "text": "CustomEvent", + "references": [ + { + "name": "CustomEvent", + "package": "global:", + "start": 0, + "end": 11 + }, + { + "name": "MyDetail", + "package": "@lit-internal/test-element-a", + "module": "detail-type.js", + "start": 12, + "end": 20 + } + ] + } + }, + { + "name": "event-subclass", + "type": { + "text": "EventSubclass", + "references": [ + { + "name": "EventSubclass", + "package": "@lit-internal/test-element-a", + "module": "element-events.js", + "start": 0, + "end": 13 + } + ] + } + }, + { + "name": "special-event", + "type": { + "text": "SpecialEvent", + "references": [ + { + "name": "SpecialEvent", + "package": "@lit-internal/test-element-a", + "module": "special-event.js", + "start": 0, + "end": 12 + } + ] + } + }, + { + "name": "template-result-custom-event", + "type": { + "text": "CustomEvent", + "references": [ + { + "name": "CustomEvent", + "package": "global:", + "start": 0, + "end": 11 + }, + { + "name": "TemplateResult", + "package": "lit", + "start": 12, + "end": 26 + } + ] + } + } + ], + "slots": [], + "cssParts": [], + "cssProperties": [], + "demos": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "SpecialEvent", + "declaration": { + "name": "SpecialEvent", + "package": "@lit-internal/test-element-a", + "module": "special-event.js" + } + }, + { + "kind": "js", + "name": "MyDetail", + "declaration": { + "name": "MyDetail", + "package": "@lit-internal/test-element-a", + "module": "detail-type.js" + } + }, + { + "kind": "js", + "name": "EventSubclass", + "declaration": {"name": "EventSubclass"} + }, + { + "kind": "js", + "name": "ElementEvents", + "declaration": {"name": "ElementEvents"} + }, + { + "kind": "custom-element-definition", + "name": "element-events", + "declaration": {"name": "ElementEvents"} + } + ], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "element-props.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "ElementProps", + "description": "My awesome element", + "superclass": {"name": "LitElement", "package": "lit"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false, + "tagName": "element-props", + "attributes": [], + "events": [{"name": "a-changed", "type": {"text": "TODO"}}], + "slots": [], + "cssParts": [], + "cssProperties": [], + "demos": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ElementProps", + "declaration": {"name": "ElementProps"} + }, + { + "kind": "custom-element-definition", + "name": "element-props", + "declaration": {"name": "ElementProps"} + } + ], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "element-slots.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "ElementSlots", + "description": "My awesome element", + "superclass": {"name": "LitElement", "package": "lit"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false, + "tagName": "element-slots", + "attributes": [], + "events": [], + "slots": [], + "cssParts": [], + "cssProperties": [], + "demos": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ElementSlots", + "declaration": {"name": "ElementSlots"} + }, + { + "kind": "custom-element-definition", + "name": "element-slots", + "declaration": {"name": "ElementSlots"} + } + ], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "package-stuff.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "Bar", + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false + }, + { + "kind": "class", + "name": "Foo", + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false + } + ], + "exports": [ + {"kind": "js", "name": "Bar", "declaration": {"name": "Bar"}}, + {"kind": "js", "name": "Foo", "declaration": {"name": "Foo"}} + ], + "deprecated": false + }, + { + "kind": "javascript-module", + "path": "special-event.js", + "summary": "TODO", + "description": "TODO", + "declarations": [ + { + "kind": "class", + "name": "SpecialEvent", + "superclass": {"name": "Event", "package": "global:"}, + "mixins": [], + "members": [], + "source": {"href": "TODO"}, + "deprecated": false + } + ], + "exports": [ + { + "kind": "js", + "name": "SpecialEvent", + "declaration": {"name": "SpecialEvent"} + } + ], + "deprecated": false + } + ] +} diff --git a/packages/labs/gen-manifest/package.json b/packages/labs/gen-manifest/package.json new file mode 100644 index 0000000000..cd804128ba --- /dev/null +++ b/packages/labs/gen-manifest/package.json @@ -0,0 +1,78 @@ +{ + "private": true, + "name": "@lit-labs/gen-manifest", + "description": "Code generator for generating Custom Elements Manifests for Lit components", + "version": "0.0.2", + "author": "Google LLC", + "license": "BSD-3-Clause", + "bugs": "https://github.com/lit/lit/issues", + "type": "module", + "main": "lib/index.js", + "repository": "lit/lit", + "scripts": { + "build": "wireit", + "test": "wireit", + "test:update-goldens": "UPDATE_TEST_GOLDENS=true npm run test" + }, + "wireit": { + "build": { + "command": "tsc --build --pretty", + "dependencies": [ + "../../tests:build", + "../analyzer:build", + "../gen-utils:build" + ], + "files": [ + "src/**/*", + "tsconfig.json", + "../test-projects/test-element-a" + ], + "output": [ + "test", + "index.{js,js.map,d.ts,d.ts.map}", + "tsconfig.tsbuildinfo" + ], + "clean": "if-file-deleted" + }, + "test": { + "dependencies": [ + "build" + ], + "files": [ + "goldens/**/*", + "../test-projects/test-element-a" + ], + "#comment": "The quotes around the file regex must be double quotes on windows!", + "command": "cross-env NODE_OPTIONS=--enable-source-maps uvu test \"_test\\.js$\"", + "output": [] + } + }, + "dependencies": { + "@lit-labs/analyzer": "^0.4.0", + "@lit-labs/gen-utils": "^0.1.0", + "custom-elements-manifest": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^17.0.31", + "@lit-internal/tests": "^0.0.0", + "uvu": "^0.5.3" + }, + "engines": { + "node": ">=14.8.0" + }, + "files": [ + "index.js" + ], + "homepage": "https://github.com/lit/lit", + "keywords": [ + "lit", + "lit-html", + "lit-element", + "LitElement", + "generator", + "cli", + "manifest", + "custom-elements-manifest", + "CEM" + ] +} diff --git a/packages/labs/gen-manifest/src/index.ts b/packages/labs/gen-manifest/src/index.ts new file mode 100644 index 0000000000..dd7e61ccb4 --- /dev/null +++ b/packages/labs/gen-manifest/src/index.ts @@ -0,0 +1,220 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { + ClassDeclaration, + Declaration, + Event, + LitElementDeclaration, + Module, + Package, + Reference, + Type, + VariableDeclaration, + LitElementExport, +} from '@lit-labs/analyzer'; +import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import type * as cem from 'custom-elements-manifest/schema'; + +const ifDefined = (model: O, name: K) => { + const obj: Partial> = {}; + if (model[name] !== undefined) { + obj[name] = model[name]; + } + return obj; +}; + +/** + * Our command for the Lit CLI. + * + * See ../../cli/src/lib/generate/generate.ts + */ +export const getCommand = () => { + return { + name: 'manifest', + description: 'Generate custom-elements.json manifest.', + kind: 'resolved', + async generate(options: {package: Package}): Promise { + return await generateManifest(options.package); + }, + }; +}; + +export const generateManifest = async ( + analysis: Package +): Promise => { + return { + 'custom-elements.json': JSON.stringify(convertPackage(analysis)), + }; +}; + +const convertPackage = (pkg: Package): cem.Package => { + return { + schemaVersion: '1.0.0', + modules: [...pkg.modules.map(convertModule)], + }; +}; + +const convertModule = (module: Module): cem.Module => { + return { + kind: 'javascript-module', + path: module.jsPath, + summary: 'TODO', // TODO + description: 'TODO', // TODO + declarations: [...module.declarations.map(convertDeclaration)], + exports: [ + ...module.exportNames.map((name) => + convertJavascriptExport(name, module.getExportReference(name)) + ), + ...module.getCustomElementExports().map(convertCustomElementExport), + ], + deprecated: false, // TODO + }; +}; + +const convertDeclaration = (declaration: Declaration): cem.Declaration => { + if (declaration.isLitElementDeclaration()) { + return convertLitElementDeclaration(declaration); + } else if (declaration.isClassDeclaration()) { + return convertClassDeclaration(declaration); + } else if (declaration.isVariableDeclaration()) { + return convertVariableDeclaration(declaration); + } else { + // TODO: FunctionDeclaration + // TODO: MixinDeclaration + // TODO: CustomElementMixinDeclaration; + throw new Error( + `Unknown declaration: ${(declaration as Object).constructor.name}` + ); + } +}; + +const convertJavascriptExport = ( + name: string, + reference: Reference +): cem.JavaScriptExport => { + return { + kind: 'js', + name, + declaration: convertReference(reference), + }; +}; + +const convertCustomElementExport = ( + declaration: LitElementExport +): cem.CustomElementExport => { + return { + kind: 'custom-element-definition', + name: declaration.tagname, + declaration: { + name: declaration.name, + }, + }; +}; + +const convertLitElementDeclaration = ( + declaration: LitElementDeclaration +): cem.CustomElementDeclaration => { + return { + ...convertClassDeclaration(declaration), + tagName: declaration.tagname, + attributes: [ + // TODO + ], + events: Array.from(declaration.events.values()).map(convertEvent), + slots: Array.from(declaration.slots.values()), + cssParts: Array.from(declaration.cssParts.values()), + cssProperties: Array.from(declaration.cssProperties.values()), + demos: [ + // TODO + ], + customElement: true, + }; +}; + +const convertClassDeclaration = ( + declaration: ClassDeclaration +): cem.ClassDeclaration => { + const {superClass} = declaration.heritage; + return { + kind: 'class', + name: declaration.name!, // TODO(kschaaf) name isn't optional in CEM + ...ifDefined(declaration, 'description'), + ...ifDefined(declaration, 'summary'), + superclass: superClass ? convertReference(superClass) : undefined, + mixins: [], // TODO + members: [ + // TODO: ClassField + // TODO: ClassMethod + ], + source: {href: 'TODO'}, // TODO + deprecated: false, // TODO + }; +}; + +const convertVariableDeclaration = ( + declaration: VariableDeclaration +): cem.VariableDeclaration => { + return { + kind: 'variable', + name: declaration.name, + summary: 'TODO', // TODO + description: 'TODO', // TODO + type: declaration.type ? convertType(declaration.type) : {text: 'TODO'}, // TODO(kschaaf) type isn't optional in CEM + }; +}; + +const convertEvent = (event: Event): cem.Event => { + return { + name: event.name, + type: event.type ? convertType(event.type) : {text: 'TODO'}, // TODO(kschaaf) type isn't optional in CEM + }; +}; + +const convertType = (type: Type): cem.Type => { + return { + text: type.text, + references: convertTypeReference(type.text, type.references), + }; +}; + +const convertTypeReference = ( + text: string, + references: Reference[] +): cem.TypeReference[] => { + const cemReferences: cem.TypeReference[] = []; + let curr = 0; + for (const ref of references) { + const start = text.indexOf(ref.name, curr); + if (start < 0) { + throw new Error( + `Could not find type reference '${ref.name}' in type '${text}'` + ); + } + curr = start + ref.name.length; + cemReferences.push({ + ...convertReference(ref), + start, + end: curr, + }); + } + return cemReferences; +}; + +const convertReference = (reference: Reference): cem.TypeReference => { + const refObj: cem.TypeReference = { + name: reference.name, + }; + if (reference.isGlobal) { + refObj.package = 'global:'; + } else if (reference.package !== undefined) { + refObj.package = reference.package; + } + if (reference.module !== undefined) { + refObj.module = reference.module; + } + return refObj; +}; diff --git a/packages/labs/gen-manifest/src/test/generate_test.ts b/packages/labs/gen-manifest/src/test/generate_test.ts new file mode 100644 index 0000000000..20ae0c4ea3 --- /dev/null +++ b/packages/labs/gen-manifest/src/test/generate_test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {test} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as fs from 'fs'; +import * as path from 'path'; +import {AbsolutePath, createPackageAnalyzer} from '@lit-labs/analyzer'; +import {writeFileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; +import {generateManifest} from '../index.js'; +import {assertGoldensMatch} from '@lit-internal/tests/utils/assert-goldens.js'; + +const testProjects = '../test-projects'; +const outputFolder = 'gen-output'; + +test('basic manifest generation', async () => { + const project = 'test-element-a'; + const inputPackage = path.resolve(testProjects, project); + + if (fs.existsSync(outputFolder)) { + fs.rmSync(outputFolder, {recursive: true}); + } + + const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); + const pkg = analyzer.getPackage(); + await writeFileTree(outputFolder, await generateManifest(pkg)); + + await assertGoldensMatch(outputFolder, path.join('goldens', project), { + formatGlob: '**/*.json', + }); +}); + +test.run(); diff --git a/packages/labs/gen-manifest/tsconfig.json b/packages/labs/gen-manifest/tsconfig.json new file mode 100644 index 0000000000..58bfedd3d8 --- /dev/null +++ b/packages/labs/gen-manifest/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "lib": ["es2020"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "preserveConstEnums": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "src/", + "outDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": [] +} diff --git a/packages/labs/gen-utils/CHANGELOG.md b/packages/labs/gen-utils/CHANGELOG.md index 24034edd84..48fff5299d 100644 --- a/packages/labs/gen-utils/CHANGELOG.md +++ b/packages/labs/gen-utils/CHANGELOG.md @@ -1,5 +1,19 @@ # @lit-labs/gen-utils +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a), [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e), [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9)]: + - @lit-labs/analyzer@0.3.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/labs/gen-utils/package.json b/packages/labs/gen-utils/package.json index 68f216e183..bf4c1c6e51 100644 --- a/packages/labs/gen-utils/package.json +++ b/packages/labs/gen-utils/package.json @@ -1,7 +1,7 @@ { "name": "@lit-labs/gen-utils", "description": "Utilities for lit code generators", - "version": "0.1.0", + "version": "0.1.2", "publishConfig": { "access": "public" }, @@ -46,7 +46,7 @@ } }, "dependencies": { - "@lit-labs/analyzer": "^0.2.0" + "@lit-labs/analyzer": "^0.4.0" }, "devDependencies": { "@lit-internal/tests": "^0.0.0" diff --git a/packages/labs/gen-utils/src/lib/str-utils.ts b/packages/labs/gen-utils/src/lib/str-utils.ts index 4fe219c496..e8da435358 100644 --- a/packages/labs/gen-utils/src/lib/str-utils.ts +++ b/packages/labs/gen-utils/src/lib/str-utils.ts @@ -21,6 +21,12 @@ const concat = (strings: TemplateStringsArray, ...values: unknown[]) => */ export const javascript = concat; +/** + * Use https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html + * for HTML syntax highlighting + */ +export const html = concat; + /** * Converts string to initial cap. */ diff --git a/packages/labs/gen-utils/src/test/package-utils_test.ts b/packages/labs/gen-utils/src/test/package-utils_test.ts index a77f8a25d9..6143e3adf9 100644 --- a/packages/labs/gen-utils/src/test/package-utils_test.ts +++ b/packages/labs/gen-utils/src/test/package-utils_test.ts @@ -42,14 +42,36 @@ test('install package with monorepo link', async ({tempFs}) => { await tempFs.write('package.json', { dependencies: { lit: '^2.0.0', + 'lit-html': '^2.0.0', + 'lit-element': '^3.0.0', + '@lit/reactive-element': '^1.0.0', }, }); await installPackage(tempFs.rootDir, { lit: '../../lit', + 'lit-html': '../../lit-html', + 'lit-element': '../../lit-element', + '@lit/reactive-element': '../../reactive-element', }); assert.ok((await tempFs.read('node_modules', 'lit', 'index.js')).length > 0); + assert.ok( + (await tempFs.read('node_modules', 'lit-html', 'lit-html.js')).length > 0 + ); + assert.ok( + (await tempFs.read('node_modules', 'lit-element', 'index.js')).length > 0 + ); + assert.ok( + ( + await tempFs.read( + 'node_modules', + '@lit', + 'reactive-element', + 'reactive-element.js' + ) + ).length > 0 + ); }); test('build package', async ({tempFs}) => { diff --git a/packages/labs/gen-wrapper-angular/CHANGELOG.md b/packages/labs/gen-wrapper-angular/CHANGELOG.md index f372b28b15..b295c8d8a8 100644 --- a/packages/labs/gen-wrapper-angular/CHANGELOG.md +++ b/packages/labs/gen-wrapper-angular/CHANGELOG.md @@ -1,5 +1,21 @@ # @lit-labs/gen-wrapper-angular +## 0.0.3 + +### Patch Changes + +- [#3384](https://github.com/lit/lit/pull/3384) [`9f802646`](https://github.com/lit/lit/commit/9f802646d955198cbaf6e521283fe137e7f5b7a6) - Updates generated wrappers to better support types for properties and events, tested via a suite of test elements. + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 + +## 0.0.2 + +### Patch Changes + +- Updated dependencies [[`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a), [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e), [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9)]: + - @lit-labs/analyzer@0.3.0 + ## 0.0.1 ### Patch Changes diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/.gitignore b/packages/labs/gen-wrapper-angular/goldens/test-element-a/.gitignore index ea02704223..0e55a4408e 100644 --- a/packages/labs/gen-wrapper-angular/goldens/test-element-a/.gitignore +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/.gitignore @@ -1 +1,4 @@ -element-a.js \ No newline at end of file +element-a.js +element-events.js +element-props.js +element-slots.js \ No newline at end of file diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/package.json b/packages/labs/gen-wrapper-angular/goldens/test-element-a/package.json index 424e62fdad..ddf6d31f57 100644 --- a/packages/labs/gen-wrapper-angular/goldens/test-element-a/package.json +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/package.json @@ -17,6 +17,9 @@ "typescript": "~4.7.4" }, "files": [ - "element-a.js" + "element-a.js", + "element-events.js", + "element-props.js", + "element-slots.js" ] } diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-a.ts b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-a.ts index a52a8df9c3..d339c78d9e 100644 --- a/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-a.ts +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-a.ts @@ -1,11 +1,12 @@ import { Component, ElementRef, - EventEmitter, - Input, NgZone, + Input, + EventEmitter, Output, } from '@angular/core'; + import type {ElementA as ElementAElement} from '@lit-internal/test-element-a/element-a.js'; import '@lit-internal/test-element-a/element-a.js'; diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-events.ts b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-events.ts new file mode 100644 index 0000000000..faa11b9f0a --- /dev/null +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-events.ts @@ -0,0 +1,88 @@ +import { + Component, + ElementRef, + NgZone, + Input, + EventEmitter, + Output, +} from '@angular/core'; + +import type {ElementEvents as ElementEventsElement} from '@lit-internal/test-element-a/element-events.js'; +import '@lit-internal/test-element-a/element-events.js'; + +@Component({ + selector: 'element-events', + template: '', +}) +export class ElementEvents { + private _el: ElementEventsElement; + private _ngZone: NgZone; + + constructor(e: ElementRef, ngZone: NgZone) { + this._el = e.nativeElement; + this._ngZone = ngZone; + + this._el.addEventListener('string-custom-event', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.stringCustomEventEvent.emit(e); + }); + + this._el.addEventListener('number-custom-event', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.numberCustomEventEvent.emit(e); + }); + + this._el.addEventListener('my-detail-custom-event', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.myDetailCustomEventEvent.emit(e); + }); + + this._el.addEventListener('event-subclass', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.eventSubclassEvent.emit(e); + }); + + this._el.addEventListener('special-event', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.specialEventEvent.emit(e); + }); + + this._el.addEventListener('template-result-custom-event', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.templateResultCustomEventEvent.emit(e); + }); + } + + @Input() + set foo(v: string | undefined) { + this._ngZone.runOutsideAngular(() => (this._el.foo = v)); + } + + get foo() { + return this._el.foo; + } + + @Output() + stringCustomEventEvent = new EventEmitter(); + + @Output() + numberCustomEventEvent = new EventEmitter(); + + @Output() + myDetailCustomEventEvent = new EventEmitter(); + + @Output() + eventSubclassEvent = new EventEmitter(); + + @Output() + specialEventEvent = new EventEmitter(); + + @Output() + templateResultCustomEventEvent = new EventEmitter(); +} diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-props.ts b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-props.ts new file mode 100644 index 0000000000..61abb35bf0 --- /dev/null +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-props.ts @@ -0,0 +1,80 @@ +import { + Component, + ElementRef, + NgZone, + Input, + EventEmitter, + Output, +} from '@angular/core'; +import {MyType} from '@lit-internal/test-element-a/element-props.js'; +export type {MyType} from '@lit-internal/test-element-a/element-props.js'; +import type {ElementProps as ElementPropsElement} from '@lit-internal/test-element-a/element-props.js'; +import '@lit-internal/test-element-a/element-props.js'; + +@Component({ + selector: 'element-props', + template: '', +}) +export class ElementProps { + private _el: ElementPropsElement; + private _ngZone: NgZone; + + constructor(e: ElementRef, ngZone: NgZone) { + this._el = e.nativeElement; + this._ngZone = ngZone; + + this._el.addEventListener('a-changed', (e: Event) => { + // TODO(justinfagnani): we need to let the element say how to get a value + // from an event, ex: e.value + this.aChangedEvent.emit(e); + }); + } + + @Input() + set aStr(v: string) { + this._ngZone.runOutsideAngular(() => (this._el.aStr = v)); + } + + get aStr() { + return this._el.aStr; + } + + @Input() + set aNum(v: number) { + this._ngZone.runOutsideAngular(() => (this._el.aNum = v)); + } + + get aNum() { + return this._el.aNum; + } + + @Input() + set aBool(v: boolean) { + this._ngZone.runOutsideAngular(() => (this._el.aBool = v)); + } + + get aBool() { + return this._el.aBool; + } + + @Input() + set aStrArray(v: string[]) { + this._ngZone.runOutsideAngular(() => (this._el.aStrArray = v)); + } + + get aStrArray() { + return this._el.aStrArray; + } + + @Input() + set aMyType(v: MyType) { + this._ngZone.runOutsideAngular(() => (this._el.aMyType = v)); + } + + get aMyType() { + return this._el.aMyType; + } + + @Output() + aChangedEvent = new EventEmitter(); +} diff --git a/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-slots.ts b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-slots.ts new file mode 100644 index 0000000000..36dc5648fb --- /dev/null +++ b/packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-slots.ts @@ -0,0 +1,27 @@ +import {Component, ElementRef, NgZone, Input} from '@angular/core'; + +import type {ElementSlots as ElementSlotsElement} from '@lit-internal/test-element-a/element-slots.js'; +import '@lit-internal/test-element-a/element-slots.js'; + +@Component({ + selector: 'element-slots', + template: '', +}) +export class ElementSlots { + private _el: ElementSlotsElement; + private _ngZone: NgZone; + + constructor(e: ElementRef, ngZone: NgZone) { + this._el = e.nativeElement; + this._ngZone = ngZone; + } + + @Input() + set mainDefault(v: string) { + this._ngZone.runOutsideAngular(() => (this._el.mainDefault = v)); + } + + get mainDefault() { + return this._el.mainDefault; + } +} diff --git a/packages/labs/gen-wrapper-angular/package.json b/packages/labs/gen-wrapper-angular/package.json index 5f36dda2d7..027829fb07 100644 --- a/packages/labs/gen-wrapper-angular/package.json +++ b/packages/labs/gen-wrapper-angular/package.json @@ -2,7 +2,7 @@ "private": true, "name": "@lit-labs/gen-wrapper-angular", "description": "Code generator for Angular wrappers for Lit components", - "version": "0.0.1", + "version": "0.0.3", "publishConfig": { "access": "public" }, @@ -15,7 +15,8 @@ "scripts": { "build": "wireit", "test": "wireit", - "test:gen": "wireit" + "test:gen": "wireit", + "test:update-goldens": "UPDATE_TEST_GOLDENS=true npm run test:gen" }, "wireit": { "build": { @@ -43,18 +44,20 @@ }, "test:gen": { "command": "uvu test \".*_test\\.js$\"", - "files": [], + "files": [ + "goldens/**/*" + ], "output": [ "gen-output" ], "dependencies": [ "build", - "../test-projects/test-element-a:build" + "../test-projects/test-element-a:pack" ] } }, "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0" }, "devDependencies": { diff --git a/packages/labs/gen-wrapper-angular/src/lib/wrapper-module-template.ts b/packages/labs/gen-wrapper-angular/src/lib/wrapper-module-template.ts index d328c1c4eb..1fa70f68be 100644 --- a/packages/labs/gen-wrapper-angular/src/lib/wrapper-module-template.ts +++ b/packages/labs/gen-wrapper-angular/src/lib/wrapper-module-template.ts @@ -7,22 +7,56 @@ import { LitElementDeclaration, PackageJson, + getImportsStringForReferences, +} from '@lit-labs/analyzer'; +import { + ReactiveProperty as ModelProperty, + Event as EventModel, + Reference, } from '@lit-labs/analyzer/lib/model.js'; import {javascript} from '@lit-labs/gen-utils/lib/str-utils.js'; +const getTypeReferencesForMap = ( + map: Map +) => Array.from(map.values()).flatMap((e) => e.type?.references ?? []); + +const getElementTypeImports = (declarations: LitElementDeclaration[]) => { + const refs: Reference[] = []; + declarations.forEach((declaration) => { + const {/*events,*/ reactiveProperties} = declaration; + refs.push( + // TODO(sorvell): Add event types. + //...getTypeReferencesForMap(events), + ...getTypeReferencesForMap(reactiveProperties) + ); + }); + return getImportsStringForReferences(refs); +}; + +// TODO(sorvell): add support for getting exports in analyzer. +const getElementTypeExportsFromImports = (imports: string) => + imports.replace(/(?:^import)/gm, 'export type'); + export const wrapperModuleTemplate = ( packageJson: PackageJson, moduleJsPath: string, elements: LitElementDeclaration[] ) => { + const imports = [`Component`, `ElementRef`, `NgZone`]; + if (elements.filter((e) => e.reactiveProperties.size).length > 0) { + imports.push(`Input`); + } + if (elements.filter((e) => e.events.size).length > 0) { + imports.push(`EventEmitter`, `Output`); + } + const typeImports = getElementTypeImports(elements); + const typeExports = getElementTypeExportsFromImports(typeImports); return javascript`import { - Component, - ElementRef, - EventEmitter, - Input, - NgZone, - Output, + ${imports.join(',\n ')} + } from '@angular/core'; +${typeImports} +${typeExports} ${elements.map( ( element @@ -61,7 +95,7 @@ export class ${name} { ${Array.from(reactiveProperties.entries()).map( ([propertyName, property]) => javascript` @Input() - set ${propertyName}(v: ${property.type.text}) { + set ${propertyName}(v: ${property.type?.text ?? 'any'}) { this._ngZone.runOutsideAngular(() => (this._el.${propertyName} = v)); } diff --git a/packages/labs/gen-wrapper-angular/src/test/generation/generate_test.ts b/packages/labs/gen-wrapper-angular/src/test/generation/generate_test.ts index c84e08ce32..63e9e3dcd1 100644 --- a/packages/labs/gen-wrapper-angular/src/test/generation/generate_test.ts +++ b/packages/labs/gen-wrapper-angular/src/test/generation/generate_test.ts @@ -9,7 +9,7 @@ import {test} from 'uvu'; import * as assert from 'uvu/assert'; import * as fs from 'fs'; import * as path from 'path'; -import {Analyzer} from '@lit-labs/analyzer'; +import {createPackageAnalyzer} from '@lit-labs/analyzer'; import {AbsolutePath} from '@lit-labs/analyzer/lib/paths.js'; import { installPackage, @@ -32,9 +32,9 @@ test('basic wrapper generation', async () => { fs.rmSync(outputPackage, {recursive: true}); } - const analyzer = new Analyzer(inputPackage as AbsolutePath); - const analysis = analyzer.analyzePackage(); - await writeFileTree(outputFolder, await generateAngularWrapper(analysis)); + const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); + const pkg = analyzer.getPackage(); + await writeFileTree(outputFolder, await generateAngularWrapper(pkg)); const wrapperSourceFile = fs.readFileSync( path.join(outputPackage, 'src', 'element-a.ts') diff --git a/packages/labs/gen-wrapper-react/CHANGELOG.md b/packages/labs/gen-wrapper-react/CHANGELOG.md index 7ccab11caa..46f74431f9 100644 --- a/packages/labs/gen-wrapper-react/CHANGELOG.md +++ b/packages/labs/gen-wrapper-react/CHANGELOG.md @@ -1,5 +1,31 @@ # @lit-labs/gen-wrapper-react +## 0.2.1 + +### Patch Changes + +- [#3384](https://github.com/lit/lit/pull/3384) [`9f802646`](https://github.com/lit/lit/commit/9f802646d955198cbaf6e521283fe137e7f5b7a6) - Updates generated wrappers to better support types for properties and events, tested via a suite of test elements. + +- [#3377](https://github.com/lit/lit/pull/3377) [`0af4e79b`](https://github.com/lit/lit/commit/0af4e79b51d34d959488ceae4caa2240a76c15e0) - Adds type info for props/events for Vue/React wrappers. Vue wrapper properly handles defaults. + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 + +## 0.2.0 + +### Minor Changes + +- [#3288](https://github.com/lit/lit/pull/3288) [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e) - Refactored Analyzer into better fit for use in plugins. Analyzer class now takes a ts.Program, and PackageAnalyzer takes a package path and creates a program to analyze a package on the filesystem. + +- [#3254](https://github.com/lit/lit/pull/3254) [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9) - Fixes bug where global install of CLI resulted in incompatible use of analyzer between CLI packages. Fixes #3234. + +### Patch Changes + +- [#3310](https://github.com/lit/lit/pull/3310) [`b225bd3a`](https://github.com/lit/lit/commit/b225bd3ae1f03d46119650c997719b86742831fe) - Test-output points to the same dependencies as monorepo. + +- Updated dependencies [[`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a), [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e), [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9)]: + - @lit-labs/analyzer@0.3.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/.gitignore b/packages/labs/gen-wrapper-react/goldens/test-element-a/.gitignore index ea02704223..0e55a4408e 100644 --- a/packages/labs/gen-wrapper-react/goldens/test-element-a/.gitignore +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/.gitignore @@ -1 +1,4 @@ -element-a.js \ No newline at end of file +element-a.js +element-events.js +element-props.js +element-slots.js \ No newline at end of file diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/package.json b/packages/labs/gen-wrapper-react/goldens/test-element-a/package.json index 311395a61a..2554ece72b 100644 --- a/packages/labs/gen-wrapper-react/goldens/test-element-a/package.json +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/package.json @@ -18,6 +18,9 @@ "typescript": "~4.7.4" }, "files": [ - "element-a.{js,js.map,d.ts,d.ts.map}" + "element-a.{js,js.map,d.ts,d.ts.map}", + "element-events.{js,js.map,d.ts,d.ts.map}", + "element-props.{js,js.map,d.ts,d.ts.map}", + "element-slots.{js,js.map,d.ts,d.ts.map}" ] } diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-a.ts b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-a.ts index fc7618c38b..fa648e42b5 100644 --- a/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-a.ts +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-a.ts @@ -1,8 +1,8 @@ import * as React from 'react'; -import {createComponent} from '@lit-labs/react'; +import {createComponent, EventName} from '@lit-labs/react'; import {ElementA as ElementAElement} from '@lit-internal/test-element-a/element-a.js'; export const ElementA = createComponent(React, 'element-a', ElementAElement, { - onAChanged: 'a-changed', + onAChanged: 'a-changed' as EventName>, }); diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-events.ts b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-events.ts new file mode 100644 index 0000000000..7e936a1110 --- /dev/null +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-events.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; +import {createComponent, EventName} from '@lit-labs/react'; + +import {ElementEvents as ElementEventsElement} from '@lit-internal/test-element-a/element-events.js'; +import {MyDetail} from '@lit-internal/test-element-a/detail-type.js'; +import {EventSubclass} from '@lit-internal/test-element-a/element-events.js'; +import {SpecialEvent} from '@lit-internal/test-element-a/special-event.js'; +import {TemplateResult} from 'lit'; +export type {MyDetail} from '@lit-internal/test-element-a/detail-type.js'; +export type {EventSubclass} from '@lit-internal/test-element-a/element-events.js'; +export type {SpecialEvent} from '@lit-internal/test-element-a/special-event.js'; +export type {TemplateResult} from 'lit'; + +export const ElementEvents = createComponent( + React, + 'element-events', + ElementEventsElement, + { + onStringCustomEvent: 'string-custom-event' as EventName< + CustomEvent + >, + onNumberCustomEvent: 'number-custom-event' as EventName< + CustomEvent + >, + onMyDetailCustomEvent: 'my-detail-custom-event' as EventName< + CustomEvent + >, + onEventSubclass: 'event-subclass' as EventName, + onSpecialEvent: 'special-event' as EventName, + onTemplateResultCustomEvent: 'template-result-custom-event' as EventName< + CustomEvent + >, + } +); diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-props.ts b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-props.ts new file mode 100644 index 0000000000..155ecdc87d --- /dev/null +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-props.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import {createComponent, EventName} from '@lit-labs/react'; + +import {ElementProps as ElementPropsElement} from '@lit-internal/test-element-a/element-props.js'; + +export const ElementProps = createComponent( + React, + 'element-props', + ElementPropsElement, + { + onAChanged: 'a-changed' as EventName>, + } +); diff --git a/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-slots.ts b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-slots.ts new file mode 100644 index 0000000000..d7112468a6 --- /dev/null +++ b/packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-slots.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import {createComponent} from '@lit-labs/react'; + +import {ElementSlots as ElementSlotsElement} from '@lit-internal/test-element-a/element-slots.js'; + +export const ElementSlots = createComponent( + React, + 'element-slots', + ElementSlotsElement, + {} +); diff --git a/packages/labs/gen-wrapper-react/package.json b/packages/labs/gen-wrapper-react/package.json index 083371c560..9aa70d027b 100644 --- a/packages/labs/gen-wrapper-react/package.json +++ b/packages/labs/gen-wrapper-react/package.json @@ -1,7 +1,7 @@ { "name": "@lit-labs/gen-wrapper-react", "description": "Code generator for React wrapper for Lit components", - "version": "0.1.0", + "version": "0.2.1", "publishConfig": { "access": "public" }, @@ -60,7 +60,7 @@ } }, "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0" }, "devDependencies": { diff --git a/packages/labs/gen-wrapper-react/src/index.ts b/packages/labs/gen-wrapper-react/src/index.ts index 01a28bf28a..1ae9aabb99 100644 --- a/packages/labs/gen-wrapper-react/src/index.ts +++ b/packages/labs/gen-wrapper-react/src/index.ts @@ -10,7 +10,9 @@ import { PackageJson, LitElementDeclaration, ModuleWithLitElementDeclarations, + getImportsStringForReferences, } from '@lit-labs/analyzer'; +import {Event as EventModel} from '@lit-labs/analyzer/lib/model.js'; import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; import {javascript, kabobToOnEvent} from '@lit-labs/gen-utils/lib/str-utils.js'; @@ -24,8 +26,8 @@ export const getCommand = () => { name: 'react', description: 'Generate React wrapper components from Lit elements', kind: 'resolved', - async generate(options: {analysis: Package}): Promise { - return await generateReactWrapper(options.analysis); + async generate(options: {package: Package}): Promise { + return await generateReactWrapper(options.package); }, }; }; @@ -149,22 +151,41 @@ const tsconfigTemplate = () => { ); }; +const getTypeImports = (declarations: LitElementDeclaration[]) => { + // We only need type imports for events. + const refs = declarations.flatMap(({events}) => + Array.from(events.values()).flatMap((e) => e.type?.references ?? []) + ); + return getImportsStringForReferences(refs); +}; + +// TODO(sorvell): add support for getting exports in analyzer. +const getElementTypeExportsFromImports = (imports: string) => + imports.replace(/(?:^import)/gm, 'export type'); + const wrapperModuleTemplate = ( packageJson: PackageJson, moduleJsPath: string, elements: LitElementDeclaration[] ) => { + const hasEvents = elements.filter(({events}) => events.size).length > 0; + const typeImports = getTypeImports(elements); + const typeExports = getElementTypeExportsFromImports(typeImports); return javascript` -import * as React from 'react'; -import {createComponent} from '@lit-labs/react'; -${elements.map( - (element) => javascript` -import {${element.name} as ${element.name}Element} from '${packageJson.name}/${moduleJsPath}'; -` -)} + import * as React from 'react'; + import {createComponent${ + hasEvents ? `, EventName` : `` + }} from '@lit-labs/react'; + ${elements.map( + (element) => javascript` + import {${element.name} as ${element.name}Element} from '${packageJson.name}/${moduleJsPath}'; + ${typeImports} + ${typeExports} + ` + )} -${elements.map((element) => wrapperTemplate(element))} -`; + ${elements.map((element) => wrapperTemplate(element))} + `; }; // TODO(kschaaf): Should this be configurable? @@ -172,22 +193,19 @@ const packageNameToReactPackageName = (pkgName: string) => `${pkgName}-react`; const wrapperTemplate = ({name, tagname, events}: LitElementDeclaration) => { return javascript` -export const ${name} = createComponent( - React, - '${tagname}', - ${name}Element, - { - ${Array.from(events.keys()).map( - (eventName) => javascript` - ${kabobToOnEvent(eventName)}: '${ - // TODO(kschaaf): add cast to `as EventName` once the - // analyzer reports the event type correctly (currently we have the - // type string without an AST reference to get its import, etc.) - // https://github.com/lit/lit/issues/2850 - eventName - }',` - )} - } -); -`; + export const ${name} = createComponent( + React, + '${tagname}', + ${name}Element, + { + ${Array.from(events.values()).map((event: EventModel) => { + const {name, type} = event; + return javascript` + ${kabobToOnEvent(name)}: '${name}' as EventName<${ + type?.text || `CustomEvent` + }>,`; + })} + } + ); + `; }; diff --git a/packages/labs/gen-wrapper-react/src/test-gen/generate_test.ts b/packages/labs/gen-wrapper-react/src/test-gen/generate_test.ts index b876137b2a..0cff941164 100644 --- a/packages/labs/gen-wrapper-react/src/test-gen/generate_test.ts +++ b/packages/labs/gen-wrapper-react/src/test-gen/generate_test.ts @@ -9,7 +9,7 @@ import {test} from 'uvu'; import * as assert from 'uvu/assert'; import * as fs from 'fs'; import * as path from 'path'; -import {Analyzer} from '@lit-labs/analyzer'; +import {createPackageAnalyzer} from '@lit-labs/analyzer'; import {AbsolutePath} from '@lit-labs/analyzer/lib/paths.js'; import { installPackage, @@ -32,9 +32,9 @@ test('basic wrapper generation', async () => { fs.rmSync(outputPackage, {recursive: true}); } - const analyzer = new Analyzer(inputPackage as AbsolutePath); - const analysis = analyzer.analyzePackage(); - await writeFileTree(outputFolder, await generateReactWrapper(analysis)); + const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); + const pkg = analyzer.getPackage(); + await writeFileTree(outputFolder, await generateReactWrapper(pkg)); const wrapperSourceFile = fs.readFileSync( path.join(outputPackage, 'src/element-a.ts') diff --git a/packages/labs/gen-wrapper-react/test-output/package.json b/packages/labs/gen-wrapper-react/test-output/package.json index f3b9294009..283c2380ec 100644 --- a/packages/labs/gen-wrapper-react/test-output/package.json +++ b/packages/labs/gen-wrapper-react/test-output/package.json @@ -7,10 +7,15 @@ "@esm-bundle/chai": "^4.1.5", "@types/chai": "^4.0.1", "@types/mocha": "^9.0.0", - "@types/react": "^18.0.9", - "@types/react-dom": "^18.0.3", - "react": "^18.1.0", - "react-dom": "^18.1.0", + "@types/react": "file:../../../../node_modules/@types/react", + "@types/react-dom": "file:../../../../node_modules/@types/react-dom", + "react": "file:../../../../node_modules/react", + "react-dom": "file:../../../../node_modules/react-dom", + "@lit-labs/react": "file:../../react", + "lit": "file:../../../lit", + "lit-html": "file:../../../lit-html", + "lit-element": "file:../../../lit-element", + "@lit/reactive-element": "file:../../../reactive-element", "@lit-internal/test-element-a": "file:../../test-projects/test-element-a/lit-internal-test-element-a-1.0.0.tgz", "@lit-internal/test-element-a-react": "file:../gen-output/test-element-a-react/lit-internal-test-element-a-react-1.0.0.tgz" }, @@ -66,7 +71,7 @@ "build", "../../../tests:build" ], - "command": "node ../../../tests/run-web-tests.js \"tests/**/*_test.js\" --config ../../../tests/web-test-runner.config.js", + "command": "node ../../../tests/run-web-tests.js \"tests/tests.js\" --config ../../../tests/web-test-runner.config.js", "output": [] } } diff --git a/packages/labs/gen-wrapper-react/test-output/rollup.config.js b/packages/labs/gen-wrapper-react/test-output/rollup.config.js index c266e7895a..a0f05e1b5a 100644 --- a/packages/labs/gen-wrapper-react/test-output/rollup.config.js +++ b/packages/labs/gen-wrapper-react/test-output/rollup.config.js @@ -14,7 +14,7 @@ import {nodeResolve} from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import replace from '@rollup/plugin-replace'; export default { - input: ['js/tests/test-element-a_test.js'], + input: ['js/tests/tests.js'], output: { dir: './tests', format: 'esm', diff --git a/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-events_test.tsx b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-events_test.tsx new file mode 100644 index 0000000000..5bd69fd61a --- /dev/null +++ b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-events_test.tsx @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; + +import React from 'react'; +// eslint-disable-next-line import/extensions +import {render, unmountComponentAtNode} from 'react-dom'; +import { + ElementEvents, + SpecialEvent, + MyDetail, + EventSubclass, + TemplateResult, +} from '@lit-internal/test-element-a-react/element-events.js'; +import {ElementEvents as ElementEventsElement} from '@lit-internal/test-element-a/element-events.js'; + +suite('test-element-events', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + unmountComponentAtNode(container); + container.parentNode.removeChild(container); + } + }); + + test('can listen to events', async () => { + let stringCustomEventPayload: CustomEvent | undefined = undefined; + let numberCustomEventPayload: CustomEvent | undefined = undefined; + let myDetailCustomEventPayload: CustomEvent | undefined = + undefined; + let templateResultCustomEventPayload: + | CustomEvent + | undefined = undefined; + let eventSubclassPayload: EventSubclass | undefined = undefined; + let specialEventPayload: SpecialEvent | undefined = undefined; + const props = { + onStringCustomEvent: (e: CustomEvent) => + (stringCustomEventPayload = e), + onNumberCustomEvent: (e: CustomEvent) => + (numberCustomEventPayload = e), + onMyDetailCustomEvent: (e: CustomEvent) => + (myDetailCustomEventPayload = e), + onTemplateResultCustomEvent: (e: CustomEvent) => + (templateResultCustomEventPayload = e), + onEventSubclass: (e: EventSubclass) => (eventSubclassPayload = e), + onSpecialEvent: (e: SpecialEvent) => (specialEventPayload = e), + }; + + render( + + + , + container + ); + const el = container.querySelector( + 'element-events' + )! as ElementEventsElement; + await el.updateComplete; + let expected_stringCustomEventPayload = 'test-detail'; + el.fireStringCustomEvent(expected_stringCustomEventPayload); + assert.equal( + stringCustomEventPayload!.detail, + expected_stringCustomEventPayload + ); + expected_stringCustomEventPayload = 'test-detail2'; + el.fireStringCustomEvent(expected_stringCustomEventPayload); + assert.equal( + stringCustomEventPayload!.detail, + expected_stringCustomEventPayload + ); + const expected_numberCustomEventPayload = 55; + el.fireNumberCustomEvent(expected_numberCustomEventPayload); + assert.equal( + numberCustomEventPayload!.detail, + expected_numberCustomEventPayload + ); + const expected_myDetailCustomEventPayload: MyDetail = {a: 'aa', b: 555}; + el.fireMyDetailCustomEvent(expected_myDetailCustomEventPayload); + assert.equal( + myDetailCustomEventPayload!.detail, + expected_myDetailCustomEventPayload + ); + // Note, default payload is html`` which results in {strings: [], values: []} + el.fireTemplateResultCustomEvent(); + const {strings, values} = templateResultCustomEventPayload!.detail; + assert.equal(strings.length, 1); + assert.equal(strings[0], ''); + assert.equal(values.length, 0); + const str = 'strstr'; + const num = 5555; + el.fireEventSubclass(str, num); + assert.equal(eventSubclassPayload!.aStr, str); + assert.equal(eventSubclassPayload!.aNumber, num); + el.fireSpecialEvent(num); + assert.equal(specialEventPayload!.aNumber, num); + }); +}); diff --git a/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-props_test.tsx b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-props_test.tsx new file mode 100644 index 0000000000..5056fd7041 --- /dev/null +++ b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-props_test.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; + +import React from 'react'; +// eslint-disable-next-line import/extensions +import {render, unmountComponentAtNode} from 'react-dom'; +import {ElementProps} from '@lit-internal/test-element-a-react/element-props.js'; +import {ElementProps as ElementPropsElement} from '@lit-internal/test-element-a/element-props.js'; + +suite('test-element-props', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + unmountComponentAtNode(container); + container.parentNode.removeChild(container); + } + }); + + test('renders correctly', async () => { + const props = { + aStr: 'Hi', + aBool: true, + aMyType: { + a: 'a', + b: 2, + c: false, + d: ['1'], + e: 'unknown', + strOrNum: 5, + }, + }; + render( + + + , + container + ); + const el = container.querySelector('element-props')! as ElementPropsElement; + await el.updateComplete; + const shadowRoot = el.shadowRoot!; + Object.entries(props).forEach(([k, v]) => { + const e = shadowRoot.getElementById(k as string)!; + assert.equal(el[k as keyof ElementPropsElement], v); + assert.equal( + e.textContent, + typeof v === 'object' ? JSON.stringify(v) : String(v) + ); + }); + }); +}); diff --git a/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-slots_test.tsx b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-slots_test.tsx new file mode 100644 index 0000000000..2353730aa8 --- /dev/null +++ b/packages/labs/gen-wrapper-react/test-output/src/tests/test-element-slots_test.tsx @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; + +import React from 'react'; +// eslint-disable-next-line import/extensions +import {render, unmountComponentAtNode} from 'react-dom'; +import {ElementSlots} from '@lit-internal/test-element-a-react/element-slots.js'; +import {ElementSlots as ElementSlotsElement} from '@lit-internal/test-element-a/element-slots.js'; + +suite('test-element-slots', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + unmountComponentAtNode(container); + container.parentNode.removeChild(container); + } + }); + + test('renders correctly', async () => { + render( + + +
+ Header1 +
+
+ Header2 +
+
Footer
+ Default +
+
, + container + ); + const ce = container.querySelector('element-slots')! as ElementSlotsElement; + await ce.updateComplete; + const shadowRoot = ce.shadowRoot!; + // header renders: `#header1, #header2` + const headerSlot = shadowRoot.querySelector( + 'slot[name="header"]' + )!; + assert.instanceOf(headerSlot, HTMLSlotElement); + const headerAssigned = headerSlot.assignedElements(); + assert.equal(headerAssigned.length, 2); + headerAssigned.forEach((e, i) => assert(e.id, `header${i + 1}`)); + // main renders fallback + const mainSlot = + shadowRoot.querySelector('slot[name="main"]')!; + const mainAssigned = mainSlot.assignedNodes({flatten: true}); + assert.equal(mainAssigned.length, 1); + assert.equal(mainAssigned[0].textContent, `mainDefault`); + // footer: text wrapped in div + const footerSlot = shadowRoot.querySelector( + 'slot[name="footer"]' + )!; + const footerAssigned = footerSlot.assignedElements(); + assert.equal(footerAssigned.length, 1); + assert.equal(footerAssigned[0].textContent?.trim(), `Footer`); + // default: text + const defaultSlot = + shadowRoot.querySelector('slot:not([name])')!; + const defaultAssigned = defaultSlot + .assignedNodes() + .map((e) => e.textContent?.trim()) + .filter((e) => e); + assert.equal(defaultAssigned.length, 1); + assert.equal(defaultAssigned[0], `Default`); + }); +}); diff --git a/packages/labs/gen-wrapper-react/test-output/src/tests/tests.ts b/packages/labs/gen-wrapper-react/test-output/src/tests/tests.ts new file mode 100644 index 0000000000..f799579b92 --- /dev/null +++ b/packages/labs/gen-wrapper-react/test-output/src/tests/tests.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import './test-element-a_test.js'; +import './test-element-props_test.js'; +import './test-element-events_test.js'; +import './test-element-slots_test.js'; diff --git a/packages/labs/gen-wrapper-vue/CHANGELOG.md b/packages/labs/gen-wrapper-vue/CHANGELOG.md index e2e2336bae..b6a4d131e6 100644 --- a/packages/labs/gen-wrapper-vue/CHANGELOG.md +++ b/packages/labs/gen-wrapper-vue/CHANGELOG.md @@ -1,5 +1,31 @@ # @lit-labs/gen-wrapper-vue +## 0.2.1 + +### Patch Changes + +- [#3384](https://github.com/lit/lit/pull/3384) [`9f802646`](https://github.com/lit/lit/commit/9f802646d955198cbaf6e521283fe137e7f5b7a6) - Updates generated wrappers to better support types for properties and events, tested via a suite of test elements. + +- [#3377](https://github.com/lit/lit/pull/3377) [`0af4e79b`](https://github.com/lit/lit/commit/0af4e79b51d34d959488ceae4caa2240a76c15e0) - Adds type info for props/events for Vue/React wrappers. Vue wrapper properly handles defaults. + +- Updated dependencies [[`fc2b1c88`](https://github.com/lit/lit/commit/fc2b1c885211e4334d5ae5637570df85dd2e3f9e), [`ad361cc2`](https://github.com/lit/lit/commit/ad361cc22303f759afbefe60512df34fffdee771)]: + - @lit-labs/analyzer@0.4.0 + +## 0.2.0 + +### Minor Changes + +- [#3304](https://github.com/lit/lit/pull/3304) [`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a) - Added support for analyzing JavaScript files. + +- [#3288](https://github.com/lit/lit/pull/3288) [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e) - Refactored Analyzer into better fit for use in plugins. Analyzer class now takes a ts.Program, and PackageAnalyzer takes a package path and creates a program to analyze a package on the filesystem. + +- [#3254](https://github.com/lit/lit/pull/3254) [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9) - Fixes bug where global install of CLI resulted in incompatible use of analyzer between CLI packages. Fixes #3234. + +### Patch Changes + +- Updated dependencies [[`31bed8d6`](https://github.com/lit/lit/commit/31bed8d6542c44a64bad8282b9ce5e5d6514e44a), [`569a6237`](https://github.com/lit/lit/commit/569a6237377eeef0c8dced2c369c77ebdd81218e), [`fc2fd4c8`](https://github.com/lit/lit/commit/fc2fd4c8f4a25b9a85073afcb38614209e079bb9)]: + - @lit-labs/analyzer@0.3.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/.gitignore b/packages/labs/gen-wrapper-vue/goldens/test-element-a/.gitignore index 38d837e530..c3731c49a0 100644 --- a/packages/labs/gen-wrapper-vue/goldens/test-element-a/.gitignore +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/.gitignore @@ -1 +1,4 @@ -ElementA.* \ No newline at end of file +/ElementA.* +/ElementEvents.* +/ElementProps.* +/ElementSlots.* \ No newline at end of file diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/package.json b/packages/labs/gen-wrapper-vue/goldens/test-element-a/package.json index 18bb9694a4..572e8d272b 100644 --- a/packages/labs/gen-wrapper-vue/goldens/test-element-a/package.json +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/package.json @@ -11,17 +11,20 @@ "version": "1.0.0", "dependencies": { "@lit-internal/test-element-a": "^1.0.0", - "vue": "^3.2.25", + "vue": "^3.2.41", "@lit-labs/vue-utils": "^0.1.0" }, "devDependencies": { "typescript": "~4.7.4", - "@vitejs/plugin-vue": "^2.3.1", - "@rollup/plugin-typescript": "^8.3.2", - "vite": "^2.9.2", - "vue-tsc": "^0.29.8" + "@vitejs/plugin-vue": "^3.1.2", + "@rollup/plugin-typescript": "^9.0.1", + "vite": "^3.1.8", + "vue-tsc": "^1.0.8" }, "files": [ - "ElementA.{js,js.map,d.ts,vue}" + "ElementA.*", + "ElementEvents.*", + "ElementProps.*", + "ElementSlots.*" ] } diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementA.vue b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementA.vue index 6389ed3bd0..8f8408786f 100644 --- a/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementA.vue +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementA.vue @@ -1,27 +1,48 @@ - + diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementEvents.vue b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementEvents.vue new file mode 100644 index 0000000000..de4fdefe11 --- /dev/null +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementEvents.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementProps.vue b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementProps.vue new file mode 100644 index 0000000000..0177262885 --- /dev/null +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementProps.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementSlots.vue b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementSlots.vue new file mode 100644 index 0000000000..a5237d8628 --- /dev/null +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementSlots.vue @@ -0,0 +1,41 @@ + + diff --git a/packages/labs/gen-wrapper-vue/goldens/test-element-a/vite.config.ts b/packages/labs/gen-wrapper-vue/goldens/test-element-a/vite.config.ts index 6878297d69..7c1f4c1f67 100644 --- a/packages/labs/gen-wrapper-vue/goldens/test-element-a/vite.config.ts +++ b/packages/labs/gen-wrapper-vue/goldens/test-element-a/vite.config.ts @@ -5,10 +5,17 @@ import typescript from '@rollup/plugin-typescript'; export default { build: { rollupOptions: { - // Ensures no deps are bundled with this build. - external: () => true, - input: ['./src/ElementA.vue'], - preserveModules: true, + // Ensures no deps are bundled with build. + // Source paths are expected to start with `./` or `/` but may be + // `x:` on Windows. + external: (id: string) => !id.match(/^((\w:)|(\.?[\\/]))/), + input: [ + './src/ElementA.vue', + './src/ElementEvents.vue', + './src/ElementProps.vue', + './src/ElementSlots.vue', + ], + preserveModules: false, preserveEntrySignatures: true, output: { format: 'es', diff --git a/packages/labs/gen-wrapper-vue/package.json b/packages/labs/gen-wrapper-vue/package.json index 2a4fa4b311..d7da92f6e1 100644 --- a/packages/labs/gen-wrapper-vue/package.json +++ b/packages/labs/gen-wrapper-vue/package.json @@ -1,7 +1,7 @@ { "name": "@lit-labs/gen-wrapper-vue", "description": "Code generator for Vue wrapper for Lit components", - "version": "0.1.1", + "version": "0.2.1", "publishConfig": { "access": "public" }, @@ -61,7 +61,7 @@ } }, "dependencies": { - "@lit-labs/analyzer": "^0.2.0", + "@lit-labs/analyzer": "^0.4.0", "@lit-labs/gen-utils": "^0.1.0", "@lit-labs/vue-utils": "^0.1.0" }, diff --git a/packages/labs/gen-wrapper-vue/src/index.ts b/packages/labs/gen-wrapper-vue/src/index.ts index e218f1158d..b574d743d5 100644 --- a/packages/labs/gen-wrapper-vue/src/index.ts +++ b/packages/labs/gen-wrapper-vue/src/index.ts @@ -22,8 +22,8 @@ export const getCommand = () => { name: 'vue', description: 'Generate Vue wrapper components from Lit elements', kind: 'resolved', - async generate(options: {analysis: Package}): Promise { - return generateVueWrapper(options.analysis); + async generate(options: {package: Package}): Promise { + return generateVueWrapper(options.package); }, }; }; @@ -58,7 +58,7 @@ export const generateVueWrapper = async (pkg: Package): Promise => { const packageNameToVuePackageName = (pkgName: string) => `${pkgName}-vue`; const gitIgnoreTemplate = (moduleNames: string[]) => - moduleNames.map((f) => `${f}.*`).join('\n'); + moduleNames.map((f) => `/${f}.*`).join('\n'); const getVueFileName = (dir: string, name: string) => `${dir}/${name}.vue`; diff --git a/packages/labs/gen-wrapper-vue/src/lib/package-json-template.ts b/packages/labs/gen-wrapper-vue/src/lib/package-json-template.ts index baf593f1d7..b21fb81297 100644 --- a/packages/labs/gen-wrapper-vue/src/lib/package-json-template.ts +++ b/packages/labs/gen-wrapper-vue/src/lib/package-json-template.ts @@ -33,18 +33,18 @@ export const packageJsonTemplate = ( dependencies: { // TODO(kschaaf): make component version range configurable? [pkgJson.name!]: '^' + pkgJson.version!, - vue: '^3.2.25', + vue: '^3.2.41', '@lit-labs/vue-utils': '^0.1.0', }, devDependencies: { // Use typescript from source package, assuming it exists typescript: pkgJson?.devDependencies?.typescript ?? '~4.7.4', - '@vitejs/plugin-vue': '^2.3.1', - '@rollup/plugin-typescript': '^8.3.2', - vite: '^2.9.2', - 'vue-tsc': '^0.29.8', + '@vitejs/plugin-vue': '^3.1.2', + '@rollup/plugin-typescript': '^9.0.1', + vite: '^3.1.8', + 'vue-tsc': '^1.0.8', }, - files: [...moduleNames.map((f) => `${f}.{js,js.map,d.ts,vue}`)], + files: [...moduleNames.map((f) => `${f}.*`)], }, null, 2 diff --git a/packages/labs/gen-wrapper-vue/src/lib/vite.config-template.ts b/packages/labs/gen-wrapper-vue/src/lib/vite.config-template.ts index a871e3f8f1..adf1ee2f98 100644 --- a/packages/labs/gen-wrapper-vue/src/lib/vite.config-template.ts +++ b/packages/labs/gen-wrapper-vue/src/lib/vite.config-template.ts @@ -29,14 +29,16 @@ import typescript from '@rollup/plugin-typescript'; export default { build: { rollupOptions: { - // Ensures no deps are bundled with this build. - external: () => true, + // Ensures no deps are bundled with build. + // Source paths are expected to start with \`./\` or \`/\` but may be + // \`x:\` on Windows. + external: (id: string) => !id.match(/^((\\w:)|(\\.?[\\\\/]))/), input: [ ${Object.keys(sfcFiles) .map((path) => `'./${path}'`) .join(', ')} ], - preserveModules: true, + preserveModules: false, preserveEntrySignatures: true, output: { format: 'es', diff --git a/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template-sfc.ts b/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template-sfc.ts index 69f7f329e4..28405d8f8e 100644 --- a/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template-sfc.ts +++ b/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template-sfc.ts @@ -6,9 +6,13 @@ import { LitElementDeclaration, - ReactiveProperty as ModelProperty, - Event as ModelEvent, PackageJson, + getImportsStringForReferences, +} from '@lit-labs/analyzer'; + +import { + ReactiveProperty as ModelProperty, + Event as EventModel, } from '@lit-labs/analyzer/lib/model.js'; import {javascript, kabobToOnEvent} from '@lit-labs/gen-utils/lib/str-utils.js'; @@ -16,9 +20,6 @@ import {javascript, kabobToOnEvent} from '@lit-labs/gen-utils/lib/str-utils.js'; * Generates a Vue wrapper component as a Vue single file component. This * approach relies on the Vue compiler to generate a Javascript property types * object for Vue runtime type checking from the Typescript property types. - * - * TODO(sorvell): This is also a Typescript module generator that is unused. - * Need to decide which approach is best and delete the unused generator. */ export const wrapperModuleTemplateSFC = ( packageJson: PackageJson, @@ -32,73 +33,130 @@ export const wrapperModuleTemplateSFC = ( ]); }; -// TODO(sorvell): place into model directly? -const getFieldModifierString = (node: ModelProperty['node']) => - node.questionToken ? '?' : node.exclamationToken ? '!' : ''; +const defaultEventType = `CustomEvent`; -const getEventType = (event: ModelEvent) => event.type?.text || `unknown`; +const getEventInfo = (event: EventModel) => { + const {name, type: modelType} = event; + const onName = kabobToOnEvent(name); + const type = modelType?.text ?? defaultEventType; + return {onName, type}; +}; -const wrapDefineProps = (props: Map) => - Array.from(props.values()) - .map( - (prop) => - `${prop.name}${getFieldModifierString(prop.node)}: ${prop.type?.text}` - ) - .join(',\n'); +const renderPropsInterface = (props: Map) => + `export interface Props { + ${Array.from(props.values()) + .map((prop) => `${prop.name}?: ${prop.type?.text || 'any'}`) + .join(';\n ')} + }`; -// TODO(sorvell): Improve event handling, currently just forwarding the event, -// but this should be its "payload." -const wrapEvents = (events: Map) => +const wrapEvents = (events: Map) => Array.from(events.values()) - .map( - (event) => `(e: '${event.name}', payload: ${getEventType(event)}): void` - ) + .map((event) => { + const {type} = getEventInfo(event); + return `(e: '${event.name}', payload: ${type}): void`; + }) .join(',\n'); + /** * Generates VNode props for events. Note that vue automatically maps * event names from e.g. `event-name` to `onEventName`. */ -const renderEvents = (events: Map) => - Array.from(events.values()) - .map( - (event) => - `${kabobToOnEvent(event.name)}: (event: ${ - event.type?.text || `CustomEvent` - }) => emit('${event.name}', (event.detail || event) as ${getEventType( - event - )})` - ) - .join(',\n'); +const renderEvents = (events: Map) => + javascript`{ + ${Array.from(events.values()) + .map((event) => { + const {onName, type} = getEventInfo(event); + return `${onName}: (event: ${type}) => emit('${event.name}', event as ${type})`; + }) + .join(',\n')} + }`; + +const getTypeReferencesForMap = ( + map: Map +) => Array.from(map.values()).flatMap((e) => e.type?.references ?? []); + +const getElementTypeImports = (declaration: LitElementDeclaration) => { + const {events, reactiveProperties} = declaration; + const refs = [ + ...getTypeReferencesForMap(events), + ...getTypeReferencesForMap(reactiveProperties), + ]; + return getImportsStringForReferences(refs); +}; + +// TODO(sorvell): add support for getting exports in analyzer. +const getElementTypeExportsFromImports = (imports: string) => + imports.replace(/(?:^import)/gm, 'export type'); // TODO(sorvell): Add support for `v-bind`. +// TODO(sorvell): Investigate if it's possible to save the ~15 lines related to +// handling defaults by factoring the defaults directive and associated code +// into the vue-utils package. const wrapperTemplate = ( - {tagname, events, reactiveProperties}: LitElementDeclaration, + declaration: LitElementDeclaration, wcPath: string ) => { - return javascript` + const {tagname, events, reactiveProperties} = declaration; + const typeImports = getElementTypeImports(declaration); + const typeExports = getElementTypeExportsFromImports(typeImports); + return javascript`${ + typeExports + ? javascript` + ` + : '' + } - `; + `; }; diff --git a/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template.ts b/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template.ts deleted file mode 100644 index dd06cd8eee..0000000000 --- a/packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ - -import { - LitElementDeclaration, - ReactiveProperty as ModelProperty, - Event as ModelEvent, - PackageJson, -} from '@lit-labs/analyzer/lib/model.js'; -import { - javascript, - kabobToOnEvent, - toInitialCap, -} from '@lit-labs/gen-utils/lib/str-utils.js'; - -/** - * Generates a Vue wrapper component as a Typescript module. This approach - * requires generating a Javascript property types object for Vue runtime - * type checking to work. - * - * TODO(sorvell): This is currently unused and instead the wrapper is generated - * as a Vue SFC. Need to decide which approach is best and delete the unused - * generator. - */ -export const wrapperModuleTemplate = ( - packageJson: PackageJson, - moduleJsPath: string, - elements: LitElementDeclaration[] -) => { - return javascript` - import { h, defineComponent, openBlock, createBlock } from "vue"; - import { assignSlotNodes, Slots, eventProp } from "@lit-labs/vue-utils/wrapper-utils.js"; - import '${packageJson.name}/${moduleJsPath}'; - - ${elements.map((element) => wrapperTemplate(element))} -`; -}; - -// TODO(sorvell): need to extract Javascript type from typescript for Vue. -// can workaround this by using an SFC and the macro `defineProperties<>()` -const tsTypeToVuePropType = (type: string) => { - const required = type.indexOf('undefined') === -1; - let jsType = type.replace('undefined', '').trim().replace(/[|]$/, ''); - jsType = jsType - .split(/\s+/) - .map((s) => toInitialCap(s).replace(/[|]/g, '||')) - .join(' ') - .trim(); - return `{type: ${jsType}, required: ${required}}`; -}; - -const wrapProps = (props: Map) => - Array.from(props.values()) - .map( - (prop) => - `${prop.name}: ${tsTypeToVuePropType( - prop.type?.text || 'string|undefined' - )}` - ) - .join(',\n'); - -// TODO(sorvell): Improve event handling, currently just forwarding the event, -// but this should be its "payload." -const wrapEvents = (events: Map) => - Array.from(events.values()) - .map( - (event) => - `${kabobToOnEvent(event.name)}: eventProp<(event: ${ - event.type?.text || `CustomEvent` - }) => void>()` - ) - .join(',\n'); - -/** - * Generates VNode props for events. Note that vue automatically maps - * event names from e.g. `event-name` to `onEventName`. - */ -const renderEvents = (events: Map) => - Array.from(events.values()) - .map( - (event) => - `${kabobToOnEvent(event.name)}: (event: ${ - event.type?.text || `CustomEvent` - }) => emit(event.type, event.detail || event)` - ) - .join(',\n'); - -// TODO(sorvell): Add support for `v-bind`. -const wrapperTemplate = ({ - name, - tagname, - events, - reactiveProperties, -}: LitElementDeclaration) => { - return javascript` - const props = { - ${wrapProps(reactiveProperties)}, - ${wrapEvents(events)} - }; - - const ${name} = defineComponent({ - name: "${name}", - props, - setup(props, {emit, slots}) { - const render = () => h( - "${tagname}", - { - ...props, - ${renderEvents(events)} - }, - assignSlotNodes(slots as Slots) - ); - return () => { - openBlock(); - return createBlock(render); - }; - } - }); - - export default ${name}; -`; -}; diff --git a/packages/labs/gen-wrapper-vue/src/test-gen/generate_test.ts b/packages/labs/gen-wrapper-vue/src/test-gen/generate_test.ts index 8b35d82f19..6e9bd88b32 100644 --- a/packages/labs/gen-wrapper-vue/src/test-gen/generate_test.ts +++ b/packages/labs/gen-wrapper-vue/src/test-gen/generate_test.ts @@ -9,7 +9,7 @@ import {test} from 'uvu'; import * as assert from 'uvu/assert'; import * as fs from 'fs'; import * as path from 'path'; -import {Analyzer} from '@lit-labs/analyzer'; +import {createPackageAnalyzer} from '@lit-labs/analyzer'; import {AbsolutePath} from '@lit-labs/analyzer/lib/paths.js'; import { installPackage, @@ -32,9 +32,9 @@ test('basic wrapper generation', async () => { fs.rmSync(outputPackage, {recursive: true}); } - const analyzer = new Analyzer(inputPackage as AbsolutePath); - const analysis = analyzer.analyzePackage(); - await writeFileTree(outputFolder, await generateVueWrapper(analysis)); + const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); + const pkg = analyzer.getPackage(); + await writeFileTree(outputFolder, await generateVueWrapper(pkg)); const wrapperSourceFile = fs.readFileSync( path.join(outputPackage, 'src/ElementA.vue') diff --git a/packages/labs/gen-wrapper-vue/test-output/package.json b/packages/labs/gen-wrapper-vue/test-output/package.json index 15ec8156fc..11b8a97d0e 100644 --- a/packages/labs/gen-wrapper-vue/test-output/package.json +++ b/packages/labs/gen-wrapper-vue/test-output/package.json @@ -58,7 +58,7 @@ "../../../tests:build" ], "files": [], - "command": "node ../../../tests/run-web-tests.js \"tests/**/*_test.js\" --config ../../../tests/web-test-runner.config.js", + "command": "node ../../../tests/run-web-tests.js \"tests/**/tests.js\" --config ../../../tests/web-test-runner.config.js", "output": [] } } diff --git a/packages/labs/gen-wrapper-vue/test-output/rollup.config.js b/packages/labs/gen-wrapper-vue/test-output/rollup.config.js index c266e7895a..a0f05e1b5a 100644 --- a/packages/labs/gen-wrapper-vue/test-output/rollup.config.js +++ b/packages/labs/gen-wrapper-vue/test-output/rollup.config.js @@ -14,7 +14,7 @@ import {nodeResolve} from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import replace from '@rollup/plugin-replace'; export default { - input: ['js/tests/test-element-a_test.js'], + input: ['js/tests/tests.js'], output: { dir: './tests', format: 'esm', diff --git a/packages/labs/gen-wrapper-vue/test-output/src/tests/SlotContainer.vue b/packages/labs/gen-wrapper-vue/test-output/src/tests/SlotContainer.vue new file mode 100644 index 0000000000..8c985f1b4d --- /dev/null +++ b/packages/labs/gen-wrapper-vue/test-output/src/tests/SlotContainer.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-events_test.ts b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-events_test.ts new file mode 100644 index 0000000000..dc1fc699ce --- /dev/null +++ b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-events_test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; +import {createApp, reactive, h} from 'vue'; +import { + default as ElementEvents, + SpecialEvent, + MyDetail, + EventSubclass, + TemplateResult, +} from '@lit-internal/test-element-a-vue/ElementEvents.js'; +import {ElementEvents as ElementEventsElement} from '@lit-internal/test-element-a/element-events.js'; + +suite('test-element-events', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + test('can listen to events', async () => { + let stringCustomEventPayload: CustomEvent | undefined = undefined; + let numberCustomEventPayload: CustomEvent | undefined = undefined; + let myDetailCustomEventPayload: CustomEvent | undefined = + undefined; + let templateResultCustomEventPayload: + | CustomEvent + | undefined = undefined; + let eventSubclassPayload: EventSubclass | undefined = undefined; + let specialEventPayload: SpecialEvent | undefined = undefined; + + const props = { + onStringCustomEvent: (e: CustomEvent) => + (stringCustomEventPayload = e), + onNumberCustomEvent: (e: CustomEvent) => + (numberCustomEventPayload = e), + onMyDetailCustomEvent: (e: CustomEvent) => + (myDetailCustomEventPayload = e), + onTemplateResultCustomEvent: (e: CustomEvent) => + (templateResultCustomEventPayload = e), + onEventSubclass: (e: EventSubclass) => (eventSubclassPayload = e), + onSpecialEvent: (e: SpecialEvent) => (specialEventPayload = e), + }; + + const reactiveProps = reactive(props); + createApp({ + render() { + return h(ElementEvents, reactiveProps); + }, + }).mount(container); + const el = container.querySelector( + 'element-events' + )! as ElementEventsElement; + await el.updateComplete; + let expected_stringCustomEventPayload = 'test-detail'; + el.fireStringCustomEvent(expected_stringCustomEventPayload); + assert.equal( + stringCustomEventPayload!.detail, + expected_stringCustomEventPayload + ); + expected_stringCustomEventPayload = 'test-detail2'; + el.fireStringCustomEvent(expected_stringCustomEventPayload); + assert.equal( + stringCustomEventPayload!.detail, + expected_stringCustomEventPayload + ); + const expected_numberCustomEventPayload = 55; + el.fireNumberCustomEvent(expected_numberCustomEventPayload); + assert.equal( + numberCustomEventPayload!.detail, + expected_numberCustomEventPayload + ); + const expected_myDetailCustomEventPayload: MyDetail = {a: 'aa', b: 555}; + el.fireMyDetailCustomEvent(expected_myDetailCustomEventPayload); + assert.equal( + myDetailCustomEventPayload!.detail, + expected_myDetailCustomEventPayload + ); + // Note, default payload is html`` which results in {strings: [''], values: []} + el.fireTemplateResultCustomEvent(); + const {strings, values} = templateResultCustomEventPayload!.detail; + assert.equal(strings.length, 1); + assert.equal(strings[0], ''); + assert.equal(values.length, 0); + const str = 'strstr'; + const num = 5555; + el.fireEventSubclass(str, num); + assert.equal(eventSubclassPayload!.aStr, str); + assert.equal(eventSubclassPayload!.aNumber, num); + el.fireSpecialEvent(num); + assert.equal(specialEventPayload!.aNumber, num); + }); +}); diff --git a/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-props_test.ts b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-props_test.ts new file mode 100644 index 0000000000..5b72e078bc --- /dev/null +++ b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-props_test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; +import {createApp, reactive, h, nextTick} from 'vue'; +import { + default as ElementProps, + Props as PropsType, +} from '@lit-internal/test-element-a-vue/ElementProps.js'; +import {ElementProps as ElementPropsElement} from '@lit-internal/test-element-a/element-props.js'; + +suite('test-element-props', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + test('renders property changes correctly', async () => { + const {aStr, aNum, aBool, aMyType} = document.createElement( + 'element-props' + ) as ElementPropsElement; + const defaults = {aStr, aNum, aBool, aMyType}; + + const props: PropsType = { + aStr: 'Hi', + aBool: true, + aMyType: { + a: 'a', + b: 2, + c: false, + d: ['1'], + e: 'unknown', + strOrNum: 5, + }, + }; + + const reactiveProps = reactive(props); + createApp({ + render() { + return h(ElementProps, reactiveProps); + }, + }).mount(container); + const el = container.querySelector('element-props')! as ElementPropsElement; + await el.updateComplete; + const shadowRoot = el.shadowRoot!; + // Check that property values are rendered to DOM as expected. + // Note, element under test has nodes with id's same as props to facilitate + // this easily. + const assertPropsRendered = async (values = reactiveProps) => { + await nextTick(); + Object.entries(values).forEach(([k, v]) => { + const e = shadowRoot.getElementById(k as string)!; + assert.equal( + e.textContent, + typeof v === 'object' ? JSON.stringify(v) : String(v) + ); + }); + }; + await assertPropsRendered(); + // Verify default values are applied when props become undefined. + // This follows Vue's convention for handling properties. + reactiveProps.aStr = undefined; + reactiveProps.aNum = undefined; + reactiveProps.aBool = undefined; + reactiveProps.aMyType = undefined; + await assertPropsRendered(defaults); + // Can update props from undefined state ok. + reactiveProps.aStr = 'n'; + reactiveProps.aNum = 100; + reactiveProps.aBool = false; + reactiveProps.aMyType = { + a: 'a2', + b: 4, + c: true, + d: ['1', '2', '3'], + e: 'unknown2', + strOrNum: '55', + }; + await assertPropsRendered(); + }); +}); diff --git a/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-slots_test.ts b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-slots_test.ts new file mode 100644 index 0000000000..961badf676 --- /dev/null +++ b/packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-slots_test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from '@esm-bundle/chai'; +import {createApp} from 'vue'; +import SlotContainer from './SlotContainer.vue'; +import {ElementSlots as ElementSlotsElement} from '@lit-internal/test-element-a/element-slots.js'; + +suite('test-element-slots', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + test('renders slots inside a Vue component', async () => { + createApp(SlotContainer).mount(container); + const ce = container.querySelector('element-slots')! as ElementSlotsElement; + await ce.updateComplete; + const shadowRoot = ce.shadowRoot!; + // header renders: `#header1, #header2` + const headerSlot = shadowRoot.querySelector( + 'slot[name="header"]' + )!; + assert.instanceOf(headerSlot, HTMLSlotElement); + const headerAssigned = headerSlot.assignedElements(); + assert.equal(headerAssigned.length, 2); + headerAssigned.forEach((e, i) => assert(e.id, `header${i + 1}`)); + // main renders fallback + const mainSlot = + shadowRoot.querySelector('slot[name="main"]')!; + const mainAssigned = mainSlot.assignedNodes({flatten: true}); + assert.equal(mainAssigned.length, 1); + assert.equal(mainAssigned[0].textContent, `mainDefault`); + // footer: text wrapped in div + const footerSlot = shadowRoot.querySelector( + 'slot[name="footer"]' + )!; + const footerAssigned = footerSlot.assignedElements(); + assert.equal(footerAssigned.length, 1); + assert.equal(footerAssigned[0].textContent?.trim(), `Footer`); + // default: text + const defaultSlot = + shadowRoot.querySelector('slot:not([name])')!; + const defaultAssigned = defaultSlot + .assignedNodes() + .map((e) => e.textContent?.trim()) + .filter((e) => e); + assert.equal(defaultAssigned.length, 1); + assert.equal(defaultAssigned[0], `Default`); + }); +}); diff --git a/packages/labs/gen-wrapper-vue/test-output/src/tests/tests.ts b/packages/labs/gen-wrapper-vue/test-output/src/tests/tests.ts new file mode 100644 index 0000000000..f799579b92 --- /dev/null +++ b/packages/labs/gen-wrapper-vue/test-output/src/tests/tests.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import './test-element-a_test.js'; +import './test-element-props_test.js'; +import './test-element-events_test.js'; +import './test-element-slots_test.js'; diff --git a/packages/labs/gen-wrapper-vue/test-output/vite.config.ts b/packages/labs/gen-wrapper-vue/test-output/vite.config.ts index 18b86fbc20..8a304cf986 100644 --- a/packages/labs/gen-wrapper-vue/test-output/vite.config.ts +++ b/packages/labs/gen-wrapper-vue/test-output/vite.config.ts @@ -4,8 +4,8 @@ import vue from '@vitejs/plugin-vue'; export default { build: { lib: { - entry: './src/tests/test-element-a_test.ts', - fileName: () => `test-element-a_test.js`, + entry: './src/tests/tests.ts', + fileName: () => `tests.js`, formats: ['es'], }, outDir: './tests', diff --git a/packages/labs/observers/CHANGELOG.md b/packages/labs/observers/CHANGELOG.md index 5ef307682e..86f00e41a6 100644 --- a/packages/labs/observers/CHANGELOG.md +++ b/packages/labs/observers/CHANGELOG.md @@ -1,5 +1,23 @@ # @lit-labs/observers +## 1.1.0 + +### Minor Changes + +- [#3294](https://github.com/lit/lit/pull/3294) [`96c05f25`](https://github.com/lit/lit/commit/96c05f258183066b34d2253c57552ef41ed4581a) - Fix value property of type `unknown` on exported controllers. The type of + `value` is now generic and can be inferred from the return type of your passed + in `callback`. The default callback `() => true` was removed, and is now + undefined by default. + +- [#3323](https://github.com/lit/lit/pull/3323) [`0f787b29`](https://github.com/lit/lit/commit/0f787b290af1ce68498ddb8fb0ab32b9d6698dc6) - Add unobserve method to `ResizeController` and `IntersectionController`. + +### Patch Changes + +- [#3293](https://github.com/lit/lit/pull/3293) [`7e22bc2e`](https://github.com/lit/lit/commit/7e22bc2e3918e36c0e46aa6430c17eb8f557968f) - Fix controllers not observing changes to target element if initialized after the host has connected. + +- [#3321](https://github.com/lit/lit/pull/3321) [`e90e8fe9`](https://github.com/lit/lit/commit/e90e8fe99423c264827564dcc98236d0329a118a) - Controllers now track all observed targets and will restore observing targets + when host is reconnected. + ## 1.0.2 ### Patch Changes diff --git a/packages/labs/observers/package.json b/packages/labs/observers/package.json index eab540b682..1df887152c 100644 --- a/packages/labs/observers/package.json +++ b/packages/labs/observers/package.json @@ -1,6 +1,6 @@ { "name": "@lit-labs/observers", - "version": "1.0.2", + "version": "1.1.0", "description": "A set of reactive controllers that facilitate using the platform observer objects.", "license": "BSD-3-Clause", "homepage": "https://lit.dev/", diff --git a/packages/labs/observers/src/intersection_controller.ts b/packages/labs/observers/src/intersection_controller.ts index e6fd598302..581302e341 100644 --- a/packages/labs/observers/src/intersection_controller.ts +++ b/packages/labs/observers/src/intersection_controller.ts @@ -11,14 +11,14 @@ import { /** * The callback function for a IntersectionController. */ -export type IntersectionValueCallback = ( +export type IntersectionValueCallback = ( ...args: Parameters -) => unknown; +) => T; /** * The config options for a IntersectionController. */ -export interface IntersectionControllerConfig { +export interface IntersectionControllerConfig { /** * Configuration object for the IntersectionObserver. */ @@ -35,7 +35,7 @@ export interface IntersectionControllerConfig { * The callback used to process detected changes into a value stored * in the controller's `value` property. */ - callback?: IntersectionValueCallback; + callback?: IntersectionValueCallback; /** * An IntersectionObserver reports the initial intersection state * when observe is called. Setting this flag to true skips processing this @@ -60,9 +60,9 @@ export interface IntersectionControllerConfig { * used to process the result into a value which is stored on the controller. * The controller's `value` is usable during the host's update cycle. */ -export class IntersectionController implements ReactiveController { +export class IntersectionController implements ReactiveController { private _host: ReactiveControllerHost; - private _target: Element | null; + private _targets: Set = new Set(); private _observer!: IntersectionObserver; private _skipInitial = false; /** @@ -77,22 +77,23 @@ export class IntersectionController implements ReactiveController { * The result of processing the observer's changes via the `callback` * function. */ - value?: unknown; + value?: T; /** * Function that returns a value processed from the observer's changes. * The result is stored in the `value` property. */ - callback: IntersectionValueCallback = () => true; + callback?: IntersectionValueCallback; constructor( - host: ReactiveControllerHost, - {target, config, callback, skipInitial}: IntersectionControllerConfig + host: ReactiveControllerHost & Element, + {target, config, callback, skipInitial}: IntersectionControllerConfig ) { - (this._host = host).addController(this); + this._host = host; // Target defaults to `host` unless explicitly `null`. - this._target = - target === null ? target : target ?? (this._host as unknown as Element); + if (target !== null) { + this._targets.add(target ?? host); + } this._skipInitial = skipInitial ?? this._skipInitial; - this.callback = callback ?? this.callback; + this.callback = callback; // Check browser support. if (!window.IntersectionObserver) { console.warn( @@ -112,6 +113,7 @@ export class IntersectionController implements ReactiveController { }, config ); + host.addController(this); } /** @@ -119,12 +121,12 @@ export class IntersectionController implements ReactiveController { * function to produce a result stored in the `value` property. */ protected handleChanges(entries: IntersectionObserverEntry[]) { - this.value = this.callback(entries, this._observer); + this.value = this.callback?.(entries, this._observer); } hostConnected() { - if (this._target) { - this.observe(this._target); + for (const target of this._targets) { + this.observe(target); } } @@ -146,12 +148,22 @@ export class IntersectionController implements ReactiveController { * @param target Element to observe */ observe(target: Element) { + this._targets.add(target); // Note, this will always trigger the callback since the initial // intersection state is reported. this._observer.observe(target); this._unobservedUpdate = true; } + /** + * Unobserve the target element. + * @param target Element to unobserve + */ + unobserve(target: Element) { + this._targets.delete(target); + this._observer.unobserve(target); + } + /** * Disconnects the observer. This is done automatically when the host * disconnects. diff --git a/packages/labs/observers/src/mutation_controller.ts b/packages/labs/observers/src/mutation_controller.ts index 6b07b2aee4..e331503e63 100644 --- a/packages/labs/observers/src/mutation_controller.ts +++ b/packages/labs/observers/src/mutation_controller.ts @@ -11,14 +11,14 @@ import { /** * The callback function for a MutationController. */ -export type MutationValueCallback = ( +export type MutationValueCallback = ( ...args: Parameters -) => unknown; +) => T; /** * The config options for a MutationController. */ -export interface MutationControllerConfig { +export interface MutationControllerConfig { /** * Configuration object for the MutationObserver. */ @@ -35,7 +35,7 @@ export interface MutationControllerConfig { * The callback used to process detected changes into a value stored * in the controller's `value` property. */ - callback?: MutationValueCallback; + callback?: MutationValueCallback; /** * By default the `callback` is called without changes when a target is * observed. This is done to help manage initial state, but this @@ -59,9 +59,9 @@ export interface MutationControllerConfig { * used to process the result into a value which is stored on the controller. * The controller's `value` is usable during the host's update cycle. */ -export class MutationController implements ReactiveController { +export class MutationController implements ReactiveController { private _host: ReactiveControllerHost; - private _target: Element | null; + private _targets: Set = new Set(); private _config: MutationObserverInit; private _observer!: MutationObserver; private _skipInitial = false; @@ -76,23 +76,24 @@ export class MutationController implements ReactiveController { * The result of processing the observer's changes via the `callback` * function. */ - value?: unknown; + value?: T; /** * Function that returns a value processed from the observer's changes. * The result is stored in the `value` property. */ - callback: MutationValueCallback = () => true; + callback?: MutationValueCallback; constructor( - host: ReactiveControllerHost, - {target, config, callback, skipInitial}: MutationControllerConfig + host: ReactiveControllerHost & Element, + {target, config, callback, skipInitial}: MutationControllerConfig ) { - (this._host = host).addController(this); + this._host = host; // Target defaults to `host` unless explicitly `null`. - this._target = - target === null ? target : target ?? (this._host as unknown as Element); + if (target !== null) { + this._targets.add(target ?? host); + } this._config = config; this._skipInitial = skipInitial ?? this._skipInitial; - this.callback = callback ?? this.callback; + this.callback = callback; // Check browser support. if (!window.MutationObserver) { console.warn( @@ -104,6 +105,7 @@ export class MutationController implements ReactiveController { this.handleChanges(records); this._host.requestUpdate(); }); + host.addController(this); } /** @@ -111,12 +113,12 @@ export class MutationController implements ReactiveController { * function to produce a result stored in the `value` property. */ protected handleChanges(records: MutationRecord[]) { - this.value = this.callback(records, this._observer); + this.value = this.callback?.(records, this._observer); } hostConnected() { - if (this._target) { - this.observe(this._target); + for (const target of this._targets) { + this.observe(target); } } @@ -145,6 +147,7 @@ export class MutationController implements ReactiveController { * @param target Element to observe */ observe(target: Element) { + this._targets.add(target); this._observer.observe(target, this._config); this._unobservedUpdate = true; this._host.requestUpdate(); diff --git a/packages/labs/observers/src/performance_controller.ts b/packages/labs/observers/src/performance_controller.ts index ed4516adec..c48b3eddd5 100644 --- a/packages/labs/observers/src/performance_controller.ts +++ b/packages/labs/observers/src/performance_controller.ts @@ -11,16 +11,16 @@ import { /** * The callback function for a PerformanceController. */ -export type PerformanceValueCallback = ( +export type PerformanceValueCallback = ( entries: PerformanceEntryList, observer: PerformanceObserver, entryList?: PerformanceObserverEntryList -) => unknown; +) => T; /** * The config options for a PerformanceController. */ -export interface PerformanceControllerConfig { +export interface PerformanceControllerConfig { /** * Configuration object for the PerformanceObserver. */ @@ -29,7 +29,7 @@ export interface PerformanceControllerConfig { * The callback used to process detected changes into a value stored * in the controller's `value` property. */ - callback?: PerformanceValueCallback; + callback?: PerformanceValueCallback; /** * By default the `callback` is called without changes when a target is * observed. This is done to help manage initial state, but this @@ -50,7 +50,7 @@ export interface PerformanceControllerConfig { * used to process the result into a value which is stored on the controller. * The controller's `value` is usable during the host's update cycle. */ -export class PerformanceController implements ReactiveController { +export class PerformanceController implements ReactiveController { private _host: ReactiveControllerHost; private _config: PerformanceObserverInit; private _observer!: PerformanceObserver; @@ -66,20 +66,20 @@ export class PerformanceController implements ReactiveController { * The result of processing the observer's changes via the `callback` * function. */ - value?: unknown; + value?: T; /** * Function that returns a value processed from the observer's changes. * The result is stored in the `value` property. */ - callback: PerformanceValueCallback = () => true; + callback?: PerformanceValueCallback; constructor( host: ReactiveControllerHost, - {config, callback, skipInitial}: PerformanceControllerConfig + {config, callback, skipInitial}: PerformanceControllerConfig ) { - (this._host = host).addController(this); + this._host = host; this._config = config; this._skipInitial = skipInitial ?? this._skipInitial; - this.callback = callback ?? this.callback; + this.callback = callback; // Check browser support. if (!window.PerformanceObserver) { console.warn( @@ -93,6 +93,7 @@ export class PerformanceController implements ReactiveController { this._host.requestUpdate(); } ); + host.addController(this); } /** @@ -103,7 +104,7 @@ export class PerformanceController implements ReactiveController { entries: PerformanceEntryList, entryList?: PerformanceObserverEntryList ) { - this.value = this.callback(entries, this._observer, entryList); + this.value = this.callback?.(entries, this._observer, entryList); } hostConnected() { diff --git a/packages/labs/observers/src/resize_controller.ts b/packages/labs/observers/src/resize_controller.ts index a47f3dbce0..5dbbdb0649 100644 --- a/packages/labs/observers/src/resize_controller.ts +++ b/packages/labs/observers/src/resize_controller.ts @@ -11,14 +11,14 @@ import { /** * The callback function for a ResizeController. */ -export type ResizeValueCallback = ( +export type ResizeValueCallback = ( ...args: Parameters -) => unknown; +) => T; /** * The config options for a ResizeController. */ -export interface ResizeControllerConfig { +export interface ResizeControllerConfig { /** * Configuration object for the ResizeController. */ @@ -35,7 +35,7 @@ export interface ResizeControllerConfig { * The callback used to process detected changes into a value stored * in the controller's `value` property. */ - callback?: ResizeValueCallback; + callback?: ResizeValueCallback; /** * By default the `callback` is called without changes when a target is * observed. This is done to help manage initial state, but this @@ -58,9 +58,9 @@ export interface ResizeControllerConfig { * used to process the result into a value which is stored on the controller. * The controller's `value` is usable during the host's update cycle. */ -export class ResizeController implements ReactiveController { +export class ResizeController implements ReactiveController { private _host: ReactiveControllerHost; - private _target: Element | null; + private _targets: Set = new Set(); private _config?: ResizeObserverOptions; private _observer!: ResizeObserver; private _skipInitial = false; @@ -75,23 +75,24 @@ export class ResizeController implements ReactiveController { * The result of processing the observer's changes via the `callback` * function. */ - value?: unknown; + value?: T; /** * Function that returns a value processed from the observer's changes. * The result is stored in the `value` property. */ - callback: ResizeValueCallback = () => true; + callback?: ResizeValueCallback; constructor( - host: ReactiveControllerHost, - {target, config, callback, skipInitial}: ResizeControllerConfig + host: ReactiveControllerHost & Element, + {target, config, callback, skipInitial}: ResizeControllerConfig ) { - (this._host = host).addController(this); + this._host = host; // Target defaults to `host` unless explicitly `null`. - this._target = - target === null ? target : target ?? (this._host as unknown as Element); + if (target !== null) { + this._targets.add(target ?? host); + } this._config = config; this._skipInitial = skipInitial ?? this._skipInitial; - this.callback = callback ?? this.callback; + this.callback = callback; // Check browser support. if (!window.ResizeObserver) { console.warn( @@ -103,6 +104,7 @@ export class ResizeController implements ReactiveController { this.handleChanges(entries); this._host.requestUpdate(); }); + host.addController(this); } /** @@ -110,12 +112,12 @@ export class ResizeController implements ReactiveController { * function to produce a result stored in the `value` property. */ protected handleChanges(entries: ResizeObserverEntry[]) { - this.value = this.callback(entries, this._observer); + this.value = this.callback?.(entries, this._observer); } hostConnected() { - if (this._target) { - this.observe(this._target); + for (const target of this._targets) { + this.observe(target); } } @@ -139,11 +141,21 @@ export class ResizeController implements ReactiveController { * @param target Element to observe */ observe(target: Element) { + this._targets.add(target); this._observer.observe(target, this._config); this._unobservedUpdate = true; this._host.requestUpdate(); } + /** + * Unobserve the target element. + * @param target Element to unobserve + */ + unobserve(target: Element) { + this._targets.delete(target); + this._observer.unobserve(target); + } + /** * Disconnects the observer. This is done automatically when the host * disconnects. diff --git a/packages/labs/observers/src/test/intersection_controller_test.ts b/packages/labs/observers/src/test/intersection_controller_test.ts index afeaeabc4e..d8867e22d0 100644 --- a/packages/labs/observers/src/test/intersection_controller_test.ts +++ b/packages/labs/observers/src/test/intersection_controller_test.ts @@ -12,6 +12,7 @@ import { import { IntersectionController, IntersectionControllerConfig, + IntersectionValueCallback, } from '@lit-labs/observers/intersection_controller.js'; import {generateElementName, nextFrame} from './test-helpers.js'; import {assert} from '@esm-bundle/chai'; @@ -24,10 +25,16 @@ if (DEV_MODE) { ReactiveElement.disableWarning?.('change-in-update'); } -// TODO: disable these tests until can figure out issues with Sauce Safari -// version. They do pass on latest Safari locally. -//(window.IntersectionObserver ? suite : suite.skip) -suite.skip('IntersectionController', () => { +const canTest = () => { + // TODO: disable tests on Sauce Safari until can figure out issues. + // The tests pass on latest Safari locally. + const isSafari = + navigator.userAgent.includes('Safari/') && + navigator.userAgent.includes('Version/'); + return !isSafari && window.IntersectionObserver; +}; + +(canTest() ? suite : suite.skip)('IntersectionController', () => { let container: HTMLElement; interface TestElement extends ReactiveElement { @@ -49,7 +56,10 @@ suite.skip('IntersectionController', () => { constructor() { super(); const config = getControllerConfig(this); - this.observer = new IntersectionController(this, config); + this.observer = new IntersectionController(this, { + callback: () => true, + ...config, + }); } override update(props: PropertyValues) { @@ -91,6 +101,8 @@ suite.skip('IntersectionController', () => { const intersectionComplete = async () => { await nextFrame(); await nextFrame(); + // For Firefox to pass tests we need a setTimeout. + await new Promise((resolve) => setTimeout(resolve, 0)); }; const intersectOut = (el: HTMLElement) => { @@ -337,6 +349,36 @@ suite.skip('IntersectionController', () => { assert.isTrue(el.observerValue); }); + test('observed targets are re-observed on host connected', async () => { + const el = await getTestElement(() => ({target: null})); + const d1 = document.createElement('div'); + const d2 = document.createElement('div'); + + el.renderRoot.appendChild(d1); + el.renderRoot.appendChild(d2); + + el.observer.observe(d1); + el.observer.observe(d2); + + await intersectionComplete(); + el.resetObserverValue(); + el.remove(); + + container.appendChild(el); + + // Reports change to first observed target. + el.resetObserverValue(); + intersectOut(d1); + await intersectionComplete(); + assert.isTrue(el.observerValue); + + // Reports change to second observed target. + el.resetObserverValue(); + intersectOut(d2); + await intersectionComplete(); + assert.isTrue(el.observerValue); + }); + test('observed target respects `skipInitial`', async () => { const el = await getTestElement(() => ({ target: null, @@ -358,7 +400,7 @@ suite.skip('IntersectionController', () => { assert.isTrue(el.observerValue); }); - test('observed target not re-observed on connection', async () => { + test('observed target re-observed on connection', async () => { const el = await getTestElement(() => ({ target: null, skipInitial: true, @@ -375,20 +417,145 @@ suite.skip('IntersectionController', () => { await intersectionComplete(); assert.isUndefined(el.observerValue); - // Does not report change when re-connected + // Reports changes when re-connected container.appendChild(el); await intersectionComplete(); assert.isUndefined(el.observerValue); intersectOut(d1); await intersectionComplete(); + assert.isTrue(el.observerValue); + }); + + test('can observe changes when initialized after host connected', async () => { + class TestFirstUpdated extends ReactiveElement { + observer!: IntersectionController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new IntersectionController(this, { + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), TestFirstUpdated); + const el = (await renderTestElement(TestFirstUpdated)) as TestFirstUpdated; + + // Reports initial change by default + assert.isTrue(el.observerValue); + + // Reports change when not intersecting + el.resetObserverValue(); + intersectOut(el); + await intersectionComplete(); + assert.isTrue(el.observerValue); + + // Reports change when intersecting + el.resetObserverValue(); assert.isUndefined(el.observerValue); + intersectIn(el); + await intersectionComplete(); + assert.isTrue(el.observerValue); + }); + + test('can observe external element after host connected', async () => { + const d = document.createElement('div'); + container.appendChild(d); + class A extends ReactiveElement { + observer!: IntersectionController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new IntersectionController(this, { + target: d, + skipInitial: true, + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), A); + const el = (await renderTestElement(A)) as A; - // Can re-observe after connection, respecting `skipInitial` + assert.equal(el.observerValue, undefined); + // Observe intersect out + intersectOut(d); + await intersectionComplete(); + assert.isTrue(el.observerValue); + el.resetObserverValue(); + + // Observe intersect in + intersectIn(d); + await intersectionComplete(); + assert.isTrue(el.observerValue); + }); + + test('IntersectionController type-checks', async () => { + // This test only checks compile-type behavior. There are no runtime checks. + const el = await getTestElement(); + const A = new IntersectionController(el, { + // @ts-expect-error Type 'string' is not assignable to type 'number' + callback: () => '', + }); + if (A) { + // Suppress no-unused-vars warnings + } + + const B = new IntersectionController(el, { + callback: () => '', + }); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + B.value = 2; + + const C = new IntersectionController( + el, + {} as IntersectionController + ); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + C.value = 3; + + const narrowTypeCb: IntersectionValueCallback = () => ''; + const D = new IntersectionController(el, {callback: narrowTypeCb}); + + D.value = null; + D.value = undefined; + D.value = ''; + // @ts-expect-error Type 'number' is not assignable to type 'string' + D.value = 3; + }); + + test('observed target can be unobserved', async () => { + const el = await getTestElement(() => ({target: null})); + const d1 = document.createElement('div'); + + // Reports initial changes when observe called. el.observer.observe(d1); + el.renderRoot.appendChild(d1); + await intersectionComplete(); + assert.isTrue(el.observerValue); + el.resetObserverValue(); + await intersectionComplete(); + + // Does not report change when unobserved + el.observer.unobserve(d1); + intersectOut(d1); await intersectionComplete(); assert.isUndefined(el.observerValue); + + el.remove(); + container.appendChild(el); + + // Does not report changes when re-connected intersectIn(d1); await intersectionComplete(); - assert.isTrue(el.observerValue); + assert.isUndefined(el.observerValue); }); }); diff --git a/packages/labs/observers/src/test/mutation_controller_test.ts b/packages/labs/observers/src/test/mutation_controller_test.ts index 4fb4b98655..da38ff9e24 100644 --- a/packages/labs/observers/src/test/mutation_controller_test.ts +++ b/packages/labs/observers/src/test/mutation_controller_test.ts @@ -12,6 +12,7 @@ import { import { MutationController, MutationControllerConfig, + MutationValueCallback, } from '@lit-labs/observers/mutation_controller.js'; import {generateElementName, nextFrame} from './test-helpers.js'; import {assert} from '@esm-bundle/chai'; @@ -53,7 +54,10 @@ const canTest = constructor() { super(); const config = getControllerConfig(this); - this.observer = new MutationController(this, config); + this.observer = new MutationController(this, { + callback: () => true, + ...config, + }); } override update(props: PropertyValues) { @@ -315,6 +319,36 @@ const canTest = assert.isTrue(el.observerValue); }); + test('observed targets are re-observed on host connected', async () => { + const el = await getTestElement(() => ({ + target: null, + config: {attributes: true}, + })); + el.resetObserverValue(); + const d1 = document.createElement('div'); + const d2 = document.createElement('div'); + + el.observer.observe(d1); + el.observer.observe(d2); + + await nextFrame(); + el.remove(); + + container.appendChild(el); + + // Reports change to first observed target. + el.resetObserverValue(); + d1.setAttribute('a', 'a'); + await nextFrame(); + assert.isTrue(el.observerValue); + + // Reports change to second observed target. + el.resetObserverValue(); + d2.setAttribute('a', 'a1'); + await nextFrame(); + assert.isTrue(el.observerValue); + }); + test('observed target respects `skipInitial`', async () => { const el = await getTestElement(() => ({ target: null, @@ -335,7 +369,7 @@ const canTest = assert.isTrue(el.observerValue); }); - test('observed target not re-observed on connection', async () => { + test('observed target re-observed on connection', async () => { const el = await getTestElement(() => ({ target: null, config: {attributes: true}, @@ -356,16 +390,131 @@ const canTest = await nextFrame(); assert.isUndefined(el.observerValue); - // Does not report change when re-connected + // Does report change when re-connected container.appendChild(el); d1.setAttribute('a', 'a1'); await nextFrame(); - assert.isUndefined(el.observerValue); + assert.isTrue(el.observerValue); // Can re-observe after connection. + el.resetObserverValue(); el.observer.observe(d1); d1.setAttribute('a', 'a2'); await nextFrame(); assert.isTrue(el.observerValue); }); + + test('can observe changes when initialized after host connected', async () => { + class TestFirstUpdated extends ReactiveElement { + observer!: MutationController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new MutationController(this, { + config: {attributes: true}, + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), TestFirstUpdated); + + const el = (await renderTestElement(TestFirstUpdated)) as TestFirstUpdated; + + // Reports initial change by default + assert.isTrue(el.observerValue); + + // Reports attribute change + el.resetObserverValue(); + el.setAttribute('hi', 'hi'); + await nextFrame(); + assert.isTrue(el.observerValue); + + // Reports another attribute change + el.resetObserverValue(); + el.requestUpdate(); + await nextFrame(); + assert.isUndefined(el.observerValue); + el.setAttribute('bye', 'bye'); + await nextFrame(); + assert.isTrue(el.observerValue); + }); + + test('can observe external element after host connected', async () => { + class A extends ReactiveElement { + observer!: MutationController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new MutationController(this, { + target: document.body, + config: {childList: true}, + skipInitial: true, + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), A); + + const el = (await renderTestElement(A)) as A; + assert.equal(el.observerValue, undefined); + const d = document.createElement('div'); + document.body.appendChild(d); + await nextFrame(); + assert.isTrue(el.observerValue); + el.resetObserverValue(); + d.remove(); + await nextFrame(); + assert.isTrue(el.observerValue); + }); + + test('MutationController type-checks', async () => { + // This test only checks compile-type behavior. There are no runtime checks. + const el = await getTestElement(() => ({ + target: null, + config: {attributes: true}, + })); + const A = new MutationController(el, { + // @ts-expect-error Type 'string' is not assignable to type 'number' + callback: () => '', + config: {attributes: true}, + }); + if (A) { + // Suppress no-unused-vars warnings + } + + const B = new MutationController(el, { + callback: () => '', + config: {attributes: true}, + }); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + B.value = 2; + + const C = new MutationController(el, { + config: {attributes: true}, + }) as MutationController; + // @ts-expect-error Type 'number' is not assignable to type 'string'. + C.value = 3; + + const narrowTypeCb: MutationValueCallback = () => ''; + const D = new MutationController(el, { + callback: narrowTypeCb, + config: {attributes: true}, + }); + + D.value = null; + D.value = undefined; + D.value = ''; + // @ts-expect-error Type 'number' is not assignable to type 'string' + D.value = 3; + }); }); diff --git a/packages/labs/observers/src/test/performance_controller_test.ts b/packages/labs/observers/src/test/performance_controller_test.ts index ac59923ffc..028c56eb51 100644 --- a/packages/labs/observers/src/test/performance_controller_test.ts +++ b/packages/labs/observers/src/test/performance_controller_test.ts @@ -12,6 +12,7 @@ import { import { PerformanceController, PerformanceControllerConfig, + PerformanceValueCallback, } from '@lit-labs/observers/performance_controller.js'; import {generateElementName, nextFrame} from './test-helpers.js'; import {assert} from '@esm-bundle/chai'; @@ -35,7 +36,7 @@ const generateMeasure = async (sync = false) => { const observerComplete = async (el?: HTMLElement) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (el as any)?.observer.flush(); + (el as any)?.observer?.flush(); await nextFrame(); await nextFrame(); }; @@ -51,9 +52,16 @@ const observerComplete = async (el?: HTMLElement) => { // return ok; // }; -// TODO: disable these tests until can figure out issues with Sauce Safari -// version. They do pass on latest Safari locally. -suite.skip('PerformanceController', () => { +const canTest = () => { + // TODO: disable tests on Sauce Safari until can figure out issues. + // The tests pass on latest Safari locally. + const isSafari = + navigator.userAgent.includes('Safari/') && + navigator.userAgent.includes('Version/'); + return !isSafari && window.PerformanceObserver; +}; + +(canTest() ? suite : suite.skip)('PerformanceController', () => { let container: HTMLElement; interface TestElement extends ReactiveElement { @@ -75,7 +83,10 @@ suite.skip('PerformanceController', () => { constructor() { super(); const config = getControllerConfig(this); - this.observer = new PerformanceController(this, config); + this.observer = new PerformanceController(this, { + callback: () => true, + ...config, + }); } override update(props: PropertyValues) { @@ -224,4 +235,84 @@ suite.skip('PerformanceController', () => { await observerComplete(el); assert.match(el.observerValue as string, /2:[\d]/); }); + + test('can observe changes when initialized after host connected', async () => { + class TestFirstUpdated extends ReactiveElement { + observer!: PerformanceController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new PerformanceController(this, { + config: {entryTypes: ['measure']}, + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), TestFirstUpdated); + const el = (await renderTestElement(TestFirstUpdated)) as TestFirstUpdated; + + // Reports initial change by default + assert.isTrue(el.observerValue); + + // Reports measure + el.resetObserverValue(); + await generateMeasure(); + await observerComplete(el); + assert.isTrue(el.observerValue); + + // Reports another measure after noop update + el.resetObserverValue(); + el.requestUpdate(); + await observerComplete(el); + assert.isUndefined(el.observerValue); + await generateMeasure(); + await observerComplete(el); + assert.isTrue(el.observerValue); + }); + + test('PerformanceController type-checks', async () => { + // This test only checks compile-type behavior. There are no runtime checks. + const el = await getTestElement((_host: ReactiveControllerHost) => ({ + config: {entryTypes: ['measure']}, + })); + const A = new PerformanceController(el, { + // @ts-expect-error Type 'string' is not assignable to type 'number' + callback: () => '', + config: {entryTypes: ['measure']}, + }); + if (A) { + // Suppress no-unused-vars warnings + } + + const B = new PerformanceController(el, { + callback: () => '', + config: {entryTypes: ['measure']}, + }); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + B.value = 2; + + const C = new PerformanceController(el, { + callback: () => '', + config: {entryTypes: ['measure']}, + }) as PerformanceController; + // @ts-expect-error Type 'number' is not assignable to type 'string'. + C.value = 3; + + const narrowTypeCb: PerformanceValueCallback = () => ''; + const D = new PerformanceController(el, { + callback: narrowTypeCb, + config: {entryTypes: ['measure']}, + }); + + D.value = null; + D.value = undefined; + D.value = ''; + // @ts-expect-error Type 'number' is not assignable to type 'string' + D.value = 3; + }); }); diff --git a/packages/labs/observers/src/test/resize_controller_test.ts b/packages/labs/observers/src/test/resize_controller_test.ts index 944ad24b3b..13b3efb138 100644 --- a/packages/labs/observers/src/test/resize_controller_test.ts +++ b/packages/labs/observers/src/test/resize_controller_test.ts @@ -12,6 +12,7 @@ import { import { ResizeController, ResizeControllerConfig, + ResizeValueCallback, } from '@lit-labs/observers/resize_controller.js'; import {generateElementName, nextFrame} from './test-helpers.js'; import {assert} from '@esm-bundle/chai'; @@ -46,7 +47,10 @@ if (DEV_MODE) { constructor() { super(); const config = getControllerConfig(this); - this.observer = new ResizeController(this, config); + this.observer = new ResizeController(this, { + callback: () => true, + ...config, + }); } override update(props: PropertyValues) { @@ -324,6 +328,36 @@ if (DEV_MODE) { assert.isTrue(el.observerValue); }); + test('observed targets are re-observed on host connected', async () => { + const el = await getTestElement(() => ({target: null})); + const d1 = document.createElement('div'); + const d2 = document.createElement('div'); + + el.renderRoot.appendChild(d1); + el.renderRoot.appendChild(d2); + + el.observer.observe(d1); + el.observer.observe(d2); + + await resizeComplete(); + el.resetObserverValue(); + el.remove(); + + container.appendChild(el); + + // Reports change to first observed target. + el.resetObserverValue(); + resizeElement(d1); + await resizeComplete(); + assert.isTrue(el.observerValue); + + // Reports change to second observed target. + el.resetObserverValue(); + resizeElement(d2); + await resizeComplete(); + assert.isTrue(el.observerValue); + }); + test('observed target respects `skipInitial`', async () => { const el = await getTestElement(() => ({ target: null, @@ -344,7 +378,7 @@ if (DEV_MODE) { assert.isTrue(el.observerValue); }); - test('observed target not re-observed on connection', async () => { + test('observed target re-observed on connection', async () => { const el = await getTestElement(() => ({target: null})); const d1 = document.createElement('div'); @@ -357,21 +391,140 @@ if (DEV_MODE) { await resizeComplete(); el.remove(); - // Does not reports change when disconnected. + // Does not report change when disconnected. resizeElement(d1); await resizeComplete(); assert.isUndefined(el.observerValue); - // Does not report change when re-connected + // Reports change when re-connected container.appendChild(el); resizeElement(d1); await resizeComplete(); + assert.isTrue(el.observerValue); + }); + + test('can observe changes when initialized after host connected', async () => { + class TestFirstUpdated extends ReactiveElement { + observer!: ResizeController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new ResizeController(this, { + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), TestFirstUpdated); + + const el = (await renderTestElement(TestFirstUpdated)) as TestFirstUpdated; + + // Reports initial change by default + assert.isTrue(el.observerValue); + + // Reports attribute change + el.resetObserverValue(); assert.isUndefined(el.observerValue); + resizeElement(el); + await resizeComplete(); + assert.isTrue(el.observerValue); + }); - // Can re-observe after connection. + test('can observe external element after host connected', async () => { + const d = document.createElement('div'); + container.appendChild(d); + class A extends ReactiveElement { + observer!: ResizeController; + observerValue: true | undefined = undefined; + override firstUpdated() { + this.observer = new ResizeController(this, { + target: d, + skipInitial: true, + callback: () => true, + }); + } + override updated() { + this.observerValue = this.observer.value; + } + resetObserverValue() { + this.observer.value = this.observerValue = undefined; + } + } + customElements.define(generateElementName(), A); + const el = (await renderTestElement(A)) as A; + + assert.equal(el.observerValue, undefined); + resizeElement(d); + await resizeComplete(); + assert.isTrue(el.observerValue); + + // Change again + el.resetObserverValue(); + assert.equal(el.observerValue, undefined); + resizeElement(d); + await resizeComplete(); + assert.isTrue(el.observerValue); + }); + + test('ResizeController type-checks', async () => { + // This test only checks compile-type behavior. There are no runtime checks. + const el = await getTestElement(); + const A = new ResizeController(el, { + // @ts-expect-error Type 'string' is not assignable to type 'number' + callback: () => '', + }); + if (A) { + // Suppress no-unused-vars warnings + } + + const B = new ResizeController(el, { + callback: () => '', + }); + // @ts-expect-error Type 'number' is not assignable to type 'string'. + B.value = 2; + + const C = new ResizeController(el, {}) as ResizeController; + // @ts-expect-error Type 'number' is not assignable to type 'string'. + C.value = 3; + + const narrowTypeCb: ResizeValueCallback = () => ''; + const D = new ResizeController(el, {callback: narrowTypeCb}); + + D.value = null; + D.value = undefined; + D.value = ''; + // @ts-expect-error Type 'number' is not assignable to type 'string' + D.value = 3; + }); + + test('observed target can be unobserved', async () => { + const el = await getTestElement(() => ({target: null})); + const d1 = document.createElement('div'); + + // Reports initial changes when observe called. el.observer.observe(d1); - resizeElement(d1); + el.renderRoot.appendChild(d1); await resizeComplete(); assert.isTrue(el.observerValue); + el.resetObserverValue(); + await resizeComplete(); + + // Does not report change when unobserved + el.observer.unobserve(d1); + resizeElement(d1); + await resizeComplete(); + assert.isUndefined(el.observerValue); + + el.remove(); + container.appendChild(el); + + // Does not report changes when re-connected + resizeElement(d1); + await resizeComplete(); + assert.isUndefined(el.observerValue); }); }); diff --git a/packages/labs/react/CHANGELOG.md b/packages/labs/react/CHANGELOG.md index 6a15874ea8..accbaa71f6 100644 --- a/packages/labs/react/CHANGELOG.md +++ b/packages/labs/react/CHANGELOG.md @@ -1,5 +1,19 @@ # Change Log +## 1.1.0 + +### Minor Changes + +- [#2988](https://github.com/lit/lit/pull/2988) [`2d10c26d`](https://github.com/lit/lit/commit/2d10c26d6c526faafacc5d28d0f70f671e72560d) - Provide a params object to createComponent to improve developer experience and make it easier to maintain and add future features. + +- [#3128](https://github.com/lit/lit/pull/3128) [`491d0e37`](https://github.com/lit/lit/commit/491d0e379dda03787de088b0c4a74b5234ac4940) - Application of react props on web components matches the behavior of setting props on dom elements. + +## 1.0.9 + +### Patch Changes + +- [#3163](https://github.com/lit/lit/pull/3163) [`1212ddd0`](https://github.com/lit/lit/commit/1212ddd0744529c294ea3905782917172c5aa11e) - Provide the explicit return type `WrappedWebComponent` for `createComponent`. This exposes an explicit typing for wrapped components rather than relying on inferences from Typescript. A well defined type should provide more resilience for implementations like SSR and others. + ## 1.0.8 ### Patch Changes diff --git a/packages/labs/react/README.md b/packages/labs/react/README.md index 247d034e82..0624b0ba91 100644 --- a/packages/labs/react/README.md +++ b/packages/labs/react/README.md @@ -34,15 +34,15 @@ import * as React from 'react'; import {createComponent} from '@lit-labs/react'; import {MyElement} from './my-element.js'; -export const MyElementComponent = createComponent( - React, - 'my-element', - MyElement, - { +export const MyElementComponent = createComponent({ + tagName: 'my-element', + elementClass: MyElement, + react: React, + events: { onactivate: 'activate', onchange: 'change', - } -); + }, +}); ``` After defining the React component, you can use it just as you would any other @@ -70,15 +70,15 @@ import * as React from 'react'; import {createComponent} from '@lit-labs/react'; import {MyElement} from './my-element.js'; -export const MyElementComponent = createComponent( - React, - 'my-element', - MyElement, - { +export const MyElementComponent = createComponent({ + tagName: 'my-element', + elementClass: MyElement, + react: React, + events: { onClick: 'pointerdown' as EventName, onChange: 'input', - } -); + }, +}); ``` Event callbacks will match their type cast. In the example below, a diff --git a/packages/labs/react/package.json b/packages/labs/react/package.json index 018e8e44a3..5d00fad2ba 100644 --- a/packages/labs/react/package.json +++ b/packages/labs/react/package.json @@ -1,6 +1,6 @@ { "name": "@lit-labs/react", - "version": "1.0.8", + "version": "1.1.0", "description": "A React component wrapper for web components.", "license": "BSD-3-Clause", "homepage": "https://lit.dev/", diff --git a/packages/labs/react/src/create-component.ts b/packages/labs/react/src/create-component.ts index 11729de890..e6b3e780b2 100644 --- a/packages/labs/react/src/create-component.ts +++ b/packages/labs/react/src/create-component.ts @@ -4,6 +4,67 @@ * SPDX-License-Identifier: BSD-3-Clause */ +// Match a prop name to a typed event callback by +// adding an Event type as an expected property on a string. +export type EventName = string & { + __event_type: T; +}; + +// A key value map matching React prop names to event names +type EventNames = Record; + +// A map of expected event listener types based on EventNames +type EventListeners = { + [K in keyof R]: R[K] extends EventName + ? (e: R[K]['__event_type']) => void + : (e: Event) => void; +}; + +type ReactProps = Omit, keyof E>; +type ElementWithoutPropsOrEventListeners = Omit< + I, + keyof E | keyof ReactProps +>; + +// Props the user is allowed to use, includes standard attributes, children, +// ref, as well as special event and element properties. +export type WebComponentProps< + I extends HTMLElement, + E extends EventNames = {} +> = Partial< + ReactProps & + ElementWithoutPropsOrEventListeners & + EventListeners +>; + +// Props used by this component wrapper. This is the WebComponentProps and the +// special `__forwardedRef` property. Note, this ref is special because +// it's both needed in this component to get access to the rendered element +// and must fulfill any ref passed by the user. +type ReactComponentProps< + I extends HTMLElement, + E extends EventNames = {} +> = WebComponentProps & { + __forwardedRef: React.Ref; +}; + +export type ReactWebComponent< + I extends HTMLElement, + E extends EventNames = {} +> = React.ForwardRefExoticComponent< + React.PropsWithoutRef> & React.RefAttributes +>; + +interface Options { + tagName: string; + elementClass: Constructor; + react: typeof window.React; + events?: E; + displayName?: string; +} + +type Constructor = {new (): T}; + const reservedReactProperties = new Set([ 'children', 'localName', @@ -58,49 +119,74 @@ const setProperty = ( name: string, value: unknown, old: unknown, - events?: Events + events?: EventNames ) => { const event = events?.[name]; - if (event !== undefined) { + if (event !== undefined && value !== old) { // Dirty check event value. - if (value !== old) { - addOrUpdateEventListener(node, event, value as (e?: Event) => void); - } - } else { - // But don't dirty check properties; elements are assumed to do this. - node[name as keyof E] = value as E[keyof E]; + addOrUpdateEventListener(node, event, value as (e?: Event) => void); + return; + } + + // Note, the attribute removal here for `undefined` and `null` values is done + // to match React's behavior on non-custom elements. It needs special + // handling because it does not match platform behavior. For example, + // setting the `id` property to `undefined` sets the attribute to the string + // "undefined." React "fixes" that odd behavior and the code here matches + // React's convention. + if ( + (value === undefined || value === null) && + name in HTMLElement.prototype + ) { + node.removeAttribute(name); + return; } + + // But don't dirty check properties; elements are assumed to do this. + node[name as keyof E] = value as E[keyof E]; }; // Set a React ref. Note, there are 2 kinds of refs and there's no built in // React API to set a ref. const setRef = (ref: React.Ref, value: Element | null) => { if (typeof ref === 'function') { - (ref as (e: Element | null) => void)(value); + ref(value); } else { (ref as {current: Element | null}).current = value; } }; -type Constructor = {new (): T}; - -/*** - * Typecast that curries an Event type through a string. The goal of the type - * cast is to match a prop name to a typed event callback. +/** + * Creates a React component for a custom element. Properties are distinguished + * from attributes automatically, and events can be configured so they are + * added to the custom element as event listeners. + * + * @param options An options bag containing the parameters needed to generate + * a wrapped web component. + * + * @param options.react The React module, typically imported from the `react` npm + * package. + * @param options.tagName The custom element tag name registered via + * `customElements.define`. + * @param options.elementClass The custom element class registered via + * `customElements.define`. + * @param options.events An object listing events to which the component can listen. The + * object keys are the event property names passed in via React props and the + * object values are the names of the corresponding events generated by the + * custom element. For example, given `{onactivate: 'activate'}` an event + * function may be passed via the component's `onactivate` prop and will be + * called when the custom element fires its `activate` event. + * @param options.displayName A React component display name, used in debugging + * messages. Default value is inferred from the name of custom element class + * registered via `customElements.define`. */ -export type EventName = string & { - __event_type: T; -}; - -type Events = Record; - -type EventProps = { - [K in keyof R]: R[K] extends EventName - ? (e: R[K]['__event_type']) => void - : (e: Event) => void; -}; - +export function createComponent< + I extends HTMLElement, + E extends EventNames = {} +>(options: Options): ReactWebComponent; /** + * @deprecated Use `createComponent(options)` instead of individual arguments. + * * Creates a React component for a custom element. Properties are distinguished * from attributes automatically, and events can be configured so they are * added to the custom element as event listeners. @@ -121,42 +207,55 @@ type EventProps = { * messages. Default value is inferred from the name of custom element class * registered via `customElements.define`. */ -export const createComponent = ( - React: typeof window.React, +export function createComponent< + I extends HTMLElement, + E extends EventNames = {} +>( + ReactOrOptions: typeof window.React, tagName: string, elementClass: Constructor, events?: E, displayName?: string -) => { +): ReactWebComponent; +export function createComponent< + I extends HTMLElement, + E extends EventNames = {} +>( + ReactOrOptions: typeof window.React | Options = window.React, + tagName?: string, + elementClass?: Constructor, + events?: E, + displayName?: string +): ReactWebComponent { + // digest overloaded parameters + let React: typeof window.React; + let tag: string; + let element: Constructor; + if (tagName === undefined) { + const options = ReactOrOptions as Options; + ({tagName: tag, elementClass: element, events, displayName} = options); + React = options.react; + } else { + React = ReactOrOptions as typeof window.React; + element = elementClass as Constructor; + tag = tagName; + } + const Component = React.Component; const createElement = React.createElement; const eventProps = new Set(Object.keys(events ?? {})); - // Props the user is allowed to use, includes standard attributes, children, - // ref, as well as special event and element properties. - type ReactProps = Omit, keyof E>; - type ElementWithoutPropsOrEvents = Omit; - type UserProps = Partial< - ReactProps & ElementWithoutPropsOrEvents & EventProps - >; + type Props = ReactComponentProps; - // Props used by this component wrapper. This is the UserProps and the - // special `__forwardedRef` property. Note, this ref is special because - // it's both needed in this component to get access to the rendered element - // and must fulfill any ref passed by the user. - type ComponentProps = UserProps & { - __forwardedRef?: React.Ref; - }; - - class ReactComponent extends Component { + class ReactComponent extends Component { private _element: I | null = null; - private _elementProps!: {[index: string]: unknown}; - private _userRef?: React.Ref; + private _elementProps!: Record; + private _forwardedRef?: React.Ref; private _ref?: React.RefCallback; - static displayName = displayName ?? elementClass.name; + static displayName = displayName ?? element.name; - private _updateElement(oldProps?: ComponentProps) { + private _updateElement(oldProps?: Props) { if (this._element === null) { return; } @@ -165,8 +264,8 @@ export const createComponent = ( setProperty( this._element, prop, - this.props[prop as keyof ComponentProps], - oldProps ? oldProps[prop as keyof ComponentProps] : undefined, + this.props[prop], + oldProps ? oldProps[prop] : undefined, events ); } @@ -187,7 +286,7 @@ export const createComponent = ( * Updates element properties correctly setting properties * on every update. Note, this does not include mount. */ - override componentDidUpdate(old: ComponentProps) { + override componentDidUpdate(old: Props) { this._updateElement(old); } @@ -200,60 +299,60 @@ export const createComponent = ( * */ override render() { - // Since refs only get fulfilled once, pass a new one if the user's - // ref changed. This allows refs to be fulfilled as expected, going from + // Extract and remove __forwardedRef from userProps in a rename-safe way + const {__forwardedRef, ...userProps} = this.props; + // Since refs only get fulfilled once, pass a new one if the user's ref + // changed. This allows refs to be fulfilled as expected, going from // having a value to null. - const userRef = this.props.__forwardedRef as React.Ref; - if (this._ref === undefined || this._userRef !== userRef) { + if (this._forwardedRef !== __forwardedRef) { this._ref = (value: I | null) => { - if (this._element === null) { - this._element = value; - } - if (userRef !== null) { - setRef(userRef, value); + if (__forwardedRef !== null) { + setRef(__forwardedRef, value); } - this._userRef = userRef; + + this._element = value; + this._forwardedRef = __forwardedRef; }; } - // Filters class properties out and passes the remaining - // attributes to React. This allows attributes to use framework rules - // for setting attributes and render correctly under SSR. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const props: any = {ref: this._ref}; - // Note, save element props while iterating to avoid the need to - // iterate again when setting properties. + // Save element props while iterating to avoid the need to iterate again + // when setting properties. this._elementProps = {}; - for (const [k, v] of Object.entries(this.props)) { - if (k === '__forwardedRef') continue; - - if ( - eventProps.has(k) || - (!reservedReactProperties.has(k) && - !(k in HTMLElement.prototype) && - k in elementClass.prototype) - ) { - this._elementProps[k] = v; - } else { + const props: Record = {ref: this._ref}; + // Filters class properties and event properties out and passes the + // remaining attributes to React. This allows attributes to use framework + // rules for setting attributes and render correctly under SSR. + for (const [k, v] of Object.entries(userProps)) { + if (reservedReactProperties.has(k)) { // React does *not* handle `className` for custom elements so // coerce it to `class` so it's handled correctly. props[k === 'className' ? 'class' : k] = v; + continue; + } + + if (eventProps.has(k) || k in element.prototype) { + this._elementProps[k] = v; + continue; } + + props[k] = v; } - return createElement(tagName, props); + return createElement, I>(tag, props); } } - const ForwardedComponent = React.forwardRef( - (props?: UserProps, ref?: React.Ref) => - createElement( - ReactComponent, - {...props, __forwardedRef: ref} as ComponentProps, - props?.children - ) + const ForwardedComponent: ReactWebComponent = React.forwardRef< + I, + WebComponentProps + >((props, __forwardedRef) => + createElement( + ReactComponent, + {...props, __forwardedRef}, + props?.children + ) ); // To ease debugging in the React Developer Tools ForwardedComponent.displayName = ReactComponent.displayName; return ForwardedComponent; -}; +} diff --git a/packages/labs/react/src/test/create-component_test.tsx b/packages/labs/react/src/test/create-component_test.tsx index 6032dc9b37..667a348105 100644 --- a/packages/labs/react/src/test/create-component_test.tsx +++ b/packages/labs/react/src/test/create-component_test.tsx @@ -4,12 +4,11 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import type {EventName} from '@lit-labs/react'; +import type {EventName, ReactWebComponent, WebComponentProps} from '@lit-labs/react'; import {ReactiveElement} from '@lit/reactive-element'; import {property} from '@lit/reactive-element/decorators/property.js'; import {customElement} from '@lit/reactive-element/decorators/custom-element.js'; -import type * as ReactModule from 'react'; import 'react/umd/react.development.js'; import 'react-dom/umd/react-dom.development.js'; import {createComponent} from '@lit-labs/react'; @@ -18,12 +17,28 @@ import {assert} from '@esm-bundle/chai'; // Needed for JSX expressions const React = window.React; +declare global { + interface HTMLElementTagNameMap { + [tagName]: BasicElement; + 'x-foo': XFoo, + } + + namespace JSX { + interface IntrinsicElements { + "x-foo": WebComponentProps, + } + } +} + interface Foo { foo?: boolean; } -const elementName = 'basic-element'; -@customElement(elementName) +@customElement('x-foo') +class XFoo extends ReactiveElement {} + +const tagName = 'basic-element'; +@customElement(tagName) class BasicElement extends ReactiveElement { @property({type: Boolean}) bool = false; @@ -36,6 +51,10 @@ class BasicElement extends ReactiveElement { @property({type: Array}) arr: unknown[] | null | undefined = null; + // override a default property + @property({type: Boolean}) + disabled = false; + @property({type: Boolean, reflect: true}) rbool = false; @property({type: String, reflect: true}) @@ -63,12 +82,6 @@ class BasicElement extends ReactiveElement { } } -declare global { - interface HTMLElementTagNameMap { - [elementName]: BasicElement; - } -} - suite('createComponent', () => { let container: HTMLElement; @@ -88,24 +101,34 @@ suite('createComponent', () => { onBar: 'bar', }; - const BasicElementComponent = createComponent( - window.React, - elementName, - BasicElement, - basicElementEvents - ); + // if some tag, run options + // otherwise + const BasicElementComponent = createComponent({ + react: window.React, + elementClass: BasicElement, + events: basicElementEvents, + tagName, + }); - let el: BasicElement; + let el: HTMLDivElement; + let wrappedEl: BasicElement; const renderReactComponent = async ( - props?: ReactModule.ComponentProps + props?: React.ComponentProps ) => { window.ReactDOM.render( - , + <> +
)}/>, + + , + , container ); - el = container.querySelector(elementName)! as BasicElement; - await el.updateComplete; + + el = container.querySelector('div')!; + wrappedEl = container.querySelector(tagName)! as BasicElement; + + await wrappedEl.updateComplete; }; /* @@ -113,11 +136,11 @@ suite('createComponent', () => { when events are not provided to `createComponent`. */ test('renders element without optional event map', async () => { - const ComponentWithoutEventMap = createComponent( - window.React, - elementName, - BasicElement, - ); + const ComponentWithoutEventMap = createComponent({ + react: window.React, + elementClass: BasicElement, + tagName, + }); const name = 'Component without event map.'; window.ReactDOM.render( @@ -125,10 +148,26 @@ suite('createComponent', () => { container ); - el = container.querySelector(elementName)! as BasicElement; - await el.updateComplete; + const elWithoutMap = container.querySelector(tagName)! as BasicElement; + await elWithoutMap.updateComplete; - assert.equal(el.textContent, 'Component without event map.'); + assert.equal(elWithoutMap.textContent, 'Component without event map.'); + }); + + /* + The following test is a type-only test. + */ + test('renders element with expected type', async () => { + type TypedComponent = ReactWebComponent; + + let TypedBasicElement!: TypedComponent; + + // If this test fails, we can assume types are broken. + // If this test passes, we can assume types are working + // because a bool !== 'string'. + // + // @ts-expect-error + }); test('works with text children', async () => { @@ -137,53 +176,54 @@ suite('createComponent', () => { Hello {name}, container ); - el = container.querySelector(elementName)! as BasicElement; - await el.updateComplete; - assert.equal(el.textContent, 'Hello World'); + + const elWithChildren = container.querySelector(tagName)! as BasicElement; + await elWithChildren.updateComplete; + + assert.equal(elWithChildren.textContent, 'Hello World'); }); test('has valid displayName', () => { assert.equal(BasicElementComponent.displayName, 'BasicElement'); - const NamedComponent = createComponent( - window.React, - elementName, - BasicElement, - basicElementEvents, - 'FooBar' - ); + const NamedComponent = createComponent({ + react: window.React, + elementClass: BasicElement, + events: basicElementEvents, + displayName: 'FooBar', + tagName, + }); assert.equal(NamedComponent.displayName, 'FooBar'); }); test('wrapper renders custom element that updates', async () => { await renderReactComponent(); - assert.isOk(el); - assert.isOk(el.hasUpdated); + assert.isOk(wrappedEl); + assert.isOk(wrappedEl.hasUpdated); }); test('can get ref to element', async () => { - const elementRef1 = window.React.createRef(); + const elementRef1 = window.React.createRef(); renderReactComponent({ref: elementRef1}); - assert.equal(elementRef1.current, el); - const elementRef2 = window.React.createRef(); + assert.equal(elementRef1.current, wrappedEl); + const elementRef2 = window.React.createRef(); renderReactComponent({ref: elementRef2}); assert.equal(elementRef1.current, null); - assert.equal(elementRef2.current, el); + assert.equal(elementRef2.current, wrappedEl); renderReactComponent({ref: elementRef1}); - assert.equal(elementRef1.current, el); + assert.equal(elementRef1.current, wrappedEl); assert.equal(elementRef2.current, null); }); test('ref does not create new attribute on element', async () => { await renderReactComponent({ref: undefined}); - const el = container.querySelector(elementName); - const outerHTML = el?.outerHTML; - const elementRef1 = window.React.createRef(); + const outerHTML = wrappedEl?.outerHTML; + const elementRef1 = window.React.createRef(); await renderReactComponent({ref: elementRef1}); - const elAfterRef = container.querySelector(elementName); + const elAfterRef = container.querySelector(tagName); const outerHTMLAfterRef = elAfterRef?.outerHTML; assert.equal(outerHTML, outerHTMLAfterRef); @@ -195,104 +235,189 @@ suite('createComponent', () => { const ref2Calls: Array = []; const refCb2 = (e: Element | null) => ref2Calls.push(e?.localName); renderReactComponent({ref: refCb1}); - assert.deepEqual(ref1Calls, [elementName]); + assert.deepEqual(ref1Calls, ["div", "x-foo", tagName]); renderReactComponent({ref: refCb2}); - assert.deepEqual(ref1Calls, [elementName, undefined]); - assert.deepEqual(ref2Calls, [elementName]); + assert.deepEqual(ref1Calls, ["div", "x-foo", tagName, undefined, undefined, undefined]); + assert.deepEqual(ref2Calls, ["div", "x-foo", tagName]); renderReactComponent({ref: refCb1}); - assert.deepEqual(ref1Calls, [elementName, undefined, elementName]); - assert.deepEqual(ref2Calls, [elementName, undefined]); + assert.deepEqual(ref1Calls, ["div", "x-foo", tagName, undefined, undefined, undefined, "div", "x-foo", tagName]); + assert.deepEqual(ref2Calls, ["div", "x-foo", tagName, undefined, undefined, undefined]); }); test('can set attributes', async () => { + await renderReactComponent({}); + assert.equal(el.getAttribute('id'), null); + assert.equal(el.id, ''); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); + await renderReactComponent({id: 'id'}); assert.equal(el.getAttribute('id'), 'id'); + assert.equal(el.id, 'id'); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); + await renderReactComponent({id: undefined}); assert.equal(el.getAttribute('id'), null); + assert.equal(el.id, ''); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); + await renderReactComponent({id: 'id2'}); assert.equal(el.getAttribute('id'), 'id2'); + assert.equal(el.id, 'id2'); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); + + // @ts-expect-error + await renderReactComponent({id: null}); + assert.equal(el.getAttribute('id'), null); + assert.equal(el.id, ''); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); + + await renderReactComponent({id: 'id3'}); + assert.equal(el.getAttribute('id'), 'id3'); + assert.equal(el.id, 'id3'); + assert.equal(el.getAttribute('id'), wrappedEl.getAttribute('id')); + assert.equal(el.id, wrappedEl.id); }); - test('can set properties', async () => { - let o = {foo: true}; - let a = [1, 2, 3]; - await renderReactComponent({ - bool: true, - str: 'str', - num: 5, - obj: o, - arr: a, - customAccessors: o - }); - assert.equal(el.bool, true); - assert.equal(el.str, 'str'); - assert.equal(el.num, 5); - assert.deepEqual(el.obj, o); - assert.deepEqual(el.arr, a); - assert.deepEqual(el.customAccessors, o); - const firstEl = el; - // update - o = {foo: false}; - a = [1, 2, 3, 4]; - await renderReactComponent({ - bool: false, - str: 'str2', - num: 10, - obj: o, - arr: a, - customAccessors: o - }); - assert.equal(firstEl, el); - assert.equal(el.bool, false); - assert.equal(el.str, 'str2'); - assert.equal(el.num, 10); - assert.deepEqual(el.obj, o); - assert.deepEqual(el.arr, a); - assert.deepEqual(el.customAccessors, o); + test('sets boolean attributes', async () => { + await renderReactComponent({}); + assert.equal(el.getAttribute('hidden'), null); + assert.equal(el.hidden, false); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: true}); + assert.equal(wrappedEl.getAttribute('hidden'), ''); + assert.equal(wrappedEl.hidden, true); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: false}); + assert.equal(wrappedEl.getAttribute('hidden'), null); + assert.equal(wrappedEl.hidden, false); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: true}); + assert.equal(wrappedEl.getAttribute('hidden'), ''); + assert.equal(wrappedEl.hidden, true); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + // @ts-expect-error + await renderReactComponent({hidden: null}); + assert.equal(wrappedEl.getAttribute('hidden'), null); + assert.equal(wrappedEl.hidden, false); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: true}); + assert.equal(wrappedEl.getAttribute('hidden'), ''); + assert.equal(wrappedEl.hidden, true); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: undefined}); + assert.equal(el.getAttribute('hidden'), null); + assert.equal(el.hidden, false); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); + + await renderReactComponent({hidden: true}); + assert.equal(wrappedEl.getAttribute('hidden'), ''); + assert.equal(wrappedEl.hidden, true); + assert.equal(el.getAttribute('hidden'), wrappedEl.getAttribute('hidden')); + assert.equal(el.hidden, wrappedEl.hidden); }); - test('can set properties that reflect', async () => { - let o = {foo: true}; - let a = [1, 2, 3]; - await renderReactComponent({ - rbool: true, - rstr: 'str', - rnum: 5, - robj: o, - rarr: a, - }); - const firstEl = el; - assert.equal(el.rbool, true); - assert.equal(el.rstr, 'str'); - assert.equal(el.rnum, 5); - assert.deepEqual(el.robj, o); - assert.deepEqual(el.rarr, a); - assert.equal(el.getAttribute('rbool'), ''); - assert.equal(el.getAttribute('rstr'), 'str'); - assert.equal(el.getAttribute('rnum'), '5'); - assert.equal(el.getAttribute('robj'), '{"foo":true}'); - assert.equal(el.getAttribute('rarr'), '[1,2,3]'); - // update - o = {foo: false}; - a = [1, 2, 3, 4]; - await renderReactComponent({ - rbool: false, - rstr: 'str2', - rnum: 10, - robj: o, - rarr: a, - }); - assert.equal(firstEl, el); - assert.equal(el.rbool, false); - assert.equal(el.rstr, 'str2'); - assert.equal(el.rnum, 10); - assert.deepEqual(el.robj, o); - assert.deepEqual(el.rarr, a); - assert.equal(el.getAttribute('rbool'), null); - assert.equal(el.getAttribute('rstr'), 'str2'); - assert.equal(el.getAttribute('rnum'), '10'); - assert.equal(el.getAttribute('robj'), '{"foo":false}'); - assert.equal(el.getAttribute('rarr'), '[1,2,3,4]'); + test('sets enumerated attributes', async () => { + await renderReactComponent({}); + assert.equal(el.getAttribute('draggable'), null); + assert.equal(el.draggable, false); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: true}); + assert.equal(el.getAttribute('draggable'), 'true'); + assert.equal(el.draggable, true); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: false}); + assert.equal(el.getAttribute('draggable'), 'false'); + assert.equal(el.draggable, false); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: true}); + assert.equal(el.getAttribute('draggable'), 'true'); + assert.equal(el.draggable, true); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + // @ts-expect-error + await renderReactComponent({draggable: null}); + assert.equal(el.getAttribute('draggable'), null); + assert.equal(el.draggable, false); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: true}); + assert.equal(el.getAttribute('draggable'), 'true'); + assert.equal(el.draggable, true); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: undefined}); + assert.equal(el.getAttribute('draggable'), null); + assert.equal(el.draggable, false); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + + await renderReactComponent({draggable: true}); + assert.equal(el.getAttribute('draggable'), 'true'); + assert.equal(el.draggable, true); + assert.equal(el.getAttribute('draggable'), wrappedEl.getAttribute('draggable')); + assert.equal(el.draggable, wrappedEl.draggable); + }); + + test('sets boolean aria attributes', async () => { + await renderReactComponent({}); + assert.equal(el.getAttribute('aria-checked'), null); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': true}); + assert.equal(el.getAttribute('aria-checked'), 'true'); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': false}); + assert.equal(el.getAttribute('aria-checked'), 'false'); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': true}); + assert.equal(el.getAttribute('aria-checked'), 'true'); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + // @ts-expect-error + await renderReactComponent({'aria-checked': null}); + assert.equal(el.getAttribute('aria-checked'), null); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': true}); + assert.equal(el.getAttribute('aria-checked'), 'true'); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': undefined}); + assert.equal(el.getAttribute('aria-checked'), null); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); + + await renderReactComponent({'aria-checked': true}); + assert.equal(el.getAttribute('aria-checked'), 'true'); + assert.equal(el.getAttribute('aria-checked'), wrappedEl.getAttribute('aria-checked')); }); test('can listen to events', async () => { @@ -312,34 +437,34 @@ suite('createComponent', () => { onFoo, onBar, }); - el.fire('foo'); + wrappedEl.fire('foo'); assert.equal(fooEvent!.type, 'foo'); - el.fire('bar'); + wrappedEl.fire('bar'); assert.equal(barEvent!.type, 'bar'); fooEvent = undefined; barEvent = undefined; await renderReactComponent({ onFoo: undefined, }); - el.fire('foo'); + wrappedEl.fire('foo'); assert.equal(fooEvent, undefined); - el.fire('bar'); + wrappedEl.fire('bar'); assert.equal(barEvent!.type, 'bar'); fooEvent = undefined; barEvent = undefined; await renderReactComponent({ onFoo, }); - el.fire('foo'); + wrappedEl.fire('foo'); assert.equal(fooEvent!.type, 'foo'); - el.fire('bar'); + wrappedEl.fire('bar'); assert.equal(barEvent!.type, 'bar'); await renderReactComponent({ onFoo: onFoo2, }); fooEvent = undefined; fooEvent2 = undefined; - el.fire('foo'); + wrappedEl.fire('foo'); assert.equal(fooEvent, undefined); assert.equal(fooEvent2!.type, 'foo'); await renderReactComponent({ @@ -347,7 +472,7 @@ suite('createComponent', () => { }); fooEvent = undefined; fooEvent2 = undefined; - el.fire('foo'); + wrappedEl.fire('foo'); assert.equal(fooEvent!.type, 'foo'); assert.equal(fooEvent2, undefined); }); @@ -359,20 +484,20 @@ suite('createComponent', () => { clickEvent = e; }, }); - el.click(); + wrappedEl.click(); assert.equal(clickEvent?.type, 'click'); }); test('can set children', async () => { - const children = (window.React.createElement( + const children = window.React.createElement( 'div' // Note, constructing children like this is rare and the React type expects // this to be an HTMLCollection even though that's not the output of // `createElement`. - ) as unknown) as HTMLCollection; + ); await renderReactComponent({children}); - assert.equal(el.childNodes.length, 1); - assert.equal(el.firstElementChild!.localName, 'div'); + assert.equal(wrappedEl.childNodes.length, 1); + assert.equal(wrappedEl.firstElementChild!.localName, 'div'); }); test('can set reserved React properties', async () => { @@ -380,7 +505,7 @@ suite('createComponent', () => { style: {display: 'block'}, className: 'foo bar', } as any); - assert.equal(el.style.display, 'block'); - assert.equal(el.getAttribute('class'), 'foo bar'); + assert.equal(wrappedEl.style.display, 'block'); + assert.equal(wrappedEl.getAttribute('class'), 'foo bar'); }); }); diff --git a/packages/labs/react/tsconfig.json b/packages/labs/react/tsconfig.json index 126d2ba49e..1718481eb8 100644 --- a/packages/labs/react/tsconfig.json +++ b/packages/labs/react/tsconfig.json @@ -21,8 +21,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "jsx": "react", - "noImplicitOverride": true, - "types": ["react", "react-dom", "mocha"] + "noImplicitOverride": true }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": [] diff --git a/packages/labs/router/README.md b/packages/labs/router/README.md index 34c567b35d..f3eda7643c 100644 --- a/packages/labs/router/README.md +++ b/packages/labs/router/README.md @@ -19,7 +19,7 @@ class MyElement extends LitElement { private _routes = new Routes(this, [ {path: '/', render: () => html`

Home

`}, {path: '/projects', render: () => html`

Projects

`}, - {path: '/about', render: () => html`

About

`}, + {path: '/about', render: () => html`

About

`}, ]); render() { diff --git a/packages/labs/router/src/test/router_test.ts b/packages/labs/router/src/test/router_test.ts index 1768aa3c7d..dc0cd7c171 100644 --- a/packages/labs/router/src/test/router_test.ts +++ b/packages/labs/router/src/test/router_test.ts @@ -198,6 +198,61 @@ const canTest = '

Not Found

' ); }); + + test('link() returns URL string including parent route', async () => { + await loadTestModule('./router_test.html'); + const el = container.contentDocument!.createElement( + 'router-test-1' + ) as Test1; + const {contentWindow, contentDocument} = container; + + // Set the iframe URL before appending the element + contentWindow!.history.pushState({}, '', '/child1/def'); + contentDocument!.body.append(el); + await el.updateComplete; + const child1 = el.shadowRoot!.querySelector('child-1') as Child1; + await child1.updateComplete; + + assert.equal(el._router.link(), '/child1/'); + assert.equal(child1._routes.link(), '/child1/def'); + }); + + test('link() can replace local path', async () => { + await loadTestModule('./router_test.html'); + const el = container.contentDocument!.createElement( + 'router-test-1' + ) as Test1; + const {contentWindow, contentDocument} = container; + + // Set the iframe URL before appending the element + contentWindow!.history.pushState({}, '', '/child1/def'); + contentDocument!.body.append(el); + await el.updateComplete; + const child1 = el.shadowRoot!.querySelector('child-1') as Child1; + await child1.updateComplete; + + assert.equal(child1._routes.link('local_path'), `/child1/local_path`); + }); + + test(`link() with local absolute path doesn't include parent route`, async () => { + await loadTestModule('./router_test.html'); + const el = container.contentDocument!.createElement( + 'router-test-1' + ) as Test1; + const {contentWindow, contentDocument} = container; + + // Set the iframe URL before appending the element + contentWindow!.history.pushState({}, '', '/child1/def'); + contentDocument!.body.append(el); + await el.updateComplete; + const child1 = el.shadowRoot!.querySelector('child-1') as Child1; + await child1.updateComplete; + + assert.equal( + child1._routes.link('/local_absolute_path'), + '/local_absolute_path' + ); + }); }); export const stripExpressionComments = (html: string) => diff --git a/packages/labs/ssr/README.md b/packages/labs/ssr/README.md index eb590969db..eaa01c9603 100644 --- a/packages/labs/ssr/README.md +++ b/packages/labs/ssr/README.md @@ -98,21 +98,19 @@ import './app-components.js'; const ssrResult = render(html` - - + - - + - `); diff --git a/packages/labs/task/CHANGELOG.md b/packages/labs/task/CHANGELOG.md index 647826db7e..d12682ec08 100644 --- a/packages/labs/task/CHANGELOG.md +++ b/packages/labs/task/CHANGELOG.md @@ -1,5 +1,30 @@ # Change Log +## 2.0.0 + +### Major Changes + +- [#3283](https://github.com/lit/lit/pull/3283) [`a279803d`](https://github.com/lit/lit/commit/a279803d14dd0d0e81d49063587965581bdc759a) - **[Breaking]** Task will no longer reset its `value` or `error` on pending. This allows us to start chaining tasks e.g. + + ```js + const a = new Task( + this, + async ([url]) => await fetch(url), + () => [this.url] + ); + const b = new Task( + this, + async ([value]) => { + /* This is not thrashed */ + }, + () => [a.value] + ); + ``` + +### Minor Changes + +- [#3287](https://github.com/lit/lit/pull/3287) [`02b0b7b9`](https://github.com/lit/lit/commit/02b0b7b9f99b85de34e56168cf4ccb6955f4c553) - Adds onComplete and onError callbacks + ## 1.1.3 ### Patch Changes diff --git a/packages/labs/task/README.md b/packages/labs/task/README.md index b0e8626abd..422cb9a92f 100644 --- a/packages/labs/task/README.md +++ b/packages/labs/task/README.md @@ -49,7 +49,7 @@ import {Task, TaskStatus} from '@lit-labs/task'; class MyElement extends LitElement { @state() - private _userId: number; + private _userId: number = -1; private _apiTask = new Task( this, @@ -57,7 +57,7 @@ class MyElement extends LitElement { fetch(`//example.com/api/userInfo?${userId}`).then((response) => response.json() ), - () => [this.userId] + () => [this._userId] ); render() { diff --git a/packages/labs/task/package.json b/packages/labs/task/package.json index 3ec2f54cac..45d0ccdb29 100644 --- a/packages/labs/task/package.json +++ b/packages/labs/task/package.json @@ -1,6 +1,6 @@ { "name": "@lit-labs/task", - "version": "1.1.3", + "version": "2.0.0", "description": "A controller for Lit that renders asynchronous tasks.", "license": "BSD-3-Clause", "homepage": "https://lit.dev/", diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index 73696d0b7a..cf12672cb7 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -44,6 +44,8 @@ export interface TaskConfig, R> { task: TaskFunction; args?: ArgsFunction; autoRun?: boolean; + onComplete?: (value: R) => unknown; + onError?: (error: unknown) => unknown; } // TODO(sorvell): Some issues: @@ -109,6 +111,8 @@ export class Task< private _host: ReactiveControllerHost; private _value?: R; private _error?: unknown; + private _onComplete?: (result: R) => unknown; + private _onError?: (error: unknown) => unknown; status: TaskStatus = TaskStatus.INITIAL; /** @@ -144,6 +148,8 @@ export class Task< typeof task === 'object' ? task : ({task, args} as TaskConfig); this._task = taskConfig.task; this._getArgs = taskConfig.args; + this._onComplete = taskConfig.onComplete; + this._onError = taskConfig.onError; if (taskConfig.autoRun !== undefined) { this.autoRun = taskConfig.autoRun; } @@ -195,8 +201,6 @@ export class Task< }); } this.status = TaskStatus.PENDING; - this._error = undefined; - this._value = undefined; let result!: R | typeof initialState; let error: unknown; // Request an update to report pending state. @@ -213,9 +217,19 @@ export class Task< this.status = TaskStatus.INITIAL; } else { if (error === undefined) { + try { + this._onComplete?.(result as R); + } catch { + // Ignore user errors from onComplete. + } this.status = TaskStatus.COMPLETE; this._resolveTaskComplete(result as R); } else { + try { + this._onError?.(error); + } catch { + // Ignore user errors from onError. + } this.status = TaskStatus.ERROR; this._rejectTaskComplete(error); } diff --git a/packages/labs/task/src/test/task_test.ts b/packages/labs/task/src/test/task_test.ts index 9ca43321b3..848ed77134 100644 --- a/packages/labs/task/src/test/task_test.ts +++ b/packages/labs/task/src/test/task_test.ts @@ -27,7 +27,7 @@ suite('Task', () => { b: string; c?: string; resolveTask: () => void; - rejectTask: () => void; + rejectTask: (error?: string) => void; taskValue?: string; renderedStatus?: string; } @@ -46,7 +46,7 @@ suite('Task', () => { c?: string; resolveTask!: () => void; - rejectTask!: () => void; + rejectTask!: (error?: string) => void; taskValue?: string; renderedStatus?: string; @@ -56,7 +56,7 @@ suite('Task', () => { const taskConfig = { task: (...args: unknown[]) => new Promise((resolve, reject) => { - this.rejectTask = () => reject(`error`); + this.rejectTask = (error = 'error') => reject(error); this.resolveTask = () => resolve(args.join(',')); }), }; @@ -169,7 +169,7 @@ suite('Task', () => { // Check task pending. await tasksUpdateComplete(); assert.equal(el.task.status, TaskStatus.PENDING); - assert.equal(el.taskValue, undefined); + assert.equal(el.taskValue, 'a,b'); // Complete task and check result. el.resolveTask(); await tasksUpdateComplete(); @@ -181,7 +181,7 @@ suite('Task', () => { // Check task pending. await tasksUpdateComplete(); assert.equal(el.task.status, TaskStatus.PENDING); - assert.equal(el.taskValue, undefined); + assert.equal(el.taskValue, 'a1,b'); // Complete task and check result. el.resolveTask(); await tasksUpdateComplete(); @@ -189,6 +189,27 @@ suite('Task', () => { assert.equal(el.taskValue, `a1,b1`); }); + test('task error is not reset on rerun', async () => { + const el = getTestElement({args: () => [el.a, el.b]}); + await renderElement(el); + el.rejectTask(); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.ERROR); + assert.equal(el.taskValue, 'error'); + + // *** Changing task argument runs task + el.a = 'a1'; + // Check task pending. + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.PENDING); + assert.equal(el.taskValue, 'error'); + // Reject task and check result. + el.rejectTask(); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.ERROR); + assert.equal(el.taskValue, `error`); + }); + test('tasks do not run when `autoRun` is `false`', async () => { const el = getTestElement({args: () => [el.a, el.b], autoRun: false}); await renderElement(el); @@ -248,7 +269,7 @@ suite('Task', () => { el.task.run(); await tasksUpdateComplete(); assert.equal(el.task.status, TaskStatus.PENDING); - assert.equal(el.taskValue, undefined); + assert.equal(el.taskValue, `a,b`); el.resolveTask(); await tasksUpdateComplete(); assert.equal(el.task.status, TaskStatus.COMPLETE); @@ -437,4 +458,74 @@ suite('Task', () => { ); }; }); + + test('onComplete callback is called', async () => { + let numOnCompleteInvocations = 0; + let lastOnCompleteResult: string | undefined = undefined; + const el = getTestElement({ + args: () => [el.a, el.b], + onComplete: (result) => { + numOnCompleteInvocations++; + lastOnCompleteResult = result; + }, + }); + await renderElement(el); + assert.equal(el.task.status, TaskStatus.PENDING); + assert.equal(numOnCompleteInvocations, 0); + assert.equal(lastOnCompleteResult, undefined); + el.resolveTask(); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.COMPLETE); + assert.equal(numOnCompleteInvocations, 1); + assert.equal(lastOnCompleteResult, 'a,b'); + + numOnCompleteInvocations = 0; + + // Called after every task completion. + el.a = 'a1'; + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.PENDING); + assert.equal(numOnCompleteInvocations, 0); + assert.equal(lastOnCompleteResult, 'a,b'); + el.resolveTask(); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.COMPLETE); + assert.equal(numOnCompleteInvocations, 1); + assert.equal(lastOnCompleteResult, 'a1,b'); + }); + + test('onError callback is called', async () => { + let numOnErrorInvocations = 0; + let lastOnErrorResult: string | undefined = undefined; + const el = getTestElement({ + args: () => [el.a, el.b], + onError: (error) => { + numOnErrorInvocations++; + lastOnErrorResult = error as string; + }, + }); + await renderElement(el); + assert.equal(el.task.status, TaskStatus.PENDING); + assert.equal(numOnErrorInvocations, 0); + assert.equal(lastOnErrorResult, undefined); + el.rejectTask('error'); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.ERROR); + assert.equal(numOnErrorInvocations, 1); + assert.equal(lastOnErrorResult, 'error'); + + numOnErrorInvocations = 0; + + // Called after every task error. + el.a = 'a1'; + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.PENDING); + assert.equal(numOnErrorInvocations, 0); + assert.equal(lastOnErrorResult, 'error'); + el.rejectTask('error2'); + await tasksUpdateComplete(); + assert.equal(el.task.status, TaskStatus.ERROR); + assert.equal(numOnErrorInvocations, 1); + assert.equal(lastOnErrorResult, 'error2'); + }); }); diff --git a/packages/labs/test-projects/test-element-a/package.json b/packages/labs/test-projects/test-element-a/package.json index 2ec49c7489..be6c9a7c95 100644 --- a/packages/labs/test-projects/test-element-a/package.json +++ b/packages/labs/test-projects/test-element-a/package.json @@ -7,7 +7,13 @@ "pack": "wireit" }, "files": [ - "/element-a.{js,js.map,d.ts,d.ts.map}" + "/element-a.{js,js.map,d.ts,d.ts.map}", + "/element-events.{js,js.map,d.ts,d.ts.map}", + "/element-props.{js,js.map,d.ts,d.ts.map}", + "/element-slots.{js,js.map,d.ts,d.ts.map}", + "/detail-type.{js,js.map,d.ts,d.ts.map}", + "/package-stuff.{js,js.map,d.ts,d.ts.map}", + "/special-event.{js,js.map,d.ts,d.ts.map}" ], "dependencies": { "lit": "^2.0.0" @@ -27,6 +33,12 @@ "command": "tsc --build --pretty", "output": [ "element-a.{js,js.map,d.ts,d.ts.map}", + "element-events.{js,js.map,d.ts,d.ts.map}", + "element-props.{js,js.map,d.ts,d.ts.map}", + "element-slots.{js,js.map,d.ts,d.ts.map}", + "detail-type.{js,js.map,d.ts,d.ts.map}", + "package-stuff.{js,js.map,d.ts,d.ts.map}", + "special-event.{js,js.map,d.ts,d.ts.map}", "tsconfig.tsbuildinfo" ], "clean": "if-file-deleted" @@ -38,7 +50,13 @@ "command": "npm pack", "files": [ "package.json", - "element-a.{js,js.map,d.ts,d.ts.map}" + "element-a.{js,js.map,d.ts,d.ts.map}", + "element-events.{js,js.map,d.ts,d.ts.map}", + "element-props.{js,js.map,d.ts,d.ts.map}", + "element-slots.{js,js.map,d.ts,d.ts.map}", + "detail-type.{js,js.map,d.ts,d.ts.map}", + "package-stuff.{js,js.map,d.ts,d.ts.map}", + "special-event.{js,js.map,d.ts,d.ts.map}" ], "output": [ "*.tgz" diff --git a/packages/labs/test-projects/test-element-a/src/detail-type.ts b/packages/labs/test-projects/test-element-a/src/detail-type.ts new file mode 100644 index 0000000000..d4423dadd2 --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/detail-type.ts @@ -0,0 +1,4 @@ +export interface MyDetail { + a: string; + b: number; +} diff --git a/packages/labs/test-projects/test-element-a/src/element-a.ts b/packages/labs/test-projects/test-element-a/src/element-a.ts index 69bb8a5ecf..609eddc7b5 100644 --- a/packages/labs/test-projects/test-element-a/src/element-a.ts +++ b/packages/labs/test-projects/test-element-a/src/element-a.ts @@ -6,16 +6,29 @@ import {LitElement, html, css} from 'lit'; import {customElement, property} from 'lit/decorators.js'; +import {Foo, Bar as Baz} from './package-stuff.js'; /** * My awesome element + * + * This is a description of my element. It's pretty great. The description has + * text that spans multiple lines. + * * @fires a-changed - An awesome event to fire + * @slot default - The default slot + * @slot stuff - A slot for stuff + * @cssProperty --foreground-color: The foreground color + * @cssProp --background-color The background color + * @cssPart header The header + * @cssPart footer - The footer */ @customElement('element-a') export class ElementA extends LitElement { static override styles = css` :host { display: block; + background-color: var(--background-color); + color: var(-foreground-color); } `; @@ -23,6 +36,18 @@ export class ElementA extends LitElement { foo?: string; override render() { - return html`

${this.foo}

`; + return html` +

${this.foo}

+ + +
Footer
+ `; } } + +export let localTypeVar: ElementA; +export let packageTypeVar: Foo; +export let externalTypeVar: LitElement; +export let globalTypeVar: HTMLElement; + +export {Foo, Baz, localTypeVar as local}; diff --git a/packages/labs/test-projects/test-element-a/src/element-events.ts b/packages/labs/test-projects/test-element-a/src/element-events.ts new file mode 100644 index 0000000000..00a772cf13 --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/element-events.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html, TemplateResult} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {SpecialEvent} from './special-event.js'; +import {MyDetail} from './detail-type.js'; + +export {SpecialEvent} from './special-event.js'; +export {MyDetail} from './detail-type.js'; +export class EventSubclass extends Event { + aStr: string; + aNumber: number; + + constructor( + aStr = 'aStr', + aNumber = 5, + type = 'event-subclass', + options = {composed: true, bubbles: true, cancelable: true} + ) { + super(type, options); + this.aStr = aStr; + this.aNumber = aNumber; + } +} + +declare global { + interface HTMLElementEventMap { + 'event-subclass': EventSubclass; + 'special-event': SpecialEvent; + 'string-custom-event': CustomEvent; + 'number-custom-event': CustomEvent; + 'my-detail-custom-event': CustomEvent; + 'template-result-custom-event': CustomEvent; + } +} + +/** + * My awesome element + * @fires string-custom-event {CustomEvent} A custom event with a string payload + * @fires number-custom-event {CustomEvent} A custom event with a number payload + * @fires my-detail-custom-event {CustomEvent} A custom event with a MyDetail payload. + * @fires event-subclass {EventSubclass} The subclass event with a string and number payload + * @fires special-event {SpecialEvent} The special event with a number payload + * @fires template-result-custom-event {CustomEvent} The result-custom-event with a TemplateResult payload. + */ +@customElement('element-events') +export class ElementEvents extends LitElement { + @property() + foo?: string; + + override render() { + return html`

${this.foo}

`; + } + + fireStringCustomEvent(detail = 'string-event', fromNode = this) { + fromNode.dispatchEvent( + new CustomEvent('string-custom-event', { + detail, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + } + + fireNumberCustomEvent(detail = 11, fromNode = this) { + fromNode.dispatchEvent( + new CustomEvent('number-custom-event', { + detail, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + } + + fireMyDetailCustomEvent( + detail = {a: 'a', b: 5} as MyDetail, + fromNode = this + ) { + fromNode.dispatchEvent( + new CustomEvent('my-detail-custom-event', { + detail, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + } + + fireTemplateResultCustomEvent(detail = html``, fromNode = this) { + fromNode.dispatchEvent( + new CustomEvent('template-result-custom-event', { + detail, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + } + + fireEventSubclass(str: string, num: number, fromNode = this) { + fromNode.dispatchEvent(new EventSubclass(str, num)); + } + + fireSpecialEvent(num: number, fromNode = this) { + fromNode.dispatchEvent(new SpecialEvent(num)); + } +} diff --git a/packages/labs/test-projects/test-element-a/src/element-props.ts b/packages/labs/test-projects/test-element-a/src/element-props.ts new file mode 100644 index 0000000000..01d482796d --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/element-props.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +export interface MyType { + a: string; + b: number; + c: boolean; + d: string[]; + e: unknown; + strOrNum: string | number; +} + +/** + * My awesome element + * @fires a-changed - An awesome event to fire + */ +@customElement('element-props') +export class ElementProps extends LitElement { + @property() + aStr = 'aStr'; + + @property({type: Number}) + aNum = -1; + + @property({type: Boolean}) + aBool = false; + + @property({type: Array}) + aStrArray = ['a', 'b']; + + @property({type: Object, attribute: false}) + aMyType: MyType = { + a: 'a', + b: -1, + c: false, + d: ['a', 'b'], + e: 'isUnknown', + strOrNum: 'strOrNum', + }; + + @state() + aState = 'aState'; + + override render() { + return html`

Props

+
${this.aStr}
+
${this.aNum}
+
${this.aBool}
+
${JSON.stringify(this.aStrArray)}
+
${JSON.stringify(this.aMyType)}
+
${this.aState}
`; + } +} diff --git a/packages/labs/test-projects/test-element-a/src/element-slots.ts b/packages/labs/test-projects/test-element-a/src/element-slots.ts new file mode 100644 index 0000000000..f47b09c20d --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/element-slots.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +/** + * My awesome element + */ +@customElement('element-slots') +export class ElementSlots extends LitElement { + @property() + mainDefault = 'mainDefault'; + + override render() { + return html`

Slots

+ + ${this.mainDefault} + + `; + } +} diff --git a/packages/labs/test-projects/test-element-a/src/package-stuff.ts b/packages/labs/test-projects/test-element-a/src/package-stuff.ts new file mode 100644 index 0000000000..5832074814 --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/package-stuff.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +export interface BarInterface { + bar: boolean; +} + +export class Bar implements BarInterface { + bar = true; +} + +export class Foo { + bar?: T; +} diff --git a/packages/labs/test-projects/test-element-a/src/special-event.ts b/packages/labs/test-projects/test-element-a/src/special-event.ts new file mode 100644 index 0000000000..c725428d22 --- /dev/null +++ b/packages/labs/test-projects/test-element-a/src/special-event.ts @@ -0,0 +1,12 @@ +export class SpecialEvent extends Event { + aNumber: number; + + constructor( + aNumber = 5, + type = 'special-event', + options = {composed: true, bubbles: true, cancelable: true} + ) { + super(type, options); + this.aNumber = aNumber; + } +} diff --git a/packages/labs/virtualizer/.gitignore b/packages/labs/virtualizer/.gitignore index 66ecf82387..489f473959 100644 --- a/packages/labs/virtualizer/.gitignore +++ b/packages/labs/virtualizer/.gitignore @@ -26,3 +26,5 @@ /test/**/*.js /test/**/*.js.map /test/screenshot/cases/*/actual.*.png +/events.d.ts* +/events.js* diff --git a/packages/labs/virtualizer/package.json b/packages/labs/virtualizer/package.json index 3db064f456..bfd9916d9c 100644 --- a/packages/labs/virtualizer/package.json +++ b/packages/labs/virtualizer/package.json @@ -99,7 +99,7 @@ "/layouts/shared/*.{d.ts,d.ts.map,js,js.map}", "/polyfillLoaders/*.{d.ts,d.ts.map,js,js.map}", "/polyfills/resize-observer-polyfill/ResizeObserver.{d.ts,js}", - "/events.{js,d.ts,d.ts.map}", + "/events.{js,d.ts,d.ts.map,js.map}", "/lit-virtualizer.{d.ts,d.ts.map,js,js.map}", "/virtualize.{d.ts,d.ts.map,js,js.map}", "/Virtualizer.{d.ts,d.ts.map,js,js.map}", diff --git a/packages/labs/virtualizer/src/LitVirtualizer.ts b/packages/labs/virtualizer/src/LitVirtualizer.ts index 59e1cc5771..7141a31a52 100644 --- a/packages/labs/virtualizer/src/LitVirtualizer.ts +++ b/packages/labs/virtualizer/src/LitVirtualizer.ts @@ -10,12 +10,11 @@ import {KeyFn} from 'lit/directives/repeat.js'; import {LayoutConfigValue} from './layouts/shared/Layout.js'; import { virtualize, - virtualizerRef, - VirtualizerHostElement, defaultRenderItem, defaultKeyFunction, RenderItemFunction, } from './virtualize.js'; +import {virtualizerRef, VirtualizerHostElement} from './Virtualizer.js'; export class LitVirtualizer extends LitElement { @property({attribute: false}) diff --git a/packages/lit-html/.gitignore b/packages/lit-html/.gitignore index 843b4373d0..f3be24dbde 100644 --- a/packages/lit-html/.gitignore +++ b/packages/lit-html/.gitignore @@ -12,3 +12,4 @@ /polyfill-support.* /private-ssr-support.* /static.* +/is-server.* diff --git a/packages/lit-html/CHANGELOG.md b/packages/lit-html/CHANGELOG.md index f9d2878438..b54b737828 100644 --- a/packages/lit-html/CHANGELOG.md +++ b/packages/lit-html/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 2.4.0 + +### Minor Changes + +- [#3318](https://github.com/lit/lit/pull/3318) [`21313077`](https://github.com/lit/lit/commit/21313077669c19b3d631a50825b8a01dae1dd0d4) - Adds an `isServer` variable export to `lit` and `lit-html/is-server.js` which will be `true` in Node and `false` in the browser. This can be used when authoring components to change behavior based on whether or not the component is executing in an SSR context. + ## 2.3.1 ### Patch Changes diff --git a/packages/lit-html/package.json b/packages/lit-html/package.json index 6f53ef2a10..bf06df9d87 100644 --- a/packages/lit-html/package.json +++ b/packages/lit-html/package.json @@ -1,6 +1,6 @@ { "name": "lit-html", - "version": "2.3.1", + "version": "2.4.0", "description": "HTML templates literals in JavaScript", "license": "BSD-3-Clause", "repository": { @@ -180,6 +180,12 @@ "node": "./node/static.js", "development": "./development/static.js", "default": "./static.js" + }, + "./is-server.js": { + "types": "./development/is-server.d.ts", + "node": "./node/is-server.js", + "development": "./development/is-server.js", + "default": "./is-server.js" } }, "scripts": { @@ -205,6 +211,7 @@ "/polyfill-support.{d.ts,d.ts.map,js,js.map}", "/private-ssr-support.{d.ts,d.ts.map,js,js.map}", "/static.{d.ts,d.ts.map,js,js.map}", + "/is-server.{d.ts,d.ts.map,js,js.map}", "/development/", "!/development/test/", "/directives/", @@ -266,6 +273,7 @@ "polyfill-support.js{,.map}", "private-ssr-support.js{,.map}", "static.js{,.map}", + "is-server.js{,.map}", "directives/*.js{,.map}", "test/*_test.html", "development/test/*_test.html", diff --git a/packages/lit-html/rollup.config.js b/packages/lit-html/rollup.config.js index 1efc527298..bab8c9bada 100644 --- a/packages/lit-html/rollup.config.js +++ b/packages/lit-html/rollup.config.js @@ -39,6 +39,7 @@ export const defaultConfig = (options = {}) => 'experimental-hydrate', 'private-ssr-support', 'polyfill-support', + 'is-server', ], bundled: [ { diff --git a/packages/lit-html/src/is-server.ts b/packages/lit-html/src/is-server.ts new file mode 100644 index 0000000000..0513962a91 --- /dev/null +++ b/packages/lit-html/src/is-server.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * @fileoverview + * + * This file exports a boolean const whose value will depend on what environment + * the module is being imported from. + */ + +const NODE_MODE = false; + +/** + * A boolean that will be `true` in server environments like Node, and `false` + * in browser environments. Note that your server environment or toolchain must + * support the `"node"` export condition for this to be `true`. + * + * This can be used when authoring components to change behavior based on + * whether or not the component is executing in an SSR context. + */ +export const isServer = NODE_MODE; diff --git a/packages/lit-html/src/lit-html.ts b/packages/lit-html/src/lit-html.ts index 56d455aa57..51941fdf91 100644 --- a/packages/lit-html/src/lit-html.ts +++ b/packages/lit-html/src/lit-html.ts @@ -627,88 +627,6 @@ export interface RenderOptions { isConnected?: boolean; } -/** - * Renders a value, usually a lit-html TemplateResult, to the container. - * - * This example renders the text "Hello, Zoe!" inside a paragraph tag, appending - * it to the container `document.body`. - * - * ```js - * import {html, render} from 'lit'; - * - * const name = "Zoe"; - * render(html`

Hello, ${name}!

`, document.body); - * ``` - * - * @param value Any [renderable - * value](https://lit.dev/docs/templates/expressions/#child-expressions), - * typically a {@linkcode TemplateResult} created by evaluating a template tag - * like {@linkcode html} or {@linkcode svg}. - * @param container A DOM container to render to. The first render will append - * the rendered value to the container, and subsequent renders will - * efficiently update the rendered value if the same result type was - * previously rendered there. - * @param options See {@linkcode RenderOptions} for options documentation. - * @see - * {@link https://lit.dev/docs/libraries/standalone-templates/#rendering-lit-html-templates| Rendering Lit HTML Templates} - */ -export const render = ( - value: unknown, - container: HTMLElement | DocumentFragment, - options?: RenderOptions -): RootPart => { - if (DEV_MODE && container == null) { - // Give a clearer error message than - // Uncaught TypeError: Cannot read properties of null (reading - // '_$litPart$') - // which reads like an internal Lit error. - throw new TypeError(`The container to render into may not be ${container}`); - } - const renderId = DEV_MODE ? debugLogRenderId++ : 0; - const partOwnerNode = options?.renderBefore ?? container; - // This property needs to remain unminified. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let part: ChildPart = (partOwnerNode as any)['_$litPart$']; - debugLogEvent?.({ - kind: 'begin render', - id: renderId, - value, - container, - options, - part, - }); - if (part === undefined) { - const endNode = options?.renderBefore ?? null; - // This property needs to remain unminified. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (partOwnerNode as any)['_$litPart$'] = part = new ChildPart( - container.insertBefore(createMarker(), endNode), - endNode, - undefined, - options ?? {} - ); - } - part._$setValue(value); - debugLogEvent?.({ - kind: 'end render', - id: renderId, - value, - container, - options, - part, - }); - return part as RootPart; -}; - -if (ENABLE_EXTRA_SECURITY_HOOKS) { - render.setSanitizer = setSanitizer; - render.createSanitizer = createSanitizer; - if (DEV_MODE) { - render._testOnlyClearSanitizerFactoryDoNotCallOrElse = - _testOnlyClearSanitizerFactoryDoNotCallOrElse; - } -} - const walker = d.createTreeWalker( d, 129 /* NodeFilter.SHOW_{ELEMENT|COMMENT} */, @@ -2178,7 +2096,7 @@ polyfillSupport?.(Template, ChildPart); // IMPORTANT: do not change the property name or the assignment expression. // This line will be used in regexes to search for lit-html usage. -(global.litHtmlVersions ??= []).push('2.3.1'); +(global.litHtmlVersions ??= []).push('2.4.0'); if (DEV_MODE && global.litHtmlVersions.length > 1) { issueWarning!( 'multiple-versions', @@ -2186,3 +2104,85 @@ if (DEV_MODE && global.litHtmlVersions.length > 1) { `Loading multiple versions is not recommended.` ); } + +/** + * Renders a value, usually a lit-html TemplateResult, to the container. + * + * This example renders the text "Hello, Zoe!" inside a paragraph tag, appending + * it to the container `document.body`. + * + * ```js + * import {html, render} from 'lit'; + * + * const name = "Zoe"; + * render(html`

Hello, ${name}!

`, document.body); + * ``` + * + * @param value Any [renderable + * value](https://lit.dev/docs/templates/expressions/#child-expressions), + * typically a {@linkcode TemplateResult} created by evaluating a template tag + * like {@linkcode html} or {@linkcode svg}. + * @param container A DOM container to render to. The first render will append + * the rendered value to the container, and subsequent renders will + * efficiently update the rendered value if the same result type was + * previously rendered there. + * @param options See {@linkcode RenderOptions} for options documentation. + * @see + * {@link https://lit.dev/docs/libraries/standalone-templates/#rendering-lit-html-templates| Rendering Lit HTML Templates} + */ +export const render = ( + value: unknown, + container: HTMLElement | DocumentFragment, + options?: RenderOptions +): RootPart => { + if (DEV_MODE && container == null) { + // Give a clearer error message than + // Uncaught TypeError: Cannot read properties of null (reading + // '_$litPart$') + // which reads like an internal Lit error. + throw new TypeError(`The container to render into may not be ${container}`); + } + const renderId = DEV_MODE ? debugLogRenderId++ : 0; + const partOwnerNode = options?.renderBefore ?? container; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let part: ChildPart = (partOwnerNode as any)['_$litPart$']; + debugLogEvent?.({ + kind: 'begin render', + id: renderId, + value, + container, + options, + part, + }); + if (part === undefined) { + const endNode = options?.renderBefore ?? null; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (partOwnerNode as any)['_$litPart$'] = part = new ChildPart( + container.insertBefore(createMarker(), endNode), + endNode, + undefined, + options ?? {} + ); + } + part._$setValue(value); + debugLogEvent?.({ + kind: 'end render', + id: renderId, + value, + container, + options, + part, + }); + return part as RootPart; +}; + +if (ENABLE_EXTRA_SECURITY_HOOKS) { + render.setSanitizer = setSanitizer; + render.createSanitizer = createSanitizer; + if (DEV_MODE) { + render._testOnlyClearSanitizerFactoryDoNotCallOrElse = + _testOnlyClearSanitizerFactoryDoNotCallOrElse; + } +} diff --git a/packages/lit-html/src/test/is-server_test.ts b/packages/lit-html/src/test/is-server_test.ts new file mode 100644 index 0000000000..915dd6ca2f --- /dev/null +++ b/packages/lit-html/src/test/is-server_test.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {isServer} from 'lit-html/is-server.js'; +import {assert} from '@esm-bundle/chai'; + +suite('is-server', () => { + test('isServer is false', () => { + assert.strictEqual(isServer, false); + }); +}); diff --git a/packages/lit-html/src/test/node-imports.ts b/packages/lit-html/src/test/node-imports.ts index 2c3c953516..2a986c0acc 100644 --- a/packages/lit-html/src/test/node-imports.ts +++ b/packages/lit-html/src/test/node-imports.ts @@ -35,3 +35,7 @@ import 'lit-html/static.js'; import 'lit-html/experimental-hydrate.js'; import 'lit-html/private-ssr-support.js'; import 'lit-html/polyfill-support.js'; + +import assert from 'node:assert/strict'; +import {isServer} from 'lit-html/is-server.js'; +assert.strictEqual(isServer, true, 'Expected isServer to be true'); diff --git a/packages/lit/CHANGELOG.md b/packages/lit/CHANGELOG.md index 2f54023849..2158842d7a 100644 --- a/packages/lit/CHANGELOG.md +++ b/packages/lit/CHANGELOG.md @@ -1,5 +1,24 @@ # Change Log +## 2.4.1 + +### Patch Changes + +- [#3374](https://github.com/lit/lit/pull/3374) [`bb098950`](https://github.com/lit/lit/commit/bb0989507f73f1e6d484199e3767eed39ebbaf22) - Initializers added to subclasses are no longer improperly added to superclass. + +## 2.4.0 + +### Minor Changes + +- [#3318](https://github.com/lit/lit/pull/3318) [`21313077`](https://github.com/lit/lit/commit/21313077669c19b3d631a50825b8a01dae1dd0d4) - Adds an `isServer` variable export to `lit` and `lit-html/is-server.js` which will be `true` in Node and `false` in the browser. This can be used when authoring components to change behavior based on whether or not the component is executing in an SSR context. + +### Patch Changes + +- [#3320](https://github.com/lit/lit/pull/3320) [`305852d4`](https://github.com/lit/lit/commit/305852d4a4f51174301720985de98fdbf8674648) - The `lit` package now specifies and "types" export condition allowing TypeScript `moduleResolution` to be `nodenext`. + +- Updated dependencies [[`21313077`](https://github.com/lit/lit/commit/21313077669c19b3d631a50825b8a01dae1dd0d4)]: + - lit-html@2.4.0 + ## 2.3.1 ### Patch Changes diff --git a/packages/lit/README.md b/packages/lit/README.md index 958f9e7f76..deb3092844 100644 --- a/packages/lit/README.md +++ b/packages/lit/README.md @@ -1,12 +1,21 @@ -Lit +
+ + + + + + Lit + ### Simple. Fast. Web Components. [![Build Status](https://github.com/lit/lit/actions/workflows/tests.yml/badge.svg)](https://github.com/lit/lit/actions/workflows/tests.yml) [![Published on npm](https://img.shields.io/npm/v/lit.svg?logo=npm)](https://www.npmjs.com/package/lit) -[![Join our Slack](https://img.shields.io/badge/slack-join%20chat-4a154b.svg?logo=slack)](https://lit.dev/slack-invite/) +[![Join our Discord](https://img.shields.io/badge/discord-join%20chat-5865F2.svg?logo=discord&logoColor=fff)](https://lit.dev/discord/) [![Mentioned in Awesome Lit](https://awesome.re/mentioned-badge.svg)](https://github.com/web-padawan/awesome-lit) +
+ Lit is a simple library for building fast, lightweight web components. At Lit's core is a boilerplate-killing component base class that provides reactive state, scoped styles, and a declarative template system that's tiny, fast and expressive. diff --git a/packages/lit/logo-dark.svg b/packages/lit/logo-dark.svg new file mode 100644 index 0000000000..0771dd0349 --- /dev/null +++ b/packages/lit/logo-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/lit/package.json b/packages/lit/package.json index 2d96ea0b2a..eb1635dd7a 100644 --- a/packages/lit/package.json +++ b/packages/lit/package.json @@ -1,6 +1,6 @@ { "name": "lit", - "version": "2.3.1", + "version": "2.4.1", "publishConfig": { "access": "public" }, @@ -18,120 +18,159 @@ "type": "module", "exports": { ".": { + "types": "./development/index.d.ts", "default": "./index.js" }, "./async-directive.js": { + "types": "./development/async-directive.d.ts", "default": "./async-directive.js" }, "./decorators.js": { + "types": "./development/decorators.d.ts", "default": "./decorators.js" }, "./decorators/custom-element.js": { + "types": "./development/decorators/custom-element.d.ts", "default": "./decorators/custom-element.js" }, "./decorators/event-options.js": { + "types": "./development/decorators/event-options.d.ts", "default": "./decorators/event-options.js" }, "./decorators/property.js": { + "types": "./development/decorators/property.d.ts", "default": "./decorators/property.js" }, "./decorators/query-all.js": { + "types": "./development/decorators/query-all.d.ts", "default": "./decorators/query-all.js" }, "./decorators/query-assigned-elements.js": { + "types": "./development/decorators/query-assigned-elements.d.ts", "default": "./decorators/query-assigned-elements.js" }, "./decorators/query-assigned-nodes.js": { + "types": "./development/decorators/query-assigned-nodes.d.ts", "default": "./decorators/query-assigned-nodes.js" }, "./decorators/query-async.js": { + "types": "./development/decorators/query-async.d.ts", "default": "./decorators/query-async.js" }, "./decorators/query.js": { + "types": "./development/decorators/query.d.ts", "default": "./decorators/query.js" }, "./decorators/state.js": { + "types": "./development/decorators/state.d.ts", "default": "./decorators/state.js" }, "./directive-helpers.js": { + "types": "./development/directive-helpers.d.ts", "default": "./directive-helpers.js" }, "./directive.js": { + "types": "./development/directive.d.ts", "default": "./directive.js" }, "./directives/async-append.js": { + "types": "./development/directives/async-append.d.ts", "default": "./directives/async-append.js" }, "./directives/async-replace.js": { + "types": "./development/directives/async-replace.d.ts", "default": "./directives/async-replace.js" }, "./directives/cache.js": { + "types": "./development/directives/cache.d.ts", "default": "./directives/cache.js" }, "./directives/choose.js": { + "types": "./development/directives/choose.d.ts", "default": "./directives/choose.js" }, "./directives/class-map.js": { + "types": "./development/directives/class-map.d.ts", "default": "./directives/class-map.js" }, "./directives/guard.js": { + "types": "./development/directives/guard.d.ts", "default": "./directives/guard.js" }, "./directives/if-defined.js": { + "types": "./development/directives/if-defined.d.ts", "default": "./directives/if-defined.js" }, "./directives/join.js": { + "types": "./development/directives/join.d.ts", "default": "./directives/join.js" }, "./directives/keyed.js": { + "types": "./development/directives/keyed.d.ts", "default": "./directives/keyed.js" }, "./directives/live.js": { + "types": "./development/directives/live.d.ts", "default": "./directives/live.js" }, "./directives/map.js": { + "types": "./development/directives/map.d.ts", "default": "./directives/map.js" }, "./directives/range.js": { + "types": "./development/directives/range.d.ts", "default": "./directives/range.js" }, "./directives/ref.js": { + "types": "./development/directives/ref.d.ts", "default": "./directives/ref.js" }, "./directives/repeat.js": { + "types": "./development/directives/repeat.d.ts", "default": "./directives/repeat.js" }, "./directives/style-map.js": { + "types": "./development/directives/style-map.d.ts", "default": "./directives/style-map.js" }, "./directives/template-content.js": { + "types": "./development/directives/template-content.d.ts", "default": "./directives/template-content.js" }, "./directives/unsafe-html.js": { + "types": "./development/directives/unsafe-html.d.ts", "default": "./directives/unsafe-html.js" }, "./directives/unsafe-svg.js": { + "types": "./development/directives/unsafe-svg.d.ts", "default": "./directives/unsafe-svg.js" }, "./directives/until.js": { + "types": "./development/directives/until.d.ts", "default": "./directives/until.js" }, "./directives/when.js": { + "types": "./development/directives/when.d.ts", "default": "./directives/when.js" }, "./experimental-hydrate-support.js": { + "types": "./development/experimental-hydrate-support.d.ts", "default": "./experimental-hydrate-support.js" }, "./experimental-hydrate.js": { + "types": "./development/experimental-hydrate.d.ts", "default": "./experimental-hydrate.js" }, "./html.js": { + "types": "./development/html.d.ts", "default": "./html.js" }, "./polyfill-support.js": { + "types": "./development/polyfill-support.d.ts", "default": "./polyfill-support.js" }, "./static-html.js": { + "types": "./development/static-html.d.ts", "default": "./static-html.js" } }, @@ -284,12 +323,13 @@ "/static-html.{d.ts.map,d.ts,js.map,js}", "/decorators/", "/directives/", + "/development/", "/logo.svg" ], "dependencies": { "@lit/reactive-element": "^1.4.0", "lit-element": "^3.2.0", - "lit-html": "^2.3.0" + "lit-html": "^2.4.0" }, "devDependencies": { "@webcomponents/shadycss": "^1.8.0", diff --git a/packages/lit/src/index.ts b/packages/lit/src/index.ts index f6b8dee0f9..4030c802e5 100644 --- a/packages/lit/src/index.ts +++ b/packages/lit/src/index.ts @@ -11,3 +11,4 @@ import '@lit/reactive-element'; import 'lit-html'; export * from 'lit-element/lit-element.js'; +export * from 'lit-html/is-server.js'; diff --git a/packages/lit/src/test/node-imports.ts b/packages/lit/src/test/node-imports.ts index d443a495d1..37157ecde6 100644 --- a/packages/lit/src/test/node-imports.ts +++ b/packages/lit/src/test/node-imports.ts @@ -45,3 +45,7 @@ import 'lit/html.js'; import 'lit/experimental-hydrate-support.js'; import 'lit/experimental-hydrate.js'; import 'lit/static-html.js'; + +import assert from 'node:assert/strict'; +import {isServer} from 'lit'; +assert.strictEqual(isServer, true, 'Expected isServer to be true'); diff --git a/packages/reactive-element/CHANGELOG.md b/packages/reactive-element/CHANGELOG.md index 0fbd8e7d99..61e1d1c255 100644 --- a/packages/reactive-element/CHANGELOG.md +++ b/packages/reactive-element/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 1.4.2 + +### Patch Changes + +- [#3374](https://github.com/lit/lit/pull/3374) [`bb098950`](https://github.com/lit/lit/commit/bb0989507f73f1e6d484199e3767eed39ebbaf22) - Initializers added to subclasses are no longer improperly added to superclass. + ## 1.4.1 ### Patch Changes diff --git a/packages/reactive-element/package.json b/packages/reactive-element/package.json index 6ed3c74b3c..b019860862 100644 --- a/packages/reactive-element/package.json +++ b/packages/reactive-element/package.json @@ -1,6 +1,6 @@ { "name": "@lit/reactive-element", - "version": "1.4.1", + "version": "1.4.2", "publishConfig": { "access": "public" }, diff --git a/packages/reactive-element/src/reactive-element.ts b/packages/reactive-element/src/reactive-element.ts index 9dd8573f61..27e778fdda 100644 --- a/packages/reactive-element/src/reactive-element.ts +++ b/packages/reactive-element/src/reactive-element.ts @@ -490,8 +490,8 @@ export abstract class ReactiveElement * @nocollapse */ static addInitializer(initializer: Initializer) { - this._initializers ??= []; - this._initializers.push(initializer); + this.finalize(); + (this._initializers ??= []).push(initializer); } static _initializers?: Initializer[]; @@ -762,6 +762,12 @@ export abstract class ReactiveElement // finalize any superclasses const superCtor = Object.getPrototypeOf(this) as typeof ReactiveElement; superCtor.finalize(); + // Create own set of initializers for this class if any exist on the + // superclass and copy them down. Note, for a small perf boost, avoid + // creating initializers unless needed. + if (superCtor._initializers !== undefined) { + this._initializers = [...superCtor._initializers]; + } this.elementProperties = new Map(superCtor.elementProperties); // initialize Map populated in observedAttributes this.__attributeToPropertyMap = new Map(); @@ -1543,7 +1549,7 @@ if (DEV_MODE) { // IMPORTANT: do not change the property name or the assignment expression. // This line will be used in regexes to search for ReactiveElement usage. -(global.reactiveElementVersions ??= []).push('1.4.1'); +(global.reactiveElementVersions ??= []).push('1.4.2'); if (DEV_MODE && global.reactiveElementVersions.length > 1) { issueWarning!( 'multiple-versions', diff --git a/packages/reactive-element/src/test/reactive-element_test.ts b/packages/reactive-element/src/test/reactive-element_test.ts index 427a40c8dc..82a47a5f23 100644 --- a/packages/reactive-element/src/test/reactive-element_test.ts +++ b/packages/reactive-element/src/test/reactive-element_test.ts @@ -2550,28 +2550,55 @@ suite('ReactiveElement', () => { assert.equal(a.getAttribute('bar'), 'yo'); }); - test('addInitializer', () => { - class A extends ReactiveElement { + suite('initializers', () => { + class Base extends ReactiveElement { prop1?: string; prop2?: string; event?: string; } - A.addInitializer((a) => { - (a as A).prop1 = 'prop1'; + Base.addInitializer((a) => { + (a as Base).prop1 = 'prop1'; }); - A.addInitializer((a) => { - (a as A).prop2 = 'prop2'; + Base.addInitializer((a) => { + (a as Base).prop2 = 'prop2'; }); - A.addInitializer((a) => { - a.addEventListener('click', (e) => ((a as A).event = e.type)); + Base.addInitializer((a) => { + a.addEventListener('click', (e) => ((a as Base).event = e.type)); + }); + customElements.define(generateElementName(), Base); + + test('addInitializer', () => { + const a = new Base(); + container.appendChild(a); + assert.equal(a.prop1, 'prop1'); + assert.equal(a.prop2, 'prop2'); + a.dispatchEvent(new Event('click')); + assert.equal(a.event, 'click'); + }); + + class Sub extends Base { + prop3?: string; + } + Sub.addInitializer((a) => { + (a as Sub).prop3 = 'prop3'; + }); + customElements.define(generateElementName(), Sub); + + test('addInitializer on subclass', () => { + const s = new Sub(); + container.appendChild(s); + assert.equal(s.prop1, 'prop1'); + assert.equal(s.prop2, 'prop2'); + assert.equal(s.prop3, 'prop3'); + s.dispatchEvent(new Event('click')); + assert.equal(s.event, 'click'); + }); + + test('addInitializer on subclass independent from superclass', () => { + const b = new Base(); + container.appendChild(b); + assert.notOk((b as any).prop3); }); - customElements.define(generateElementName(), A); - const a = new A(); - container.appendChild(a); - assert.equal(a.prop1, 'prop1'); - assert.equal(a.prop2, 'prop2'); - a.dispatchEvent(new Event('click')); - assert.equal(a.event, 'click'); }); suite('exceptions', () => { diff --git a/scripts/update-version-variables.js b/scripts/update-version-variables.js index 5ee56dd9d1..f3698b6514 100644 --- a/scripts/update-version-variables.js +++ b/scripts/update-version-variables.js @@ -36,7 +36,39 @@ const updateVersionVariable = async (packageDir, sourcePath, variableName) => { } // Write file - await fs.writeFile(filePath, newSource); + await fs.writeFile(filePath, newSource, 'utf-8'); +}; + +const simpleUpdateVersionVariable = async ( + packageName, + sourcePath, + versionRegex, + replacementFn +) => { + // Read package.json's version + const packagePath = path.resolve('./packages', packageName, 'package.json'); + const version = JSON.parse(await fs.readFile(packagePath, 'utf-8')).version; + + const fileSource = await fs.readFile(sourcePath, 'utf-8'); + + if (!versionRegex.test(fileSource)) { + throw new Error(`Version regex not found: ${sourcePath} ${versionRegex}`); + } + + console.log(`updating version for ${sourcePath} to ${version}`); + // Replace version number + const newSource = fileSource.replace(versionRegex, replacementFn(version)); + + // Write file + await fs.writeFile(sourcePath, newSource, 'utf-8'); +}; + +const templateGoldens = (goldenDirName) => { + return [ + `packages/labs/cli/test-goldens/init/${goldenDirName}/package.json`, + /"lit": "\^.+"/, + (version) => `"lit": "^${version}"`, + ]; }; await Promise.all([ @@ -47,4 +79,13 @@ await Promise.all([ 'reactive-element.ts', 'reactiveElementVersions' ), + simpleUpdateVersionVariable( + 'lit', + './packages/labs/cli/src/lib/lit-version.ts', + /const litVersion = '.+';/, + (version) => `const litVersion = '${version}';` + ), + simpleUpdateVersionVariable('lit', ...templateGoldens('js')), + simpleUpdateVersionVariable('lit', ...templateGoldens('js-named')), + simpleUpdateVersionVariable('lit', ...templateGoldens('ts-named')), ]);