From 403997bb1288905c46344369fe916a42d5dfb1a4 Mon Sep 17 00:00:00 2001 From: graynorton Date: Tue, 29 Nov 2022 17:00:38 -0800 Subject: [PATCH] Update rethink scroll to (#3482) * Move the render function to the end of lit-html (#3284) * Move the render function to the end of lit-html Believe it or not, this is part of some work to better integrate with closure compiler's dead code elimination. * Add an empty changeset. * Fix empty changeset. * Task: Do not reset task value or error on pending (#3283) * [infra] Enable IntersectionController and PerformanceController tests (#3291) Add intersection controller and performance controller tests to CI - skipping Safari. Deflake Firefox intersection controller tests. * [labs/ssr] fix Hydrating LitElements example markup (#3298) * [labs/observers] Fix controllers not observing target if initialized after host connected (#3293) Co-authored-by: Steve Orvell * [labs/analyzer] Refactor Analyzer into better fit for use in plugins (#3288) * Refactor context * Make Analyzer implement AnalyzerInterface rather than has-a AnalyzerContext * Add PackageInfo and pass to getModule * Add changeset. Minor cleanup. * Fix cli test * Normalize rootDir * Refactor PackageAnalyzer into factory * Address feedback. Minor cleanup. * Slack -> Discord readme (#3307) * [@labs/gen-wrapper-react] TestOutput links to monorepo for dependencies (#3310) * test-output points to the same react dependency * remove types from tsconfig in labs_react * restore multiple react versions * [labs/cli] Lazily install and locally version localize (#2936) * [cli] Lazily install and locally version localize Also merge the two localize commands into one They have just about the same deps and share some setup and teardown code, there's no win in putting them in separate modules. * Use better assertions of no errors. This should print out the stderr output in the case there was some. * Fix error output The .finally fork of the Promise.race result promise was causing an early exit from node before the ordinary uvu error handling could kick in. * Fix failing test It was passing locally because the cwd was set to the CLI directory, but we want to run in a fake workspace directory. * Add an installation message when running npm install. * Changeset * Use try/finally instead of promise methods * Move localize command into its own package. * Task add onComplete and onError (#3287) * [lit] Add "types" to package exports (#3320) * [lit-html] Add `isServer` environment checker module (#3318) 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. * [labs/analyzer] Adds support for analyzing JavaScript packages (#3304) * Add support for analyzing JavaScript packages * Fix customElements.define detection, add comments. * Better comments / error handling * Revert accidental SSR changes * Add optional/non-null to model * Run analyzer_test in JS. Add changeset. * Fix gen-wrapper-angular * Fix another inadvertant SSR change * Address review feedback. * Add test based on feedback. * [labs/observers] Improve controllers value type from unknown to generic (#3294) 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. * [labs/react] Provide explicit return type from createComponent (#3163) * create params object * add changeset * update readme * eeek, this requires generics * found correct return type * more refined type * add ref typing * adjust ref typings * type forwarded instance * expose types at top of file * organize types * no react window module * no react window module * add event listeners * checkout readmes from main * remove as casting in render * remove anys * create minimal JSXInterface for library * save types * jsxmodule * attrubtes over htmlprops * explicit return of element types * move comments * minimal references to window * remove ref cast * remove code changes, type only changes * update changeset * ideal * roll back to minimal amount of changes * types at top of file * better comment * rename userprops to element props * add types to test refs * add extends to exported element props type * pause to sync * add comments, more specific names for events * update EventNames downstream * include package types in tsconfig * undo * tests pass with no extra exports * exposing element props successful * wow only the exposed ReactWebComponent fails * add react types to workspaces * move types to dev deps * remove artifacts from different PR * remove rollup artifact from other PR * declare types in test-output tsconfig * capitals for classes * add export to ReactWebComponent * a/b the types array in test-output * add test for ReactWebComponent type * simplify test * componentProps to ReactComponentProps * type only test * update description * unblocked gen-wrapper-react * add comment for type test * restore modified files * restore modified files, again * remove types tsconfig property * only export what's required * remove old code * add return type * remove extra line in index * [labs/observers] observed targets are re-observed when the host is reconnected (#3321) Controllers now track all observed targets and will restore observing targets when host is reconnected. Fixes: https://github.com/lit/lit/issues/2902 * [labs/observers] Add unobserve method to ResizeController and IntersectionController (#3323) Add unobserve method to `ResizeController` and `IntersectionController` to match native API. Fixes: #3237 * [labs/gen-utils] Add core packages to testing install with link (#3330) * [infra] Update changesets and package for release (#3332) * Update changesets for release * Update cli-localize package.json for release * Add @lit-labs/cli-localize to changeset * Remove gen-wrapper-angular from changesets (#3336) * Version Packages (#3337) * Unpin Node version for windows-tools test (#3338) * [labs/react] introduce a options object (#2988) * create params object * add changeset * update readme * eeek, this requires generics * found correct return type * more refined type * add ref typing * adjust ref typings * type forwarded instance * expose types at top of file * organize types * no react window module * no react window module * more merge main * initial params bag * add changeset, remove commented code * ReactOrParams * destructure params * update tests, react is optional * remove optional react * remove default react * change is a patch * forgot options.react * [labs/react] Update REAMDE for function overload (#3350) * initial commit * add empty changeset * [@lit-labs/router] add Routes.link tests (#3348) * [gen-manifest] Initial impl of CEM generator (#2990) * [gen-manifest] Initial impl of CEM generator Reset changelog Fix readme * Sync with monorepo changes * Fix comments * Add variable declaration * Update to changes on main. * Fix version for gen-utils * Fix analyzer version * Fix and add tests for type reference serialization * [labs/analyzer] Cache Module models based on dependencies. (#3333) * Cache Module models based on dependencies. * Cleanup and add changeset. * Windows path fixes * Normalize all the paths for Windows * Move moduleCache from module var to Analyzer field. * Add missing wireit input * Add missing wireit output * Fix typo in task README (#3385) * Initializers are copied but separate from superclass initializers (#3374) Initializers are copied but separate from superclass initializers, fixes #/3373. * Example code had an h1 tag closed by an h3 tag (#3392) I changed the h3 tag to be an h1 tag to match the other routes * Improvements to Vue/React wrappers (#3377) Updates react wrapper to correctly type events Update vue wrapper * update vite/vue deps * properly type events * configure defaults using the Vue convention: whenever unset, revert to default value. * Wrapper test elements + runtime tests (#3384) * Adds additional test elements * element-events: for testing events * element-props: for testing property types * element-slots: for testing slotting. * Adds property types to angular wrapper * [@labs/react] certain attributes should be removed when undefined or null (#3128) * add sieve for boolean attributes * unchange stuff that isn't required yet * add changeset * no sieve, use hasAttribute * use hasOwnProperty * boolean attributes should test as null * pause * let react handle the nullifies * remove unnecessary return * add comment, update changeset * remove 232 * cascade logic over nested * update comments * don't watch for disabled * show test updates and output * hidden attr is alright * passing tests * set HTMLPrototype undefined values to empty string * id is the special case * add tests for properties * update tests for properties * remove nested iffs from set property * format * set as empty string * change value, don't assign * value as str * remove attribute if undefined * set value as string on htmlelement attr * match vanilla react behavior * order ifs by cost, comment alternatives * forgot return * checkpoint * sync with example gist * draggable is null * checkpoint * update tests, wrappedEl vs el * test div against web component * remove extra lines * update changeset * include unwrapped web component x-foo * add null checks at end of attribute checks * add comments, add undefined tests after boolean * typo, enumerated * add null checkes to test, add ts-expect-errors * add test ordering * forgot one truthy test * [infra] Use new projectV2 object in issue workflow (#3419) * Add lit to changeset along side reactive-element (#3422) * Version Packages (#3423) * Update chromedriver to 107 for benchmark tests (#3427) * Update chromedriver to 107 for benchmark tests * [labs/virtualizer] Fix width inheritance calculation (issue #3400) (#3424) * Added a test to demonstrate virtualizer width inheritance bug in #3400 * Applied the fix to Virtualizer _updateView to interpret width correctly. * Added changeset describing the fix for #3400. * [labs/virtualizer] Export event classes through a new events.js (#3430) * Added an events.js to export the RangeChangedEvent and VisibilityChangedEvent classes. * Point to events.js to get the event classes now and moved the custom Range interface into there. * Added events.js files to package.json's files property and the wireit outputs for build:ts. * [labs/context] Rename context decorators to consume and provide (#3398) * [@labs/react] Filter __forwardedRef in prod build (#3409) * [labs/react] Filter __forwardedRef in prod build * rebased from main, branched from react-forwarded-ref * added changeset * update ref setting * add dunders back in * add comments, update changeset * iterrate userprops Co-authored-by: Justin Fagnani * [labs/context] Make @consume decorator work with optional fields (#3399) * [labs/context] Rename ContextKey to Context (#3404) * Allow ContextProvider to be added lazily and still work with ContextRoot (#3434) * [infra] Fix npm install with version 9 (#3448) * Fix dep on missing folder. Fixes npm install on npm 9. * Add empty changeset * Implement `lit init element` (#3248) Co-authored-by: Justin Fagnani * Logo dark mode support (#3457) * Update logo.svg * empty changeset * lit logo dark mode * add light theme too * fix file locations * use srcset * slack to discord badge * Gitignore build output files from virtualizer (#3454) * Gitignore build output files from virtualizer * add prettierignore files * [labs/analyzer] Add lazy Declaration analysis, Reference dereferencing, and Superclass support (#3380) * Fix comment * Use NODE_OPTIONS=--enable-source-maps * Make declarations lazy * Analyze exports and add ability to dereference References to them * Add superClass reference analysis * Add changeset * fixup! Use NODE_OPTIONS=--enable-source-maps * Fix references to ImportTypes * Add CEM generation to CLI * Fix type import references * Address review feedback. * Fix path normalization on Windows * Updates based on feedback. * Add getSpecifierString to another site. * [labs/gen-manifest] Adds `exports` and more metadata to manifest generator (#3464) * Adds exports to manifest generator Also fixes a few bugs in export analysis and adds better tests. * Add `@slot`, `@cssProp`, and `@cssPart` to manifest generator * Fix config to make JS program analysis faster. * Add windows line ending to regex * Add changeset * Additional Windows line endings fix. * Add support for parsing description, summary, & deprecated * Add more exports support * Emit summary & description in manifest. * Gitignore build output files from virtualizer (#3454) * Gitignore build output files from virtualizer * add prettierignore files * Add the js.map extension to the files property for events.js. * update .prettierignore and .gitignore to include events.js.map Co-authored-by: Peter Burns Co-authored-by: Elliott Marquez <5981958+e111077@users.noreply.github.com> Co-authored-by: Andrew Jakubowicz Co-authored-by: Michael Potter Co-authored-by: Steve Orvell Co-authored-by: Kevin Schaaf Co-authored-by: Brian Taylor Vann Co-authored-by: Augustine Kim Co-authored-by: Lit Robot <98060554+lit-robot@users.noreply.github.com> Co-authored-by: Nick Cipriani Co-authored-by: Brendan Baldwin Co-authored-by: Justin Fagnani --- ...{eight-frogs-roll.md => blue-feet-roll.md} | 0 .changeset/cyan-moose-know.md | 5 + ...irty-years-camp.md => dirty-mugs-rhyme.md} | 0 .changeset/dull-months-prove.md | 6 + .changeset/five-falcons-suffer.md | 6 + .changeset/fresh-oranges-search.md | 2 + .changeset/lemon-llamas-rule.md | 5 + .changeset/moody-colts-trade.md | 8 - .changeset/six-buttons-cover.md | 2 + .changeset/tidy-flowers-mate.md | 5 + .changeset/unlucky-lamps-sing.md | 5 + .changeset/unlucky-parents-melt.md | 5 + .changeset/wild-pillows-carry.md | 6 + .eslintignore | 13 + .github/workflows/add-issues-to-project.yml | 8 +- .github/workflows/tests.yml | 4 +- .prettierignore | 14 + README.md | 10 +- lit-next.code-workspace | 20 + package-lock.json | 356 ++++++---- package.json | 1 + packages/benchmarks/package.json | 2 +- packages/labs/analyzer/.vscode/launch.json | 2 +- packages/labs/analyzer/CHANGELOG.md | 20 + packages/labs/analyzer/package.json | 7 +- packages/labs/analyzer/src/index.ts | 3 + .../labs/analyzer/src/lib/analyze-package.ts | 93 +++ packages/labs/analyzer/src/lib/analyzer.ts | 160 +++-- .../analyzer/src/lib/javascript/classes.ts | 122 +++- .../labs/analyzer/src/lib/javascript/jsdoc.ts | 134 ++++ .../analyzer/src/lib/javascript/modules.ts | 348 +++++++-- .../analyzer/src/lib/javascript/packages.ts | 68 ++ .../analyzer/src/lib/javascript/variables.ts | 99 ++- .../src/lib/lit-element/decorators.ts | 2 +- .../analyzer/src/lib/lit-element/events.ts | 70 +- .../src/lib/lit-element/lit-element.ts | 144 +++- .../src/lib/lit-element/properties.ts | 184 ++++- packages/labs/analyzer/src/lib/model.ts | 320 ++++++++- packages/labs/analyzer/src/lib/paths.ts | 18 + .../labs/analyzer/src/lib/program-context.ts | 566 --------------- packages/labs/analyzer/src/lib/references.ts | 415 +++++++++++ packages/labs/analyzer/src/lib/types.ts | 273 ++++++++ .../labs/analyzer/src/test/analyzer_test.ts | 83 +-- .../src/test/javascript/exports_test.ts | 390 +++++++++++ .../src/test/javascript/modules_test.ts | 238 +++++++ .../src/test/lit-element/events_test.ts | 283 ++++---- .../src/test/lit-element/jsdoc_test.ts | 306 ++++++++ .../src/test/lit-element/lit-element_test.ts | 221 ++++-- .../src/test/lit-element/properties_test.ts | 392 ++++++----- packages/labs/analyzer/src/test/types_test.ts | 37 +- packages/labs/analyzer/src/test/utils.ts | 202 ++++++ .../decorators-properties/package.json | 6 - .../basic-elements/class-a.js} | 0 .../basic-elements/default-element.js} | 0 .../test-files/js/basic-elements/element-a.js | 34 + .../basic-elements/element-b.js} | 16 +- .../test-files/js/basic-elements/element-c.js | 24 + .../basic-elements/not-lit.js} | 0 .../test-files/js/basic-elements/package.json | 6 + .../test-files/js/events/custom-event.js | 18 + .../test-files/js/events/element-a.js | 52 ++ .../test-files/{ => js}/events/package.json | 0 .../analyzer/test-files/js/jsdoc/element-a.js | 86 +++ .../analyzer/test-files/js/jsdoc/package.json | 6 + .../test-files/js/modules/module-a.js | 11 + .../test-files/js/modules/module-b.js | 9 + .../test-files/js/modules/package.json | 6 + .../test-files/js/properties/element-a.js | 60 ++ .../external.ts => js/properties/external.js} | 0 .../test-files/js/properties/package.json | 6 + .../{ => ts}/basic-elements/package.json | 0 .../ts/basic-elements/src/class-a.ts | 9 + .../ts/basic-elements/src/default-element.ts | 9 + .../{ => ts}/basic-elements/src/element-a.ts | 5 + .../ts/basic-elements/src/element-b.ts | 39 ++ .../ts/basic-elements/src/element-c.ts | 19 + .../ts/basic-elements/src/not-lit.ts | 3 + .../{ => ts}/basic-elements/tsconfig.json | 0 .../test-files/ts/events/package.json | 6 + .../{ => ts}/events/src/custom-event.ts | 0 .../{ => ts}/events/src/element-a.ts | 0 .../events}/tsconfig.json | 0 .../analyzer/test-files/ts/jsdoc/package.json | 6 + .../test-files/ts/jsdoc/src/element-a.ts | 87 +++ .../{events => ts/jsdoc}/tsconfig.json | 0 .../test-files/ts/modules/package.json | 6 + .../test-files/ts/modules/src/module-a.ts | 10 + .../test-files/ts/modules/src/module-b.ts | 9 + .../{types => ts/modules}/tsconfig.json | 0 .../test-files/ts/properties/package.json | 6 + .../properties}/src/element-a.ts | 11 + .../test-files/ts/properties/src/external.ts | 7 + .../test-files/ts/properties/tsconfig.json | 16 + .../test-files/{ => ts}/types/package.json | 0 .../test-files/{ => ts}/types/src/external.ts | 4 + .../test-files/{ => ts}/types/src/module.ts | 6 +- .../test-files/ts/types/tsconfig.json | 16 + packages/labs/cli-localize/.gitignore | 2 + packages/labs/cli-localize/CHANGELOG.md | 9 + packages/labs/cli-localize/README.md | 7 + packages/labs/cli-localize/package-lock.json | 660 ++++++++++++++++++ packages/labs/cli-localize/package.json | 83 +++ .../build.ts => cli-localize/src/commands.ts} | 30 +- packages/labs/cli-localize/src/index.ts | 59 ++ packages/labs/cli-localize/tsconfig.json | 23 + packages/labs/cli/.prettierignore | 1 + packages/labs/cli/CHANGELOG.md | 20 + packages/labs/cli/bin/lit.js | 4 +- packages/labs/cli/package.json | 11 +- packages/labs/cli/src/lib/commands/help.ts | 8 +- packages/labs/cli/src/lib/commands/init.ts | 82 +++ packages/labs/cli/src/lib/commands/labs.ts | 12 +- .../labs/cli/src/lib/commands/localize.ts | 51 +- .../labs/cli/src/lib/generate/generate.ts | 34 +- .../cli/src/lib/init/element-starter/index.ts | 52 ++ .../templates/demo/index.html.ts | 25 + .../element-starter/templates/gitignore.ts | 19 + .../element-starter/templates/lib/element.ts | 107 +++ .../element-starter/templates/npmignore.ts | 17 + .../element-starter/templates/package.json.ts | 55 ++ .../templates/tsconfig.json.ts | 31 + packages/labs/cli/src/lib/lit-cli.ts | 23 +- packages/labs/cli/src/lib/lit-version.ts | 1 + packages/labs/cli/src/lib/localize/extract.ts | 54 -- packages/labs/cli/src/test/cli-test-utils.ts | 4 + packages/labs/cli/src/test/gen/react_test.ts | 2 +- packages/labs/cli/src/test/help_test.ts | 31 +- .../labs/cli/src/test/init/element_test.ts | 159 +++++ packages/labs/cli/src/test/uvu-wrapper.ts | 30 +- .../cli/test-goldens/init/js-named/.gitignore | 1 + .../cli/test-goldens/init/js-named/.npmignore | 5 + .../init/js-named/demo/index.html | 10 + .../init/js-named/lib/le-element.js | 54 ++ .../test-goldens/init/js-named/package.json | 26 + .../labs/cli/test-goldens/init/js/.gitignore | 1 + .../labs/cli/test-goldens/init/js/.npmignore | 5 + .../cli/test-goldens/init/js/demo/index.html | 10 + .../test-goldens/init/js/lib/my-element.js | 54 ++ .../cli/test-goldens/init/js/package.json | 26 + .../cli/test-goldens/init/ts-named/.gitignore | 2 + .../cli/test-goldens/init/ts-named/.npmignore | 5 + .../init/ts-named/demo/index.html | 10 + .../test-goldens/init/ts-named/package.json | 30 + .../init/ts-named/src/el-element.ts | 47 ++ .../test-goldens/init/ts-named/tsconfig.json | 19 + packages/labs/context/README.md | 16 +- packages/labs/context/src/index.ts | 24 +- packages/labs/context/src/lib/context-key.ts | 26 - .../context/src/lib/context-request-event.ts | 18 +- packages/labs/context/src/lib/context-root.ts | 8 +- .../src/lib/controllers/context-consumer.ts | 13 +- .../src/lib/controllers/context-provider.ts | 14 +- .../labs/context/src/lib/create-context.ts | 56 ++ .../{context-provided.ts => consume.ts} | 12 +- .../{context-provider.ts => provide.ts} | 9 +- .../context/src/test/context-provider_test.ts | 19 +- .../context/src/test/context-request_test.ts | 6 +- .../context/src/test/late-provider_test.ts | 104 ++- .../src/test/provider-and-consumer_test.ts | 8 +- .../src/test/provider-consumer_test.ts | 4 +- packages/labs/gen-manifest/.gitignore | 6 + packages/labs/gen-manifest/CHANGELOG.md | 10 + packages/labs/gen-manifest/README.md | 5 + .../test-element-a/custom-elements.json | 477 +++++++++++++ packages/labs/gen-manifest/package.json | 78 +++ packages/labs/gen-manifest/src/index.ts | 220 ++++++ .../gen-manifest/src/test/generate_test.ts | 36 + packages/labs/gen-manifest/tsconfig.json | 24 + packages/labs/gen-utils/CHANGELOG.md | 14 + packages/labs/gen-utils/package.json | 4 +- packages/labs/gen-utils/src/lib/str-utils.ts | 6 + .../gen-utils/src/test/package-utils_test.ts | 22 + .../labs/gen-wrapper-angular/CHANGELOG.md | 16 + .../goldens/test-element-a/.gitignore | 5 +- .../goldens/test-element-a/package.json | 5 +- .../goldens/test-element-a/src/element-a.ts | 5 +- .../test-element-a/src/element-events.ts | 88 +++ .../test-element-a/src/element-props.ts | 80 +++ .../test-element-a/src/element-slots.ts | 27 + .../labs/gen-wrapper-angular/package.json | 13 +- .../src/lib/wrapper-module-template.ts | 48 +- .../src/test/generation/generate_test.ts | 8 +- packages/labs/gen-wrapper-react/CHANGELOG.md | 26 + .../goldens/test-element-a/.gitignore | 5 +- .../goldens/test-element-a/package.json | 5 +- .../goldens/test-element-a/src/element-a.ts | 4 +- .../test-element-a/src/element-events.ts | 34 + .../test-element-a/src/element-props.ts | 13 + .../test-element-a/src/element-slots.ts | 11 + packages/labs/gen-wrapper-react/package.json | 4 +- packages/labs/gen-wrapper-react/src/index.ts | 76 +- .../src/test-gen/generate_test.ts | 8 +- .../test-output/package.json | 15 +- .../test-output/rollup.config.js | 2 +- .../src/tests/test-element-events_test.tsx | 107 +++ .../src/tests/test-element-props_test.tsx | 61 ++ .../src/tests/test-element-slots_test.tsx | 80 +++ .../test-output/src/tests/tests.ts | 10 + packages/labs/gen-wrapper-vue/CHANGELOG.md | 26 + .../goldens/test-element-a/.gitignore | 5 +- .../goldens/test-element-a/package.json | 15 +- .../goldens/test-element-a/src/ElementA.vue | 51 +- .../test-element-a/src/ElementEvents.vue | 79 +++ .../test-element-a/src/ElementProps.vue | 56 ++ .../test-element-a/src/ElementSlots.vue | 41 ++ .../goldens/test-element-a/vite.config.ts | 15 +- packages/labs/gen-wrapper-vue/package.json | 4 +- packages/labs/gen-wrapper-vue/src/index.ts | 6 +- .../src/lib/package-json-template.ts | 12 +- .../src/lib/vite.config-template.ts | 8 +- .../src/lib/wrapper-module-template-sfc.ts | 158 +++-- .../src/lib/wrapper-module-template.ts | 125 ---- .../src/test-gen/generate_test.ts | 8 +- .../gen-wrapper-vue/test-output/package.json | 2 +- .../test-output/rollup.config.js | 2 +- .../test-output/src/tests/SlotContainer.vue | 15 + .../src/tests/test-element-events_test.ts | 104 +++ .../src/tests/test-element-props_test.ts | 92 +++ .../src/tests/test-element-slots_test.ts | 62 ++ .../test-output/src/tests/tests.ts | 10 + .../test-output/vite.config.ts | 4 +- packages/labs/observers/CHANGELOG.md | 18 + packages/labs/observers/package.json | 2 +- .../observers/src/intersection_controller.ts | 46 +- .../labs/observers/src/mutation_controller.ts | 37 +- .../observers/src/performance_controller.ts | 23 +- .../labs/observers/src/resize_controller.ts | 46 +- .../src/test/intersection_controller_test.ts | 185 ++++- .../src/test/mutation_controller_test.ts | 157 ++++- .../src/test/performance_controller_test.ts | 101 ++- .../src/test/resize_controller_test.ts | 165 ++++- packages/labs/react/CHANGELOG.md | 14 + packages/labs/react/README.md | 28 +- packages/labs/react/package.json | 2 +- packages/labs/react/src/create-component.ts | 277 +++++--- .../react/src/test/create-component_test.tsx | 421 +++++++---- packages/labs/react/tsconfig.json | 3 +- packages/labs/router/README.md | 2 +- packages/labs/router/src/test/router_test.ts | 55 ++ packages/labs/ssr/README.md | 9 +- packages/labs/task/CHANGELOG.md | 25 + packages/labs/task/README.md | 4 +- packages/labs/task/package.json | 2 +- packages/labs/task/src/task.ts | 18 +- packages/labs/task/src/test/task_test.ts | 103 ++- .../test-projects/test-element-a/package.json | 22 +- .../test-element-a/src/detail-type.ts | 4 + .../test-element-a/src/element-a.ts | 27 +- .../test-element-a/src/element-events.ts | 113 +++ .../test-element-a/src/element-props.ts | 59 ++ .../test-element-a/src/element-slots.ts | 25 + .../test-element-a/src/package-stuff.ts | 17 + .../test-element-a/src/special-event.ts | 12 + packages/labs/virtualizer/.gitignore | 2 + packages/labs/virtualizer/package.json | 2 +- .../labs/virtualizer/src/LitVirtualizer.ts | 3 +- packages/lit-html/.gitignore | 1 + packages/lit-html/CHANGELOG.md | 6 + packages/lit-html/package.json | 10 +- packages/lit-html/rollup.config.js | 1 + packages/lit-html/src/is-server.ts | 24 + packages/lit-html/src/lit-html.ts | 166 ++--- packages/lit-html/src/test/is-server_test.ts | 14 + packages/lit-html/src/test/node-imports.ts | 4 + packages/lit/CHANGELOG.md | 19 + packages/lit/README.md | 13 +- packages/lit/logo-dark.svg | 18 + packages/lit/package.json | 44 +- packages/lit/src/index.ts | 1 + packages/lit/src/test/node-imports.ts | 4 + packages/reactive-element/CHANGELOG.md | 6 + packages/reactive-element/package.json | 2 +- .../reactive-element/src/reactive-element.ts | 12 +- .../src/test/reactive-element_test.ts | 57 +- scripts/update-version-variables.js | 43 +- 275 files changed, 11218 insertions(+), 2459 deletions(-) rename .changeset/{eight-frogs-roll.md => blue-feet-roll.md} (100%) create mode 100644 .changeset/cyan-moose-know.md rename .changeset/{thirty-years-camp.md => dirty-mugs-rhyme.md} (100%) create mode 100644 .changeset/dull-months-prove.md create mode 100644 .changeset/five-falcons-suffer.md create mode 100644 .changeset/fresh-oranges-search.md create mode 100644 .changeset/lemon-llamas-rule.md delete mode 100644 .changeset/moody-colts-trade.md create mode 100644 .changeset/six-buttons-cover.md create mode 100644 .changeset/tidy-flowers-mate.md create mode 100644 .changeset/unlucky-lamps-sing.md create mode 100644 .changeset/unlucky-parents-melt.md create mode 100644 .changeset/wild-pillows-carry.md create mode 100644 packages/labs/analyzer/src/lib/analyze-package.ts create mode 100644 packages/labs/analyzer/src/lib/javascript/jsdoc.ts create mode 100644 packages/labs/analyzer/src/lib/javascript/packages.ts delete mode 100644 packages/labs/analyzer/src/lib/program-context.ts create mode 100644 packages/labs/analyzer/src/lib/references.ts create mode 100644 packages/labs/analyzer/src/lib/types.ts create mode 100644 packages/labs/analyzer/src/test/javascript/exports_test.ts create mode 100644 packages/labs/analyzer/src/test/javascript/modules_test.ts create mode 100644 packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts create mode 100644 packages/labs/analyzer/src/test/utils.ts delete mode 100644 packages/labs/analyzer/test-files/decorators-properties/package.json rename packages/labs/analyzer/test-files/{basic-elements/src/class-a.ts => js/basic-elements/class-a.js} (100%) rename packages/labs/analyzer/test-files/{basic-elements/src/default-element.ts => js/basic-elements/default-element.js} (100%) create mode 100644 packages/labs/analyzer/test-files/js/basic-elements/element-a.js rename packages/labs/analyzer/test-files/{basic-elements/src/element-b.ts => js/basic-elements/element-b.js} (64%) create mode 100644 packages/labs/analyzer/test-files/js/basic-elements/element-c.js rename packages/labs/analyzer/test-files/{basic-elements/src/not-lit.ts => js/basic-elements/not-lit.js} (100%) create mode 100644 packages/labs/analyzer/test-files/js/basic-elements/package.json create mode 100644 packages/labs/analyzer/test-files/js/events/custom-event.js create mode 100644 packages/labs/analyzer/test-files/js/events/element-a.js rename packages/labs/analyzer/test-files/{ => js}/events/package.json (100%) create mode 100644 packages/labs/analyzer/test-files/js/jsdoc/element-a.js create mode 100644 packages/labs/analyzer/test-files/js/jsdoc/package.json create mode 100644 packages/labs/analyzer/test-files/js/modules/module-a.js create mode 100644 packages/labs/analyzer/test-files/js/modules/module-b.js create mode 100644 packages/labs/analyzer/test-files/js/modules/package.json create mode 100644 packages/labs/analyzer/test-files/js/properties/element-a.js rename packages/labs/analyzer/test-files/{decorators-properties/src/external.ts => js/properties/external.js} (100%) create mode 100644 packages/labs/analyzer/test-files/js/properties/package.json rename packages/labs/analyzer/test-files/{ => ts}/basic-elements/package.json (100%) create mode 100644 packages/labs/analyzer/test-files/ts/basic-elements/src/class-a.ts create mode 100644 packages/labs/analyzer/test-files/ts/basic-elements/src/default-element.ts rename packages/labs/analyzer/test-files/{ => ts}/basic-elements/src/element-a.ts (86%) create mode 100644 packages/labs/analyzer/test-files/ts/basic-elements/src/element-b.ts create mode 100644 packages/labs/analyzer/test-files/ts/basic-elements/src/element-c.ts create mode 100644 packages/labs/analyzer/test-files/ts/basic-elements/src/not-lit.ts rename packages/labs/analyzer/test-files/{ => ts}/basic-elements/tsconfig.json (100%) create mode 100644 packages/labs/analyzer/test-files/ts/events/package.json rename packages/labs/analyzer/test-files/{ => ts}/events/src/custom-event.ts (100%) rename packages/labs/analyzer/test-files/{ => ts}/events/src/element-a.ts (100%) rename packages/labs/analyzer/test-files/{decorators-properties => ts/events}/tsconfig.json (100%) create mode 100644 packages/labs/analyzer/test-files/ts/jsdoc/package.json create mode 100644 packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts rename packages/labs/analyzer/test-files/{events => ts/jsdoc}/tsconfig.json (100%) create mode 100644 packages/labs/analyzer/test-files/ts/modules/package.json create mode 100644 packages/labs/analyzer/test-files/ts/modules/src/module-a.ts create mode 100644 packages/labs/analyzer/test-files/ts/modules/src/module-b.ts rename packages/labs/analyzer/test-files/{types => ts/modules}/tsconfig.json (100%) create mode 100644 packages/labs/analyzer/test-files/ts/properties/package.json rename packages/labs/analyzer/test-files/{decorators-properties => ts/properties}/src/element-a.ts (88%) create mode 100644 packages/labs/analyzer/test-files/ts/properties/src/external.ts create mode 100644 packages/labs/analyzer/test-files/ts/properties/tsconfig.json rename packages/labs/analyzer/test-files/{ => ts}/types/package.json (100%) rename packages/labs/analyzer/test-files/{ => ts}/types/src/external.ts (74%) rename packages/labs/analyzer/test-files/{ => ts}/types/src/module.ts (92%) create mode 100644 packages/labs/analyzer/test-files/ts/types/tsconfig.json create mode 100644 packages/labs/cli-localize/.gitignore create mode 100644 packages/labs/cli-localize/CHANGELOG.md create mode 100644 packages/labs/cli-localize/README.md create mode 100644 packages/labs/cli-localize/package-lock.json create mode 100644 packages/labs/cli-localize/package.json rename packages/labs/{cli/src/lib/localize/build.ts => cli-localize/src/commands.ts} (60%) create mode 100644 packages/labs/cli-localize/src/index.ts create mode 100644 packages/labs/cli-localize/tsconfig.json create mode 100644 packages/labs/cli/.prettierignore create mode 100644 packages/labs/cli/src/lib/commands/init.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/index.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/demo/index.html.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/gitignore.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/lib/element.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/npmignore.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/package.json.ts create mode 100644 packages/labs/cli/src/lib/init/element-starter/templates/tsconfig.json.ts create mode 100644 packages/labs/cli/src/lib/lit-version.ts delete mode 100644 packages/labs/cli/src/lib/localize/extract.ts create mode 100644 packages/labs/cli/src/test/init/element_test.ts create mode 100644 packages/labs/cli/test-goldens/init/js-named/.gitignore create mode 100644 packages/labs/cli/test-goldens/init/js-named/.npmignore create mode 100644 packages/labs/cli/test-goldens/init/js-named/demo/index.html create mode 100644 packages/labs/cli/test-goldens/init/js-named/lib/le-element.js create mode 100644 packages/labs/cli/test-goldens/init/js-named/package.json create mode 100644 packages/labs/cli/test-goldens/init/js/.gitignore create mode 100644 packages/labs/cli/test-goldens/init/js/.npmignore create mode 100644 packages/labs/cli/test-goldens/init/js/demo/index.html create mode 100644 packages/labs/cli/test-goldens/init/js/lib/my-element.js create mode 100644 packages/labs/cli/test-goldens/init/js/package.json create mode 100644 packages/labs/cli/test-goldens/init/ts-named/.gitignore create mode 100644 packages/labs/cli/test-goldens/init/ts-named/.npmignore create mode 100644 packages/labs/cli/test-goldens/init/ts-named/demo/index.html create mode 100644 packages/labs/cli/test-goldens/init/ts-named/package.json create mode 100644 packages/labs/cli/test-goldens/init/ts-named/src/el-element.ts create mode 100644 packages/labs/cli/test-goldens/init/ts-named/tsconfig.json delete mode 100644 packages/labs/context/src/lib/context-key.ts create mode 100644 packages/labs/context/src/lib/create-context.ts rename packages/labs/context/src/lib/decorators/{context-provided.ts => consume.ts} (84%) rename packages/labs/context/src/lib/decorators/{context-provider.ts => provide.ts} (92%) create mode 100644 packages/labs/gen-manifest/.gitignore create mode 100644 packages/labs/gen-manifest/CHANGELOG.md create mode 100644 packages/labs/gen-manifest/README.md create mode 100644 packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json create mode 100644 packages/labs/gen-manifest/package.json create mode 100644 packages/labs/gen-manifest/src/index.ts create mode 100644 packages/labs/gen-manifest/src/test/generate_test.ts create mode 100644 packages/labs/gen-manifest/tsconfig.json create mode 100644 packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-events.ts create mode 100644 packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-props.ts create mode 100644 packages/labs/gen-wrapper-angular/goldens/test-element-a/src/element-slots.ts create mode 100644 packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-events.ts create mode 100644 packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-props.ts create mode 100644 packages/labs/gen-wrapper-react/goldens/test-element-a/src/element-slots.ts create mode 100644 packages/labs/gen-wrapper-react/test-output/src/tests/test-element-events_test.tsx create mode 100644 packages/labs/gen-wrapper-react/test-output/src/tests/test-element-props_test.tsx create mode 100644 packages/labs/gen-wrapper-react/test-output/src/tests/test-element-slots_test.tsx create mode 100644 packages/labs/gen-wrapper-react/test-output/src/tests/tests.ts create mode 100644 packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementEvents.vue create mode 100644 packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementProps.vue create mode 100644 packages/labs/gen-wrapper-vue/goldens/test-element-a/src/ElementSlots.vue delete mode 100644 packages/labs/gen-wrapper-vue/src/lib/wrapper-module-template.ts create mode 100644 packages/labs/gen-wrapper-vue/test-output/src/tests/SlotContainer.vue create mode 100644 packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-events_test.ts create mode 100644 packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-props_test.ts create mode 100644 packages/labs/gen-wrapper-vue/test-output/src/tests/test-element-slots_test.ts create mode 100644 packages/labs/gen-wrapper-vue/test-output/src/tests/tests.ts create mode 100644 packages/labs/test-projects/test-element-a/src/detail-type.ts create mode 100644 packages/labs/test-projects/test-element-a/src/element-events.ts create mode 100644 packages/labs/test-projects/test-element-a/src/element-props.ts create mode 100644 packages/labs/test-projects/test-element-a/src/element-slots.ts create mode 100644 packages/labs/test-projects/test-element-a/src/package-stuff.ts create mode 100644 packages/labs/test-projects/test-element-a/src/special-event.ts create mode 100644 packages/lit-html/src/is-server.ts create mode 100644 packages/lit-html/src/test/is-server_test.ts create mode 100644 packages/lit/logo-dark.svg 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')), ]);