Repository: ReactiveX/rxjs Branch: master Commit: c15b37f81ba5 Files: 1068 Total size: 4.1 MB Directory structure: gitextract_fhrsca5d/ ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── config.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ └── install-dependencies/ │ │ └── action.yml │ ├── lock.yml │ └── workflows/ │ ├── ci_main.yml │ ├── ci_ts_latest.yml │ ├── publish.yml │ └── rebase.yml ├── .gitignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── apps/ │ └── rxjs.dev/ │ ├── .browserslistrc │ ├── .eslintrc.js │ ├── .firebaserc │ ├── .gitignore │ ├── README.md │ ├── angular.json │ ├── content/ │ │ ├── 6-to-7-change-summary.md │ │ ├── blackLivesMatter.md │ │ ├── code-of-conduct.md │ │ ├── deprecations/ │ │ │ ├── array-argument.md │ │ │ ├── breaking-changes.md │ │ │ ├── index.md │ │ │ ├── multicasting.md │ │ │ ├── resultSelector.md │ │ │ ├── scheduler-argument.md │ │ │ ├── subscribe-arguments.md │ │ │ └── to-promise.md │ │ ├── file-not-found.md │ │ ├── guide/ │ │ │ ├── core-semantics.md │ │ │ ├── glossary-and-semantics.md │ │ │ ├── higher-order-observables.md │ │ │ ├── importing.md │ │ │ ├── installation.md │ │ │ ├── observable.md │ │ │ ├── observer.md │ │ │ ├── operators.md │ │ │ ├── overview.md │ │ │ ├── scheduler.md │ │ │ ├── subject.md │ │ │ ├── subscription.md │ │ │ └── testing/ │ │ │ └── marble-testing.md │ │ ├── license.md │ │ ├── maintainer-guidelines.md │ │ ├── marketing/ │ │ │ ├── announcements.json │ │ │ ├── api.html │ │ │ ├── contributors.json │ │ │ ├── index.html │ │ │ ├── operator-decision-tree.html │ │ │ └── team.html │ │ ├── navigation.json │ │ └── operator-decision-tree.yml │ ├── database.rules.json │ ├── firebase.json │ ├── ngsw-config.json │ ├── package.json │ ├── scripts/ │ │ ├── _payload-limits.json │ │ ├── deploy-to-firebase.sh │ │ ├── deploy-to-firebase.test.sh │ │ ├── payload.sh │ │ ├── publish-docs.sh │ │ └── test-pwa-score.js │ ├── src/ │ │ ├── app/ │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ ├── custom-elements/ │ │ │ │ ├── announcement-bar/ │ │ │ │ │ ├── announcement-bar.component.spec.ts │ │ │ │ │ ├── announcement-bar.component.ts │ │ │ │ │ └── announcement-bar.module.ts │ │ │ │ ├── api/ │ │ │ │ │ ├── api-list.component.spec.ts │ │ │ │ │ ├── api-list.component.ts │ │ │ │ │ ├── api-list.module.ts │ │ │ │ │ ├── api.service.spec.ts │ │ │ │ │ └── api.service.ts │ │ │ │ ├── code/ │ │ │ │ │ ├── code-example.component.spec.ts │ │ │ │ │ ├── code-example.component.ts │ │ │ │ │ ├── code-example.module.ts │ │ │ │ │ ├── code-tabs.component.spec.ts │ │ │ │ │ ├── code-tabs.component.ts │ │ │ │ │ ├── code-tabs.module.ts │ │ │ │ │ ├── code.component.spec.ts │ │ │ │ │ ├── code.component.ts │ │ │ │ │ ├── code.module.ts │ │ │ │ │ └── pretty-printer.service.ts │ │ │ │ ├── contributor/ │ │ │ │ │ ├── contributor-list.component.spec.ts │ │ │ │ │ ├── contributor-list.component.ts │ │ │ │ │ ├── contributor-list.module.ts │ │ │ │ │ ├── contributor.component.ts │ │ │ │ │ ├── contributor.service.spec.ts │ │ │ │ │ ├── contributor.service.ts │ │ │ │ │ └── contributors.model.ts │ │ │ │ ├── current-location/ │ │ │ │ │ ├── current-location.component.spec.ts │ │ │ │ │ ├── current-location.component.ts │ │ │ │ │ └── current-location.module.ts │ │ │ │ ├── custom-elements.module.ts │ │ │ │ ├── element-registry.ts │ │ │ │ ├── elements-loader.spec.ts │ │ │ │ ├── elements-loader.ts │ │ │ │ ├── expandable-section/ │ │ │ │ │ ├── expandable-section.component.ts │ │ │ │ │ └── expandable-section.module.ts │ │ │ │ ├── lazy-custom-element.component.spec.ts │ │ │ │ ├── lazy-custom-element.component.ts │ │ │ │ ├── live-example/ │ │ │ │ │ ├── live-example.component.spec.ts │ │ │ │ │ ├── live-example.component.ts │ │ │ │ │ └── live-example.module.ts │ │ │ │ ├── operator-decision-tree/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── fixtures.ts │ │ │ │ │ ├── interfaces.ts │ │ │ │ │ ├── operator-decision-tree-data.service.spec.ts │ │ │ │ │ ├── operator-decision-tree-data.service.ts │ │ │ │ │ ├── operator-decision-tree.component.scss │ │ │ │ │ ├── operator-decision-tree.component.spec.ts │ │ │ │ │ ├── operator-decision-tree.component.ts │ │ │ │ │ ├── operator-decision-tree.module.spec.ts │ │ │ │ │ ├── operator-decision-tree.module.ts │ │ │ │ │ ├── operator-decision-tree.service.spec.ts │ │ │ │ │ ├── operator-decision-tree.service.ts │ │ │ │ │ ├── utils.spec.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── resource/ │ │ │ │ │ ├── resource-list.component.spec.ts │ │ │ │ │ ├── resource-list.component.ts │ │ │ │ │ ├── resource-list.module.ts │ │ │ │ │ ├── resource.model.ts │ │ │ │ │ ├── resource.service.spec.ts │ │ │ │ │ └── resource.service.ts │ │ │ │ ├── search/ │ │ │ │ │ ├── file-not-found-search.component.spec.ts │ │ │ │ │ ├── file-not-found-search.component.ts │ │ │ │ │ └── file-not-found-search.module.ts │ │ │ │ └── toc/ │ │ │ │ ├── toc.component.ts │ │ │ │ └── toc.module.ts │ │ │ ├── documents/ │ │ │ │ ├── document-contents.ts │ │ │ │ ├── document.service.spec.ts │ │ │ │ └── document.service.ts │ │ │ ├── layout/ │ │ │ │ ├── doc-viewer/ │ │ │ │ │ ├── doc-viewer.component.spec.ts │ │ │ │ │ ├── doc-viewer.component.ts │ │ │ │ │ └── dt.component.ts │ │ │ │ ├── footer/ │ │ │ │ │ └── footer.component.ts │ │ │ │ ├── mode-banner/ │ │ │ │ │ └── mode-banner.component.ts │ │ │ │ ├── nav-item/ │ │ │ │ │ ├── nav-item.component.spec.ts │ │ │ │ │ └── nav-item.component.ts │ │ │ │ ├── nav-menu/ │ │ │ │ │ ├── nav-menu.component.spec.ts │ │ │ │ │ └── nav-menu.component.ts │ │ │ │ ├── notification/ │ │ │ │ │ └── notification.component.ts │ │ │ │ └── top-menu/ │ │ │ │ ├── top-menu.component.spec.ts │ │ │ │ └── top-menu.component.ts │ │ │ ├── navigation/ │ │ │ │ ├── navigation.model.ts │ │ │ │ ├── navigation.service.spec.ts │ │ │ │ └── navigation.service.ts │ │ │ ├── search/ │ │ │ │ ├── interfaces.ts │ │ │ │ ├── search-box/ │ │ │ │ │ ├── search-box.component.spec.ts │ │ │ │ │ └── search-box.component.ts │ │ │ │ ├── search.service.ts │ │ │ │ └── search.worker.ts │ │ │ ├── shared/ │ │ │ │ ├── attribute-utils.spec.ts │ │ │ │ ├── attribute-utils.ts │ │ │ │ ├── copier.service.ts │ │ │ │ ├── current-date.ts │ │ │ │ ├── custom-icon-registry.spec.ts │ │ │ │ ├── custom-icon-registry.ts │ │ │ │ ├── deployment.service.spec.ts │ │ │ │ ├── deployment.service.ts │ │ │ │ ├── ga.service.spec.ts │ │ │ │ ├── ga.service.ts │ │ │ │ ├── location.service.spec.ts │ │ │ │ ├── location.service.ts │ │ │ │ ├── logger.service.spec.ts │ │ │ │ ├── logger.service.ts │ │ │ │ ├── reporting-error-handler.spec.ts │ │ │ │ ├── reporting-error-handler.ts │ │ │ │ ├── scroll-spy.service.spec.ts │ │ │ │ ├── scroll-spy.service.ts │ │ │ │ ├── scroll.service.spec.ts │ │ │ │ ├── scroll.service.ts │ │ │ │ ├── search-results/ │ │ │ │ │ ├── search-results.component.spec.ts │ │ │ │ │ └── search-results.component.ts │ │ │ │ ├── select/ │ │ │ │ │ ├── select.component.spec.ts │ │ │ │ │ └── select.component.ts │ │ │ │ ├── shared.module.ts │ │ │ │ ├── stackblitz.service.ts │ │ │ │ ├── toc.service.spec.ts │ │ │ │ ├── toc.service.ts │ │ │ │ ├── web-worker-message.ts │ │ │ │ ├── web-worker.ts │ │ │ │ └── window.ts │ │ │ └── sw-updates/ │ │ │ ├── sw-updates.module.ts │ │ │ ├── sw-updates.service.spec.ts │ │ │ └── sw-updates.service.ts │ │ ├── assets/ │ │ │ ├── .gitkeep │ │ │ └── js/ │ │ │ ├── devtools-welcome.js │ │ │ └── prettify.js │ │ ├── environments/ │ │ │ ├── environment.archive.ts │ │ │ ├── environment.next.ts │ │ │ ├── environment.stable.ts │ │ │ └── environment.ts │ │ ├── extra-files/ │ │ │ ├── README.md │ │ │ ├── archive/ │ │ │ │ └── robots.txt │ │ │ ├── next/ │ │ │ │ └── robots.txt │ │ │ └── stable/ │ │ │ └── robots.txt │ │ ├── google385281288605d160.html │ │ ├── index.html │ │ ├── karma.conf.js │ │ ├── main.ts │ │ ├── noop-worker-basic.js │ │ ├── polyfills.ts │ │ ├── pwa-manifest.json │ │ ├── styles/ │ │ │ ├── 0-base/ │ │ │ │ ├── _base-dir.scss │ │ │ │ └── _typography.scss │ │ │ ├── 1-layouts/ │ │ │ │ ├── _api-page.scss │ │ │ │ ├── _content-layout.scss │ │ │ │ ├── _doc-viewer.scss │ │ │ │ ├── _footer.scss │ │ │ │ ├── _layout-global.scss │ │ │ │ ├── _layouts-dir.scss │ │ │ │ ├── _marketing-layout.scss │ │ │ │ ├── _not-found.scss │ │ │ │ ├── _print-layout.scss │ │ │ │ ├── _sidenav.scss │ │ │ │ ├── _table-of-contents.scss │ │ │ │ └── _top-menu.scss │ │ │ ├── 2-modules/ │ │ │ │ ├── _alert.scss │ │ │ │ ├── _api-list.scss │ │ │ │ ├── _api-pages.scss │ │ │ │ ├── _buttons.scss │ │ │ │ ├── _callout.scss │ │ │ │ ├── _card.scss │ │ │ │ ├── _code.scss │ │ │ │ ├── _contribute.scss │ │ │ │ ├── _contributor.scss │ │ │ │ ├── _deploy-theme.scss │ │ │ │ ├── _details.scss │ │ │ │ ├── _edit-page-cta.scss │ │ │ │ ├── _features.scss │ │ │ │ ├── _filetree.scss │ │ │ │ ├── _heading-anchors.scss │ │ │ │ ├── _hr.scss │ │ │ │ ├── _images.scss │ │ │ │ ├── _label.scss │ │ │ │ ├── _modules-dir.scss │ │ │ │ ├── _notification.scss │ │ │ │ ├── _presskit.scss │ │ │ │ ├── _progress-bar.scss │ │ │ │ ├── _resources.scss │ │ │ │ ├── _scrollbar.scss │ │ │ │ ├── _search-results.scss │ │ │ │ ├── _select-menu.scss │ │ │ │ ├── _subsection.scss │ │ │ │ ├── _table.scss │ │ │ │ └── _toc.scss │ │ │ ├── _constants.scss │ │ │ ├── _mixins.scss │ │ │ ├── _typography-theme.scss │ │ │ ├── main.scss │ │ │ └── rxjs-theme.scss │ │ ├── styles.scss │ │ ├── test.ts │ │ ├── testing/ │ │ │ ├── doc-viewer-utils.ts │ │ │ ├── location.service.ts │ │ │ ├── logger.service.ts │ │ │ └── search.service.ts │ │ └── typings.d.ts │ ├── tests/ │ │ └── e2e/ │ │ ├── protractor.conf.js │ │ ├── tsconfig.e2e.json │ │ └── visual-testing.e2e-spec.ts │ ├── tools/ │ │ ├── README.md │ │ ├── firebase-test-utils/ │ │ │ ├── FirebaseGlob.spec.ts │ │ │ ├── FirebaseGlob.ts │ │ │ ├── FirebaseRedirect.spec.ts │ │ │ ├── FirebaseRedirect.ts │ │ │ ├── FirebaseRedirector.spec.ts │ │ │ └── FirebaseRedirector.ts │ │ ├── marbles/ │ │ │ ├── diagrams/ │ │ │ │ ├── audit.txt │ │ │ │ ├── bufferWhen.txt │ │ │ │ ├── concatAll.txt │ │ │ │ ├── debounce.txt │ │ │ │ ├── delay.txt │ │ │ │ ├── exhaustAll.txt │ │ │ │ ├── throttle.txt │ │ │ │ └── windowWhen.txt │ │ │ ├── scripts/ │ │ │ │ └── index.ts │ │ │ └── tsconfig.marbles.json │ │ ├── stackblitz/ │ │ │ └── rxjs.version.js │ │ └── transforms/ │ │ ├── .eslintignore │ │ ├── .eslintrc.js │ │ ├── README.md │ │ ├── angular-api-package/ │ │ │ ├── index.js │ │ │ ├── mocks/ │ │ │ │ ├── aliasedExports.ts │ │ │ │ ├── anotherOperator.ts │ │ │ │ ├── importedSrc.ts │ │ │ │ ├── operator.ts │ │ │ │ └── testSrc.ts │ │ │ ├── post-processors/ │ │ │ │ └── embedMarbleDiagrams.js │ │ │ ├── processors/ │ │ │ │ ├── addMetadataAliases.js │ │ │ │ ├── addMetadataAliases.spec.js │ │ │ │ ├── checkOperator.js │ │ │ │ ├── computeApiBreadCrumbs.js │ │ │ │ ├── computeApiBreadCrumbs.spec.js │ │ │ │ ├── computeSearchTitle.js │ │ │ │ ├── computeSearchTitle.spec.js │ │ │ │ ├── computeStability.js │ │ │ │ ├── computeStability.spec.js │ │ │ │ ├── convertPrivateClassesToInterfaces.js │ │ │ │ ├── extractDecoratedClasses.js │ │ │ │ ├── extractDecoratedClasses.spec.js │ │ │ │ ├── filterContainedDocs.js │ │ │ │ ├── filterPrivateDocs.js │ │ │ │ ├── filterPrivateDocs.spec.js │ │ │ │ ├── generateApiListDoc.js │ │ │ │ ├── generateApiListDoc.spec.js │ │ │ │ ├── generateDeprecationsListDoc.js │ │ │ │ ├── markAliases.spec.ts │ │ │ │ ├── markAliases.ts │ │ │ │ ├── markBarredODocsAsPrivate.js │ │ │ │ ├── markBarredODocsAsPrivate.spec.js │ │ │ │ ├── matchUpDirectiveDecorators.js │ │ │ │ ├── matchUpDirectiveDecorators.spec.js │ │ │ │ ├── mergeDecoratorDocs.js │ │ │ │ ├── mergeDecoratorDocs.spec.js │ │ │ │ ├── migrateLegacyJSDocTags.js │ │ │ │ ├── migrateLegacyJSDocTags.spec.js │ │ │ │ ├── processClassLikeMembers.js │ │ │ │ ├── processClassLikeMembers.spec.js │ │ │ │ ├── simplifyMemberAnchors.js │ │ │ │ ├── simplifyMemberAnchors.spec.js │ │ │ │ ├── splitDescription.js │ │ │ │ └── splitDescription.spec.js │ │ │ └── tag-defs/ │ │ │ ├── deprecated.js │ │ │ ├── internal.js │ │ │ └── throws.js │ │ ├── angular-base-package/ │ │ │ ├── ignore-words.json │ │ │ ├── index.js │ │ │ ├── post-processors/ │ │ │ │ ├── add-image-dimensions.js │ │ │ │ ├── add-image-dimensions.spec.js │ │ │ │ ├── auto-link-code.js │ │ │ │ ├── auto-link-code.spec.js │ │ │ │ ├── autolink-headings.js │ │ │ │ ├── autolink-headings.spec.js │ │ │ │ ├── h1-checker.js │ │ │ │ └── h1-checker.spec.js │ │ │ ├── processors/ │ │ │ │ ├── checkUnbalancedBackTicks.js │ │ │ │ ├── checkUnbalancedBackTicks.spec.js │ │ │ │ ├── convertToJson.js │ │ │ │ ├── convertToJson.spec.js │ │ │ │ ├── copyContentAssets.js │ │ │ │ ├── copyContentAssets.spec.js │ │ │ │ ├── createSitemap.js │ │ │ │ ├── createSitemap.spec.js │ │ │ │ ├── fixInternalDocumentLinks.js │ │ │ │ ├── fixInternalDocumentLinks.spec.js │ │ │ │ ├── generateKeywords.js │ │ │ │ ├── generateKeywords.spec.js │ │ │ │ ├── renderLinkInfo.js │ │ │ │ └── renderLinkInfo.spec.js │ │ │ ├── readers/ │ │ │ │ └── json.js │ │ │ ├── rendering/ │ │ │ │ ├── filterByPropertyValue.js │ │ │ │ ├── filterByPropertyValue.spec.js │ │ │ │ ├── toId.js │ │ │ │ ├── toId.spec.js │ │ │ │ ├── trimBlankLines.js │ │ │ │ ├── trimBlankLines.spec.js │ │ │ │ ├── truncateCode.js │ │ │ │ └── truncateCode.spec.js │ │ │ └── services/ │ │ │ ├── copyFolder.js │ │ │ ├── filterAmbiguousDirectiveAliases.js │ │ │ ├── filterAmbiguousDirectiveAliases.spec.js │ │ │ ├── filterFromInImports.spec.js │ │ │ ├── filterFromInImports.ts │ │ │ ├── filterPipes.js │ │ │ ├── filterPipes.spec.js │ │ │ └── getImageDimensions.js │ │ ├── angular-content-package/ │ │ │ ├── index.js │ │ │ └── inline-tag-defs/ │ │ │ └── anchor.js │ │ ├── angular.io-package/ │ │ │ ├── index.js │ │ │ └── processors/ │ │ │ ├── cleanGeneratedFiles.js │ │ │ ├── createOverviewDump.js │ │ │ └── processNavigationMap.js │ │ ├── authors-package/ │ │ │ ├── api-package.js │ │ │ ├── guide-package.js │ │ │ ├── index.js │ │ │ ├── index.spec.js │ │ │ ├── marketing-package.js │ │ │ ├── tutorial-package.js │ │ │ └── watchr.js │ │ ├── config.js │ │ ├── content-package/ │ │ │ ├── index.js │ │ │ ├── readers/ │ │ │ │ ├── content.js │ │ │ │ └── content.spec.js │ │ │ └── tag-defs/ │ │ │ ├── intro.js │ │ │ └── title.js │ │ ├── helpers/ │ │ │ ├── test-package.js │ │ │ ├── utils.js │ │ │ └── utils.spec.js │ │ ├── links-package/ │ │ │ ├── index.js │ │ │ ├── inline-tag-defs/ │ │ │ │ ├── link.js │ │ │ │ └── link.spec.js │ │ │ └── services/ │ │ │ ├── disambiguators/ │ │ │ │ ├── disambiguateByDeprecated.js │ │ │ │ ├── disambiguateByDeprecated.spec.js │ │ │ │ ├── disambiguateByModule.js │ │ │ │ ├── disambiguateByModule.spec.js │ │ │ │ ├── disambiguateByNonMember.js │ │ │ │ ├── disambiguateByNonMember.spec.js │ │ │ │ ├── disambiguateByNonOperator.js │ │ │ │ └── disambiguateByNonOperator.spec.js │ │ │ ├── getAliases.js │ │ │ ├── getAliases.spec.js │ │ │ ├── getDocFromAlias.js │ │ │ ├── getDocFromAlias.spec.js │ │ │ ├── getLinkInfo.js │ │ │ └── getLinkInfo.spec.js │ │ ├── remark-package/ │ │ │ ├── index.js │ │ │ └── services/ │ │ │ ├── handlers/ │ │ │ │ └── code.js │ │ │ ├── markedNunjucksFilter.js │ │ │ ├── plugins/ │ │ │ │ └── mapHeadings.js │ │ │ ├── renderMarkdown.js │ │ │ └── renderMarkdown.spec.js │ │ ├── rxjs-decision-tree-generator/ │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ └── lib/ │ │ │ ├── addUniqueId.spec.ts │ │ │ ├── addUniqueId.ts │ │ │ ├── build.spec.ts │ │ │ ├── build.ts │ │ │ ├── decisionTreeReducer.spec.ts │ │ │ ├── decisionTreeReducer.ts │ │ │ ├── extractInitialSequence.spec.ts │ │ │ ├── extractInitialSequence.ts │ │ │ ├── fixtures.ts │ │ │ ├── flattenApiList.spec.ts │ │ │ ├── flattenApiList.ts │ │ │ ├── generateUniqueId.spec.ts │ │ │ ├── generateUniqueId.ts │ │ │ ├── helpers.spec.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ └── interfaces.ts │ │ ├── templates/ │ │ │ ├── README.md │ │ │ ├── api/ │ │ │ │ ├── base.template.html │ │ │ │ ├── class.template.html │ │ │ │ ├── const.template.html │ │ │ │ ├── decorator.template.html │ │ │ │ ├── deprecation.template.html │ │ │ │ ├── directive.template.html │ │ │ │ ├── enum.template.html │ │ │ │ ├── export-base.template.html │ │ │ │ ├── function.template.html │ │ │ │ ├── includes/ │ │ │ │ │ ├── annotations.html │ │ │ │ │ ├── class-overview.html │ │ │ │ │ ├── decorator-overview.html │ │ │ │ │ ├── deprecation.html │ │ │ │ │ ├── description.html │ │ │ │ │ ├── directive-overview.html │ │ │ │ │ ├── export-as.html │ │ │ │ │ ├── info-bar.html │ │ │ │ │ ├── interface-overview.html │ │ │ │ │ ├── metadata.html │ │ │ │ │ ├── pipe-overview.html │ │ │ │ │ ├── renamed-exports.html │ │ │ │ │ ├── security-notes.html │ │ │ │ │ ├── see-also.html │ │ │ │ │ ├── selectors.html │ │ │ │ │ └── usageNotes.html │ │ │ │ ├── interface.template.html │ │ │ │ ├── let.template.html │ │ │ │ ├── lib/ │ │ │ │ │ ├── descendants.html │ │ │ │ │ ├── directiveHelpers.html │ │ │ │ │ ├── githubLinks.html │ │ │ │ │ ├── memberHelpers.html │ │ │ │ │ └── paramList.html │ │ │ │ ├── module.template.html │ │ │ │ ├── pipe.template.html │ │ │ │ ├── type-alias.template.html │ │ │ │ ├── value-module.template.html │ │ │ │ └── var.template.html │ │ │ ├── content.template.html │ │ │ ├── data-module.template.js │ │ │ ├── example-region.template.html │ │ │ ├── json-doc.template.json │ │ │ ├── overview-dump.template.html │ │ │ └── sitemap.template.xml │ │ └── test.js │ ├── tsconfig.app.json │ ├── tsconfig.docs.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tsconfig.worker.json ├── nx.json ├── package.json ├── packages/ │ ├── observable/ │ │ ├── .eslintrc.json │ │ ├── .tshy/ │ │ │ ├── browser.json │ │ │ ├── build.json │ │ │ ├── commonjs.json │ │ │ ├── esm.json │ │ │ └── webpack.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── observable.spec.ts │ │ │ ├── observable.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ └── rxjs/ │ ├── .dependency-cruiser.json │ ├── .dockerignore │ ├── .eslintrc.json │ ├── .gitattributes │ ├── CHANGELOG.md │ ├── Dockerfile │ ├── README.md │ ├── integration/ │ │ └── import/ │ │ ├── fixtures/ │ │ │ ├── browser/ │ │ │ │ ├── browser-test.js │ │ │ │ ├── index.html │ │ │ │ ├── index.mjs │ │ │ │ └── package.json │ │ │ ├── commonjs/ │ │ │ │ ├── index.js │ │ │ │ └── package.json │ │ │ ├── esm/ │ │ │ │ ├── index.mjs │ │ │ │ └── package.json │ │ │ ├── vite-bundle/ │ │ │ │ ├── .gitignore │ │ │ │ ├── index.html │ │ │ │ ├── package.json │ │ │ │ ├── src/ │ │ │ │ │ ├── main.ts │ │ │ │ │ └── vite-env.d.ts │ │ │ │ ├── test.mjs │ │ │ │ └── tsconfig.json │ │ │ └── webpack-bundle/ │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ └── main.ts │ │ │ ├── test.js │ │ │ ├── tsconfig.json │ │ │ └── webpack.config.js │ │ └── runner.js │ ├── package.json │ ├── spec/ │ │ ├── Observable-spec.ts │ │ ├── Scheduler-spec.ts │ │ ├── Subject-spec.ts │ │ ├── Subscriber-spec.ts │ │ ├── Subscription-spec.ts │ │ ├── ajax/ │ │ │ └── index-spec.ts │ │ ├── config-spec.ts │ │ ├── exports-spec.ts.disabled │ │ ├── firstValueFrom-spec.ts │ │ ├── helpers/ │ │ │ ├── interop-helper-spec.ts │ │ │ ├── interop-helper.ts │ │ │ ├── marble-testing.ts │ │ │ ├── observableMatcher.ts │ │ │ ├── setup.ts │ │ │ ├── subscription.ts │ │ │ └── test-helper.ts │ │ ├── index-spec.ts │ │ ├── lastValueFrom-spec.ts │ │ ├── module-test-spec.mjs │ │ ├── observables/ │ │ │ ├── bindCallback-spec.ts │ │ │ ├── bindNodeCallback-spec.ts │ │ │ ├── combineLatest-spec.ts │ │ │ ├── concat-spec.ts │ │ │ ├── connectable-spec.ts │ │ │ ├── defer-spec.ts │ │ │ ├── dom/ │ │ │ │ ├── ajax-spec.ts │ │ │ │ ├── animationFrames-spec.ts │ │ │ │ ├── fetch-spec.ts │ │ │ │ └── webSocket-spec.ts │ │ │ ├── empty-spec.ts │ │ │ ├── forkJoin-spec.ts │ │ │ ├── from-promise-spec.ts │ │ │ ├── from-spec.ts │ │ │ ├── fromEvent-spec.ts │ │ │ ├── fromEventPattern-spec.ts │ │ │ ├── generate-spec.ts │ │ │ ├── if-spec.ts │ │ │ ├── interval-spec.ts │ │ │ ├── merge-spec.ts │ │ │ ├── never-spec.ts │ │ │ ├── of-spec.ts │ │ │ ├── onErrorResumeNext-spec.ts │ │ │ ├── partition-spec.ts │ │ │ ├── race-spec.ts │ │ │ ├── range-spec.ts │ │ │ ├── throwError-spec.ts │ │ │ ├── timer-spec.ts │ │ │ ├── using-spec.ts │ │ │ └── zip-spec.ts │ │ ├── operators/ │ │ │ ├── audit-spec.ts │ │ │ ├── auditTime-spec.ts │ │ │ ├── buffer-spec.ts │ │ │ ├── bufferCount-spec.ts │ │ │ ├── bufferTime-spec.ts │ │ │ ├── bufferToggle-spec.ts │ │ │ ├── bufferWhen-spec.ts │ │ │ ├── catchError-spec.ts │ │ │ ├── combineLatestAll-spec.ts │ │ │ ├── combineLatestWith-spec.ts │ │ │ ├── concatAll-spec.ts │ │ │ ├── concatMap-spec.ts │ │ │ ├── concatMapTo-spec.ts │ │ │ ├── concatWith-spec.ts │ │ │ ├── connect-spec.ts │ │ │ ├── count-spec.ts │ │ │ ├── debounce-spec.ts │ │ │ ├── debounceTime-spec.ts │ │ │ ├── defaultIfEmpty-spec.ts │ │ │ ├── delay-spec.ts │ │ │ ├── delayWhen-spec.ts │ │ │ ├── dematerialize-spec.ts │ │ │ ├── distinct-spec.ts │ │ │ ├── distinctUntilChanged-spec.ts │ │ │ ├── distinctUntilKeyChanged-spec.ts │ │ │ ├── elementAt-spec.ts │ │ │ ├── endWith-spec.ts │ │ │ ├── every-spec.ts │ │ │ ├── exhaustAll-spec.ts │ │ │ ├── exhaustMap-spec.ts │ │ │ ├── expand-spec.ts │ │ │ ├── filter-spec.ts │ │ │ ├── finalize-spec.ts │ │ │ ├── find-spec.ts │ │ │ ├── findIndex-spec.ts │ │ │ ├── first-spec.ts │ │ │ ├── groupBy-spec.ts │ │ │ ├── ignoreElements-spec.ts │ │ │ ├── index-spec.ts │ │ │ ├── isEmpty-spec.ts │ │ │ ├── last-spec.ts │ │ │ ├── map-spec.ts │ │ │ ├── mapTo-spec.ts │ │ │ ├── materialize-spec.ts │ │ │ ├── max-spec.ts │ │ │ ├── mergeAll-spec.ts │ │ │ ├── mergeMap-spec.ts │ │ │ ├── mergeMapTo-spec.ts │ │ │ ├── mergeScan-spec.ts │ │ │ ├── mergeWith-spec.ts │ │ │ ├── min-spec.ts │ │ │ ├── observeOn-spec.ts │ │ │ ├── onErrorResumeNext-spec.ts │ │ │ ├── pairwise-spec.ts │ │ │ ├── raceWith-spec.ts │ │ │ ├── reduce-spec.ts │ │ │ ├── repeat-spec.ts │ │ │ ├── repeatWhen-spec.ts │ │ │ ├── retry-spec.ts │ │ │ ├── retryWhen-spec.ts │ │ │ ├── sample-spec.ts │ │ │ ├── sampleTime-spec.ts │ │ │ ├── scan-spec.ts │ │ │ ├── sequenceEqual-spec.ts │ │ │ ├── share-spec.ts │ │ │ ├── shareReplay-spec.ts │ │ │ ├── single-spec.ts │ │ │ ├── skip-spec.ts │ │ │ ├── skipLast-spec.ts │ │ │ ├── skipUntil-spec.ts │ │ │ ├── skipWhile-spec.ts │ │ │ ├── startWith-spec.ts │ │ │ ├── subscribeOn-spec.ts │ │ │ ├── switchAll-spec.ts │ │ │ ├── switchMap-spec.ts │ │ │ ├── switchMapTo-spec.ts │ │ │ ├── switchScan-spec.ts │ │ │ ├── take-spec.ts │ │ │ ├── takeLast-spec.ts │ │ │ ├── takeUntil-spec.ts │ │ │ ├── takeWhile-spec.ts │ │ │ ├── tap-spec.ts │ │ │ ├── throttle-spec.ts │ │ │ ├── throttleTime-spec.ts │ │ │ ├── throwIfEmpty-spec.ts │ │ │ ├── timeInterval-spec.ts │ │ │ ├── timeout-spec.ts │ │ │ ├── timeoutWith-spec.ts │ │ │ ├── timestamp-spec.ts │ │ │ ├── toArray-spec.ts │ │ │ ├── window-spec.ts │ │ │ ├── windowCount-spec.ts │ │ │ ├── windowTime-spec.ts │ │ │ ├── windowToggle-spec.ts │ │ │ ├── windowWhen-spec.ts │ │ │ ├── withLatestFrom-spec.ts │ │ │ ├── zipAll-spec.ts │ │ │ └── zipWith-spec.ts │ │ ├── scheduled/ │ │ │ └── scheduled-spec.ts │ │ ├── schedulers/ │ │ │ ├── AnimationFrameScheduler-spec.ts │ │ │ ├── AsapScheduler-spec.ts │ │ │ ├── QueueScheduler-spec.ts │ │ │ ├── TestScheduler-spec.ts │ │ │ ├── VirtualTimeScheduler-spec.ts │ │ │ ├── animationFrameProvider-spec.ts │ │ │ ├── dateTimestampProvider-spec.ts │ │ │ ├── intervalProvider-spec.ts │ │ │ └── timeoutProvider-spec.ts │ │ ├── subjects/ │ │ │ ├── AsyncSubject-spec.ts │ │ │ ├── BehaviorSubject-spec.ts │ │ │ └── ReplaySubject-spec.ts │ │ ├── support/ │ │ │ ├── .mocharc.js │ │ │ ├── mocha-browser-runner.html │ │ │ ├── mocha.sauce.gruntfile.js │ │ │ └── mocha.sauce.runner.js │ │ ├── testing/ │ │ │ └── index-spec.ts │ │ ├── tsconfig.json │ │ ├── util/ │ │ │ ├── ArgumentOutOfRangeError-spec.ts │ │ │ ├── EmptyError-spec.ts │ │ │ ├── Immediate-spec.ts │ │ │ ├── TimeoutError-spec.ts │ │ │ ├── UnsubscriptionError-spec.ts │ │ │ ├── isObservable-spec.ts │ │ │ ├── isPromise-spec.ts │ │ │ ├── pipe-spec.ts │ │ │ └── rx-spec.ts │ │ └── websocket/ │ │ └── index-spec.ts │ ├── spec-dtslint/ │ │ ├── AsyncSubject-spec.ts │ │ ├── BehaviorSubject-spec.ts │ │ ├── Observable-spec.ts │ │ ├── ReplaySubject-spec.ts │ │ ├── Subject-spec.ts │ │ ├── Subscriber-spec.ts │ │ ├── errors-spec.ts │ │ ├── firstValueFrom-spec.ts │ │ ├── helpers.ts │ │ ├── index.d.ts │ │ ├── lastValueFrom-spec.ts │ │ ├── observables/ │ │ │ ├── bindCallback-spec.ts │ │ │ ├── combineLatest-spec.ts │ │ │ ├── concat-spec.ts │ │ │ ├── defer-spec.ts │ │ │ ├── dom/ │ │ │ │ ├── ajax-spec.ts │ │ │ │ ├── animationFrames-spec.ts │ │ │ │ └── fetch-spec.ts │ │ │ ├── empty-spec.ts │ │ │ ├── forkJoin-spec.ts │ │ │ ├── from-spec.ts │ │ │ ├── fromEvent-spec.ts │ │ │ ├── iif-spec.ts │ │ │ ├── interval-spec.ts │ │ │ ├── never-spec.ts │ │ │ ├── of-spec.ts │ │ │ ├── onErrorResumeNext-spec.ts │ │ │ ├── partition-spec.ts │ │ │ ├── race-spec.ts │ │ │ ├── range-spec.ts │ │ │ ├── throwError-spec.ts │ │ │ ├── timer-spec.ts │ │ │ ├── using-spec.ts │ │ │ └── zip-spec.ts │ │ ├── operators/ │ │ │ ├── audit-spec.ts │ │ │ ├── auditTime-spec.ts │ │ │ ├── buffer-spec.ts │ │ │ ├── bufferCount-spec.ts │ │ │ ├── bufferTime-spec.ts │ │ │ ├── bufferToggle-spec.ts │ │ │ ├── bufferWhen-spec.ts │ │ │ ├── catchError-spec.ts │ │ │ ├── combineLatestAll-spec.ts │ │ │ ├── combineLatestWith-spec.ts │ │ │ ├── concatAll-spec.ts │ │ │ ├── concatMap-spec.ts │ │ │ ├── concatMapTo-spec.ts │ │ │ ├── concatWith-spec.ts │ │ │ ├── connect-spec.ts │ │ │ ├── count-spec.ts │ │ │ ├── debounce-spec.ts │ │ │ ├── debounceTime-spec.ts │ │ │ ├── defaultIfEmpty-spec.ts │ │ │ ├── delay-spec.ts │ │ │ ├── delayWhen-spec.ts │ │ │ ├── dematerialize-spec.ts │ │ │ ├── distinct-spec.ts │ │ │ ├── distinctUntilChanged-spec.ts │ │ │ ├── distinctUntilKeyChanged-spec.ts │ │ │ ├── elementAt-spec.ts │ │ │ ├── endWith-spec.ts │ │ │ ├── every-spec.ts │ │ │ ├── exhaustAll-spec.ts │ │ │ ├── exhaustMap-spec.ts │ │ │ ├── expand-spec.ts │ │ │ ├── filter-spec.ts │ │ │ ├── finalize-spec.ts │ │ │ ├── find-spec.ts │ │ │ ├── findIndex-spec.ts │ │ │ ├── first-spec.ts │ │ │ ├── groupBy-spec.ts │ │ │ ├── ignoreElements-spec.ts │ │ │ ├── isEmpty-spec.ts │ │ │ ├── last-spec.ts │ │ │ ├── map-spec.ts │ │ │ ├── mapTo-spec.ts │ │ │ ├── materialize-spec.ts │ │ │ ├── max-spec.ts │ │ │ ├── mergeAll-spec.ts │ │ │ ├── mergeMap-spec.ts │ │ │ ├── mergeMapTo-spec.ts │ │ │ ├── mergeScan-spec.ts │ │ │ ├── mergeWith-spec.ts │ │ │ ├── min-spec.ts │ │ │ ├── observeOn-spec.ts │ │ │ ├── onErrorResumeNextWith-spec.ts │ │ │ ├── pairwise-spec.ts │ │ │ ├── raceWith-spec.ts │ │ │ ├── reduce-spec.ts │ │ │ ├── repeat-spec.ts │ │ │ ├── repeatWhen-spec.ts │ │ │ ├── retry-spec.ts │ │ │ ├── retryWhen-spec.ts │ │ │ ├── sample-spec.ts │ │ │ ├── sampleTime-spec.ts │ │ │ ├── scan-spec.ts │ │ │ ├── sequenceEqual-spec.ts │ │ │ ├── share-spec.ts │ │ │ ├── shareReplay-spec.ts │ │ │ ├── single-spec.ts │ │ │ ├── skip-spec.ts │ │ │ ├── skipLast-spec.ts │ │ │ ├── skipUntil-spec.ts │ │ │ ├── skipWhile-spec.ts │ │ │ ├── startWith-spec.ts │ │ │ ├── subscribeOn-spec.ts │ │ │ ├── switchAll-spec.ts │ │ │ ├── switchMap-spec.ts │ │ │ ├── switchMapTo-spec.ts │ │ │ ├── switchScan-spec.ts │ │ │ ├── take-spec.ts │ │ │ ├── takeLast-spec.ts │ │ │ ├── takeUntil-spec.ts │ │ │ ├── takeWhile-spec.ts │ │ │ ├── tap-spec.ts │ │ │ ├── throttle-spec.ts │ │ │ ├── throttleTime-spec.ts │ │ │ ├── throwIfEmpty-spec.ts │ │ │ ├── timeInterval-spec.ts │ │ │ ├── timeout-spec.ts │ │ │ ├── timeoutWith-spec.ts │ │ │ ├── timestamp-spec.ts │ │ │ ├── toArray-spec.ts │ │ │ ├── window-spec.ts │ │ │ ├── windowCount-spec.ts │ │ │ ├── windowTime-spec.ts │ │ │ ├── windowToggle-spec.ts │ │ │ ├── windowWhen-spec.ts │ │ │ ├── withLatestFrom-spec.ts │ │ │ ├── zipAll-spec.ts │ │ │ └── zipWith-spec.ts │ │ ├── tsconfig.json │ │ ├── types-spec.ts │ │ └── util/ │ │ ├── pipe-spec.ts │ │ └── rx-spec.ts │ ├── src/ │ │ ├── Rx.global.js │ │ ├── ajax/ │ │ │ └── index.ts │ │ ├── fetch/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── internal/ │ │ │ ├── AnyCatcher.ts │ │ │ ├── AsyncSubject.ts │ │ │ ├── BehaviorSubject.ts │ │ │ ├── Notification.ts │ │ │ ├── Operator.ts │ │ │ ├── ReplaySubject.ts │ │ │ ├── Scheduler.ts │ │ │ ├── Subject.ts │ │ │ ├── ajax/ │ │ │ │ ├── AjaxResponse.ts │ │ │ │ ├── ajax.ts │ │ │ │ ├── errors.ts │ │ │ │ └── types.ts │ │ │ ├── firstValueFrom.ts │ │ │ ├── lastValueFrom.ts │ │ │ ├── observable/ │ │ │ │ ├── bindCallback.ts │ │ │ │ ├── bindCallbackInternals.ts │ │ │ │ ├── bindNodeCallback.ts │ │ │ │ ├── combineLatest.ts │ │ │ │ ├── concat.ts │ │ │ │ ├── connectable.ts │ │ │ │ ├── defer.ts │ │ │ │ ├── dom/ │ │ │ │ │ ├── WebSocketSubject.ts │ │ │ │ │ ├── animationFrames.ts │ │ │ │ │ ├── fetch.ts │ │ │ │ │ └── webSocket.ts │ │ │ │ ├── empty.ts │ │ │ │ ├── forkJoin.ts │ │ │ │ ├── fromEvent.ts │ │ │ │ ├── fromEventPattern.ts │ │ │ │ ├── fromSubscribable.ts │ │ │ │ ├── generate.ts │ │ │ │ ├── iif.ts │ │ │ │ ├── interval.ts │ │ │ │ ├── merge.ts │ │ │ │ ├── never.ts │ │ │ │ ├── of.ts │ │ │ │ ├── onErrorResumeNext.ts │ │ │ │ ├── partition.ts │ │ │ │ ├── race.ts │ │ │ │ ├── range.ts │ │ │ │ ├── throwError.ts │ │ │ │ ├── timer.ts │ │ │ │ ├── using.ts │ │ │ │ └── zip.ts │ │ │ ├── operators/ │ │ │ │ ├── audit.ts │ │ │ │ ├── auditTime.ts │ │ │ │ ├── buffer.ts │ │ │ │ ├── bufferCount.ts │ │ │ │ ├── bufferTime.ts │ │ │ │ ├── bufferToggle.ts │ │ │ │ ├── bufferWhen.ts │ │ │ │ ├── catchError.ts │ │ │ │ ├── combineLatestAll.ts │ │ │ │ ├── combineLatestWith.ts │ │ │ │ ├── concatAll.ts │ │ │ │ ├── concatMap.ts │ │ │ │ ├── concatMapTo.ts │ │ │ │ ├── concatWith.ts │ │ │ │ ├── connect.ts │ │ │ │ ├── count.ts │ │ │ │ ├── debounce.ts │ │ │ │ ├── debounceTime.ts │ │ │ │ ├── defaultIfEmpty.ts │ │ │ │ ├── delay.ts │ │ │ │ ├── delayWhen.ts │ │ │ │ ├── dematerialize.ts │ │ │ │ ├── distinct.ts │ │ │ │ ├── distinctUntilChanged.ts │ │ │ │ ├── distinctUntilKeyChanged.ts │ │ │ │ ├── elementAt.ts │ │ │ │ ├── endWith.ts │ │ │ │ ├── every.ts │ │ │ │ ├── exhaustAll.ts │ │ │ │ ├── exhaustMap.ts │ │ │ │ ├── expand.ts │ │ │ │ ├── filter.ts │ │ │ │ ├── finalize.ts │ │ │ │ ├── find.ts │ │ │ │ ├── findIndex.ts │ │ │ │ ├── first.ts │ │ │ │ ├── groupBy.ts │ │ │ │ ├── ignoreElements.ts │ │ │ │ ├── isEmpty.ts │ │ │ │ ├── joinAllInternals.ts │ │ │ │ ├── last.ts │ │ │ │ ├── map.ts │ │ │ │ ├── mapTo.ts │ │ │ │ ├── materialize.ts │ │ │ │ ├── max.ts │ │ │ │ ├── mergeAll.ts │ │ │ │ ├── mergeInternals.ts │ │ │ │ ├── mergeMap.ts │ │ │ │ ├── mergeMapTo.ts │ │ │ │ ├── mergeScan.ts │ │ │ │ ├── mergeWith.ts │ │ │ │ ├── min.ts │ │ │ │ ├── observeOn.ts │ │ │ │ ├── onErrorResumeNextWith.ts │ │ │ │ ├── pairwise.ts │ │ │ │ ├── partition.ts │ │ │ │ ├── raceWith.ts │ │ │ │ ├── reduce.ts │ │ │ │ ├── repeat.ts │ │ │ │ ├── repeatWhen.ts │ │ │ │ ├── retry.ts │ │ │ │ ├── retryWhen.ts │ │ │ │ ├── sample.ts │ │ │ │ ├── sampleTime.ts │ │ │ │ ├── scan.ts │ │ │ │ ├── scanInternals.ts │ │ │ │ ├── sequenceEqual.ts │ │ │ │ ├── share.ts │ │ │ │ ├── shareReplay.ts │ │ │ │ ├── single.ts │ │ │ │ ├── skip.ts │ │ │ │ ├── skipLast.ts │ │ │ │ ├── skipUntil.ts │ │ │ │ ├── skipWhile.ts │ │ │ │ ├── startWith.ts │ │ │ │ ├── subscribeOn.ts │ │ │ │ ├── switchAll.ts │ │ │ │ ├── switchMap.ts │ │ │ │ ├── switchMapTo.ts │ │ │ │ ├── switchScan.ts │ │ │ │ ├── take.ts │ │ │ │ ├── takeLast.ts │ │ │ │ ├── takeUntil.ts │ │ │ │ ├── takeWhile.ts │ │ │ │ ├── tap.ts │ │ │ │ ├── throttle.ts │ │ │ │ ├── throttleTime.ts │ │ │ │ ├── throwIfEmpty.ts │ │ │ │ ├── timeInterval.ts │ │ │ │ ├── timeout.ts │ │ │ │ ├── timeoutWith.ts │ │ │ │ ├── timestamp.ts │ │ │ │ ├── toArray.ts │ │ │ │ ├── window.ts │ │ │ │ ├── windowCount.ts │ │ │ │ ├── windowTime.ts │ │ │ │ ├── windowToggle.ts │ │ │ │ ├── windowWhen.ts │ │ │ │ ├── withLatestFrom.ts │ │ │ │ ├── zipAll.ts │ │ │ │ └── zipWith.ts │ │ │ ├── scheduled/ │ │ │ │ ├── scheduleArray.ts │ │ │ │ ├── scheduleAsyncIterable.ts │ │ │ │ ├── scheduleIterable.ts │ │ │ │ ├── scheduleObservable.ts │ │ │ │ ├── schedulePromise.ts │ │ │ │ ├── scheduleReadableStreamLike.ts │ │ │ │ └── scheduled.ts │ │ │ ├── scheduler/ │ │ │ │ ├── Action.ts │ │ │ │ ├── AnimationFrameAction.ts │ │ │ │ ├── AnimationFrameScheduler.ts │ │ │ │ ├── AsapAction.ts │ │ │ │ ├── AsapScheduler.ts │ │ │ │ ├── AsyncAction.ts │ │ │ │ ├── AsyncScheduler.ts │ │ │ │ ├── QueueAction.ts │ │ │ │ ├── QueueScheduler.ts │ │ │ │ ├── VirtualTimeScheduler.ts │ │ │ │ ├── animationFrame.ts │ │ │ │ ├── animationFrameProvider.ts │ │ │ │ ├── asap.ts │ │ │ │ ├── async.ts │ │ │ │ ├── dateTimestampProvider.ts │ │ │ │ ├── immediateProvider.ts │ │ │ │ ├── intervalProvider.ts │ │ │ │ ├── performanceTimestampProvider.ts │ │ │ │ ├── queue.ts │ │ │ │ ├── timeoutProvider.ts │ │ │ │ └── timerHandle.ts │ │ │ ├── symbol/ │ │ │ │ └── iterator.ts │ │ │ ├── testing/ │ │ │ │ ├── ColdObservable.ts │ │ │ │ ├── HotObservable.ts │ │ │ │ ├── TestMessage.ts │ │ │ │ ├── TestScheduler.ts │ │ │ │ └── subscription-logging.ts │ │ │ ├── types.ts │ │ │ └── util/ │ │ │ ├── ArgumentOutOfRangeError.ts │ │ │ ├── EmptyError.ts │ │ │ ├── Immediate.ts │ │ │ ├── NotFoundError.ts │ │ │ ├── SequenceError.ts │ │ │ ├── args.ts │ │ │ ├── argsArgArrayOrObject.ts │ │ │ ├── argsOrArgArray.ts │ │ │ ├── arrRemove.ts │ │ │ ├── createObject.ts │ │ │ ├── executeSchedule.ts │ │ │ ├── identity.ts │ │ │ ├── isDate.ts │ │ │ ├── isScheduler.ts │ │ │ ├── mapOneOrManyArgs.ts │ │ │ ├── noop.ts │ │ │ ├── not.ts │ │ │ ├── pipe.ts │ │ │ ├── rx.ts │ │ │ ├── throwUnobservableError.ts │ │ │ └── workarounds.ts │ │ ├── operators/ │ │ │ └── index.ts │ │ ├── testing/ │ │ │ └── index.ts │ │ ├── tsconfig.base.json │ │ ├── tsconfig.cjs.json │ │ ├── tsconfig.cjs.spec.json │ │ ├── tsconfig.esm.json │ │ ├── tsconfig.types.json │ │ ├── tsconfig.types.spec.json │ │ └── webSocket/ │ │ └── index.ts │ ├── tools/ │ │ ├── add-license-to-file.js │ │ ├── generate-alias.js │ │ └── subject-benchmark.js │ ├── tsconfig.json │ ├── tsconfig.mocha.json │ └── wallaby.js ├── resources/ │ └── CI-CD/ │ └── README.md └── scripts/ ├── copy-common-package-files.js ├── publish.js └── release.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf indent_size = 2 indent_style = space trim_trailing_whitespace = true ================================================ FILE: .eslintignore ================================================ packages/rxjs/integration/import/fixtures ================================================ FILE: .eslintrc.json ================================================ { "root": true, "ignorePatterns": ["**/*"], "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx"], "extends": ["plugin:@nx/typescript"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unsafe-declaration-merging": "off", "@typescript-eslint/consistent-type-imports": "warn", "@typescript-eslint/consistent-type-exports": "warn", "no-prototype-builtins": "off", "@typescript-eslint/no-inferrable-types": "warn", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-interface": "off" } }, { "files": ["*.js", "*.jsx"], "extends": ["plugin:@nx/javascript"], "rules": {} }, { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } ] } ], "@typescript-eslint/no-unused-vars": "off" } } ] } ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Bug report for RxJS core behavior. body: - type: textarea id: description attributes: label: Describe the bug description: | A clear and concise description of the behavior. validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior description: A clear and concise description of what you expect to happen. validations: required: true - type: textarea id: code attributes: label: Reproduction code description: Code to create a minimal reproduction. render: typescript - type: input id: repro-link attributes: label: Reproduction URL description: Use [Stackblitz](https://stackblitz.com/fork/rxjs) or a git repo to show a minimal reproduction of the issue. Please also paste the example code in the "Reproduction code" section above. - type: input id: version attributes: label: Version validations: required: true - type: textarea id: environment attributes: label: Environment placeholder: Version of runtime environment, build configuration, etc, that can affect behavior of RxJS. - type: textarea id: addition attributes: label: Additional context placeholder: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Questions, request, issues other than Rx core bugs url: https://github.com/ReactiveX/rxjs/discussions about: For general discussions, or request please use discussions. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ **Description:** **BREAKING CHANGE:** **Related issue (if exists):** ================================================ FILE: .github/actions/install-dependencies/action.yml ================================================ name: Install Dependencies description: 'Prepares the repo by installing dependencies' inputs: node-version: description: 'The node version to setup' required: true registry-url: description: 'Define registry-url' required: false default: 'https://registry.npmjs.org' # outputs: - no outputs runs: using: 'composite' steps: - name: Use Node.js ${{ inputs.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} registry-url: ${{ inputs.registry-url }} - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT shell: bash - uses: actions/cache@v3 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: Install dependencies run: yarn install --frozen-lockfile shell: bash env: YARN_CACHE_FOLDER: ${{ steps.yarn-cache-dir-path.outputs.dir }} ================================================ FILE: .github/lock.yml ================================================ # Configuration for lock-threads - https://github.com/dessant/lock-threads # Number of days of inactivity before a closed issue or pull request is locked daysUntilLock: 30 # Issues and pull requests with these labels will not be locked. Set to `[]` to disable exemptLabels: [] # Label to add before locking, such as `outdated`. Set to `false` to disable lockLabel: false # Comment to post before locking. Set to `false` to disable lockComment: false # Limit to only `issues` or `pulls` # only: issues # Optionally, specify configuration settings just for `issues` or `pulls` # issues: # exemptLabels: # - help-wanted # lockLabel: outdated # pulls: # daysUntilLock: 30 ================================================ FILE: .github/workflows/ci_main.yml ================================================ name: CI on: pull_request: types: ['opened', 'reopened', 'synchronize'] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: matrix: node: ['18', '20'] name: Node ${{ matrix.node }} build steps: - uses: actions/checkout@v4 - name: Install Dependencies uses: ./.github/actions/install-dependencies with: node-version: ${{ matrix.node }} - name: Build, test and lint all projects (except website) run: yarn nx run-many -t build lint test --exclude=rxjs.dev - name: rxjs lint run: yarn workspace rxjs lint - name: rxjs build run: yarn workspace rxjs build - name: rxjs test run: yarn workspace rxjs test - name: rxjs dtslint run: yarn workspace rxjs dtslint - name: rxjs test:import run: yarn workspace rxjs test:import - name: rxjs test:esm run: yarn workspace rxjs test:esm - name: rxjs.dev build run: yarn workspace rxjs.dev build --prod - name: rxjs.dev test run: yarn workspace rxjs.dev test --watch=false --browsers=ChromeHeadless ================================================ FILE: .github/workflows/ci_ts_latest.yml ================================================ name: CI (ts@latest) on: pull_request: types: ['opened', 'reopened', 'synchronize'] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: matrix: node: ['20'] name: ts@latest steps: - uses: actions/checkout@v4 - name: Install Dependencies uses: ./.github/actions/install-dependencies with: node-version: ${{ matrix.node }} - name: build run: | yarn workspace rxjs add typescript@latest @types/node@latest --peer --no-save yarn nx compile rxjs ================================================ FILE: .github/workflows/publish.yml ================================================ name: publish on: # Run manually using the GitHub UI workflow_dispatch: inputs: version: description: 'Version to publish' required: false default: '' # ...or whenever a GitHub release gets created release: types: [published] jobs: publish: name: Publish to npm runs-on: ubuntu-latest permissions: id-token: write # needed for provenance data generation steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # include tags - name: Install Dependencies uses: ./.github/actions/install-dependencies with: node-version: 20 - name: Prepare packages for publishing run: yarn prepare-packages - name: Apply updated version to packages run: | # Use the version from the workflow input if it's set, otherwise use the tag name from the release VERSION=${{ github.event.inputs.version || github.ref_name }} yarn nx release version $VERSION - name: Publish packages to npm run: node ./scripts/publish.js env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true ================================================ FILE: .github/workflows/rebase.yml ================================================ on: issue_comment: types: [created] permissions: contents: read name: Automatic Rebase jobs: rebase: permissions: contents: write # for cirrus-actions/rebase to push code to rebase pull-requests: read # for cirrus-actions/rebase to get info about PR name: Rebase if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Automatic Rebase uses: cirrus-actions/rebase@1.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # https://github.community/t5/GitHub-Actions/Workflow-is-failing-if-no-job-can-be-ran-due-to-condition/m-p/38186#M3250 always_job: name: Always run job runs-on: ubuntu-latest steps: - name: Always run run: echo "This job is used to prevent the workflow to fail when all other jobs are skipped." ================================================ FILE: .gitignore ================================================ # Editor-specific .idea/ *.iml *.sublime-project *.sublime-workspace .settings .vscode # Installed libs node_modules/ typings/ # Generated dist/ .tshy-build # Import location artifacts packages/rxjs/ajax/ packages/rxjs/fetch/ packages/rxjs/operators/ packages/rxjs/testing/ packages/rxjs/webSocket/ # Copied Package Files packages/**/LICENSE.txt packages/**/CODE_OF_CONDUCT.md # Misc npm-debug.log .DS_STORE *.tgz .eslintcache package-lock.json integration/import/**/rx.json integration/import/**/operators.json .nx/cache ================================================ FILE: .prettierrc.json ================================================ { "trailingComma": "es5", "singleQuote": true, "printWidth": 140, "overrides": [ { "files": ["spec/**/*.ts", "spec-dtslint/**/*.ts"], "options": { "requirePragma": true } }, { "files": ["spec/operators/**/*.ts", "spec/subjects/**/*.ts"], "options": { "requirePragma": false } } ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting Ben Lesh (ben@benlesh.com), Tracy Lee (tracy@thisdot.co) or OJ Kwon (kwon.ohjoong@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to RxJS [Read and abide by the Code of Conduct](CODE_OF_CONDUCT.md)! Even if you don't read it, it still applies to you. Ignorance is not an exemption. Contents - [Contributing to RxJS](#contributing-to-rxjs) - [Submitting a Pull Request (PR)](#submitting-a-pull-request-pr) - [After your pull request is merged](#after-your-pull-request-is-merged) - [Coding Style Guidelines](#coding-style-guidelines) - [Documentation](#documentation) - [Unit Tests](#unit-tests) - [CI Tests](#ci-tests) - [Performance Tests](#performance-tests) - [Macro](#macro) - [Micro](#micro) - [Commit Message Guidelines](#commit-message-guidelines) - [Commit Message Format](#commit-message-format) - [Revert](#revert) - [Type](#type) - [Scope](#scope) - [Subject](#subject) - [Body](#body) - [Footer](#footer) --- - Related documents - [Creating Operators](apps/rxjs.dev/content/guide/operators.md#creating-custom-operators) - [Writing Marble Tests](apps/rxjs.dev/content/guide/testing/marble-testing.md) --- (This document is a work in progress and is subject to change) ## Submitting a Pull Request (PR) Before you submit your Pull Request (PR) consider the following guidelines: - Search [GitHub](https://github.com/ReactiveX/RxJS/pulls) for an open or closed PR that relates to your submission. You don't want to duplicate effort. - Make your changes in a new git branch: ```shell git checkout -b my-fix-branch master ``` - Create your patch, following [code style guidelines](#coding-style-guidelines), and **including appropriate test cases**. - Run the full test suite and ensure that all tests pass. - Commit your changes using a descriptive commit message that follows our [commit message guidelines](#commit-message-guidelines). Adherence to these conventions is necessary because release notes are automatically generated from these messages. ```shell git commit -a ``` Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files. - Push your branch to GitHub: ```shell git push origin my-fix-branch ``` - In GitHub, send a pull request to `RxJS:master`. - If we suggest changes then: - Make the required updates. - Re-run the test suites to ensure tests are still passing. - Re-run performance tests to make sure your changes didn't hurt performance. - Rebase your branch and force push to your GitHub repository (this will update your Pull Request): ```shell git rebase master -i git push -f ``` - When updating your feature branch with the requested changes, please do not overwrite the commit history, but rather contain the changes in new commits. This is for the sake of a clearer and easier review process. That's it! Thank you for your contribution! ### After your pull request is merged After your pull request is merged, you can safely delete your branch and pull the changes from the main (upstream) repository: - Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: ```shell git push origin --delete my-fix-branch ``` - Check out the master branch: ```shell git checkout master -f ``` - Delete the local branch: ```shell git branch -D my-fix-branch ``` - Update your master with the latest upstream version: ```shell git pull --ff upstream master ``` ## Coding Style Guidelines - Please use proper types and generics throughout your code. - 2 space indentation only - favor readability over terseness (TBD): For now, try to follow the style that exists elsewhere in the source, and use your best judgment. ## Documentation - The documentation is auto-generated directly from the source code. - In short: From the source code we generate JSON documents, describing each operator, function ... and render this JSON within an Angular application - The folder `apps/rxjs.dev` contains everything you need for building and developing the docs - The [Documentation README](apps/rxjs.dev/README.md) will support you - After a PR is merged to master the docs will be published to https://rxjs.dev/ ## Unit Tests Unit tests are located under the [spec directory](/spec). Unit tests over synchronous operators and operations can be written in a standard [chai](https://www.chaijs.com/) style. Unit tests written against any asynchronous operator should be written in [Marble Test Style outlined in detail here](apps/rxjs.dev/content/guide/testing/marble-testing.md). Each operator under test must be in its own file to cover the following cases: - Never - Empty - Single/Multiple Values - Error in the sequence - Never ending sequences - Early disposal in sequences If the operator accepts a function as an argument from the user/developer (for example `filter(fn)` or `zip(a, fn)`), then it must cover the following cases: - Success with all values in the callback - Success with the context, if any allowed in the operator signature - If an error is thrown ### CI Tests - Using [Travis](https://travis-ci.org/) on your forked version of RxJS will allow running CI tests on that fork before submitting a PR to master - Simply create a `Travis` account and add your fork as a new project - [Sauce Labs](https://saucelabs.com/) setup will allow performing automated browser tests on the fork. Since `saucelabs` doesn't perform browser tests on a PR, this will help verify test results before PR's are checked into master. - In your `Travis` repo configuration, set the environment variables SAUCE_USERNAME and SAUCE_ACCESS_KEY to your `saucelabs` account ([reference](https://cloud.githubusercontent.com/assets/1210596/12679038/b9ba4eb6-c656-11e5-8c9b-b063c9a3f9dc.png)) - As master runs both of these tests per each check in, it'd be welcome to setup those test before creating your PR ## Performance Tests One of the primary goals of this library is (and will continue to be) great performance. As such, we've employed a variety of performance testing techniques. - DON'T labor over minute variations in ops/sec or milliseconds, there will always be variance in perf test results. - DON'T alter a performance test unless absolutely necessary. Performance tests may be compared to previous results from previous builds. - DO run tests multiple times and make sure the margins of error are low - DO run tests in your feature branches and compare them to master - DO add performance tests for all new operators - DO add performance tests that you feel are missing from other operators - DO add additional performance tests for all worthy code paths. If you develop an operator with special handling for scalar observables, please add tests for those scenarios ### Macro [Macro performance tests](perf/macro) are best written for scenarios where many object instance allocations (or deallocations) are occurring. Operators that create a lot of child subscriptions or operators that emit new objects like Observables and Subjects are definitely worth creating macro performance tests for. Other scenarios for macro performance testing may include common end-to-end scenarios from real-world apps. If you have a situation in your app where you feel RxJS is performing poorly, please [submit an issue](https://github.com/ReactiveX/rxjs/issues/) and include a minimal code example showing your performance issues. We would love to solve perf for your real-world problems and add those tests to our perf test battery. Macro performance tests can be run by hosting the root directory with any web server (we use [http-server](https://www.npmjs.com/package/http-server)), then running: ```sh yarn build_all protractor protractor.conf.js ``` ### Micro [Micro performance tests](perf/micro) really only serve to test operations per second. They're quick and easy to develop, and provide a reasonable look into the relative performance of our operators versus prior versions. All operators should have corresponding micro performance tests. Micro performance test can be run with: ```sh yarn build_all node perf/micro ``` If you wish to run a single micro performance test, you can do so by providing a single argument with the name of the perf test file(s): ```sh node perf/micro zip ``` ## Commit Message Guidelines We have very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. But also, we use the git commit messages to **generate the RxJS change log**. Helper script `yarn commit` provides command line based wizard to format commit message easily. ### Commit Message Format Each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**: ``` ():
``` The **header** is mandatory and the **scope** of the header is optional. Any line of the commit message cannot be longer than 100 characters! This allows the message to be easier to read on GitHub as well as in various git tools. ### Revert If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit .`, where the hash is the SHA of the commit being reverted. ### Type Must be one of the following: - **feat**: A new feature - **fix**: A bug fix - **docs**: Documentation only changes - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - **refactor**: A code change that neither fixes a bug nor adds a feature - **perf**: A code change that improves performance - **test**: Adding missing tests - **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation ### Scope The scope could be anything specifying the place of the commit change. For example `Observable`, `Subject`, `switchMap`, etc. ### Subject The subject contains succinct description of the change: - use the imperative, present tense: "change" not "changed" nor "changes" - don't capitalize first letter - no dot (.) at the end ### Body Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior. ### Footer The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit **Closes**. **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. ================================================ FILE: LICENSE.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright (c) 2015-2025 Ben Lesh Google, Inc. Netflix, Inc. Microsoft Corp. and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # RxJS Logo RxJS: Reactive Extensions For JavaScript ![CI](https://github.com/reactivex/rxjs/workflows/CI/badge.svg) [![npm version](https://badge.fury.io/js/rxjs.svg)](http://badge.fury.io/js/rxjs) [![Join the chat at https://gitter.im/Reactive-Extensions/RxJS](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Reactive-Extensions/RxJS?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) # RxJS 8 Monorepo Look for RxJS and related packages under the [/packages](/packages/) directory. Applications like the [rxjs.dev](https://rxjs.dev) documentation site are under the [/apps](/apps/) directory. [Apache 2.0 License](LICENSE.txt) - [Code of Conduct](CODE_OF_CONDUCT.md) - [Contribution Guidelines](CONTRIBUTING.md) - [Maintainer Guidelines](apps/rxjs.dev/content/maintainer-guidelines.md) - [API Documentation](https://rxjs.dev/) Reactive Extensions Library for JavaScript. This is a rewrite of [Reactive-Extensions/RxJS](https://github.com/Reactive-Extensions/RxJS) and is the latest production-ready version of RxJS. This rewrite is meant to have better performance, better modularity, better debuggable call stacks, while staying mostly backwards compatible, with some breaking changes that reduce the API surface. ## Versions In This Repository - [master](https://github.com/ReactiveX/rxjs/commits/master) - This is all of the current work, which is against v8 of RxJS right now - [7.x](https://github.com/ReactiveX/rxjs/tree/7.x) - This is the branch for version 7.X - [6.x](https://github.com/ReactiveX/rxjs/tree/6.x) - This is the branch for version 6.X Most PRs should be made to **master**. ## Important By contributing or commenting on issues in this repository, whether you've read them or not, you're agreeing to the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). Much like traffic laws, ignorance doesn't grant you immunity. ## Development Because of [this issue](https://github.com/npm/rfcs/issues/287#issuecomment-1727960500) we're using `yarn`. (Basically the docs app uses `@types/jasmine`, and the package uses `@types/mocha` and they get hoisted to the top level by `npm install` with workspaces, and then TypeScript vomits everywhere when you try to build). 1. `cd` to the repository root 2. `yarn install` to install all dependencies 3. `yarn workspace rxjs test` will run the RxJS test suite 4. `yarn workspace rxjs.dev start` will start the rxjs.dev documentation site local development server ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Security updates are applied only to the latest release. ## Reporting a Vulnerability If you have discovered a security vulnerability in this project, please report it privately. **Do not disclose it as a public issue.** This gives us time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released. Besides, make sure to align with us before any public disclosure to ensure no dangerous information goes public too soon. Please disclose it at [security advisory](https://github.com/ReactiveX/rxjs/security/advisories/new). Although we will be working to solve any security issue as fast as possible, it is also important to notice that, in accordance with Apache 2.0 terms, no RxJS contributor can be liable for damages, including the ones caused by a security issue. ================================================ FILE: apps/rxjs.dev/.browserslistrc ================================================ # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries # You can see what browsers were selected by your queries by running: # npx browserslist > 0.5% last 2 versions Firefox ESR not dead not IE 9-11 # For IE 9-11 support, remove 'not'. not ios_saf 15.2-15.3 not safari 15.2-15.3 ================================================ FILE: apps/rxjs.dev/.eslintrc.js ================================================ module.exports = { root: true, overrides: [ { files: ['*.ts'], parserOptions: { project: ['./tsconfig.json', './tests/e2e/tsconfig.e2e.json'], createDefaultProgram: true, tsconfigRootDir: __dirname, }, extends: [ 'plugin:@angular-eslint/ng-cli-compat', 'plugin:@angular-eslint/ng-cli-compat--formatting-add-on', 'plugin:@angular-eslint/template/process-inline-templates', ], rules: { '@typescript-eslint/ban-types': 'error', '@angular-eslint/component-selector': [ 'error', { type: 'element', prefix: 'aio', style: 'kebab-case', }, ], '@angular-eslint/directive-selector': [ 'error', { type: 'attribute', prefix: 'aio', style: 'camelCase', }, ], 'dot-notation': 'error', indent: 'off', 'max-len': ['error', 140], '@typescript-eslint/member-delimiter-style': [ 'error', { singleline: { delimiter: 'comma', requireLast: false, }, }, ], '@typescript-eslint/member-ordering': 'off', '@typescript-eslint/naming-convention': 'off', 'no-console': ['error', { allow: ['log', 'warn', 'error'] }], 'no-empty-function': 'off', '@angular-eslint/no-host-metadata-property': 'off', 'no-restricted-syntax': [ 'error', { selector: 'CallExpression[callee.name=/^(fdescribe|fit)$/]', message: "Don't keep jasmine focus methods.", }, ], 'no-shadow': 'off', '@typescript-eslint/no-shadow': ['error'], 'no-tabs': 'error', 'no-underscore-dangle': 'off', 'no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': ['error'], 'no-use-before-define': 'off', 'prefer-arrow/prefer-arrow-functions': 'off', quotes: 'off', '@typescript-eslint/quotes': ['error', 'single', { avoidEscape: true }], semi: 'error', 'jsdoc/newline-after-description': 'off', '@typescript-eslint/no-non-null-assertion': 'off', }, }, { files: ['*.html'], extends: ['plugin:@angular-eslint/template/recommended'], rules: { '@angular-eslint/template/accessibility-alt-text': 'error', '@angular-eslint/template/accessibility-elements-content': 'error', '@angular-eslint/template/accessibility-label-has-associated-control': 'error', '@angular-eslint/template/accessibility-table-scope': 'error', '@angular-eslint/template/accessibility-valid-aria': 'error', '@angular-eslint/template/click-events-have-key-events': 'error', '@angular-eslint/template/eqeqeq': 'off', '@angular-eslint/template/mouse-events-have-key-events': 'error', '@angular-eslint/template/no-autofocus': 'error', '@angular-eslint/template/no-distracting-elements': 'error', '@angular-eslint/template/no-positive-tabindex': 'error', }, }, ], }; ================================================ FILE: apps/rxjs.dev/.firebaserc ================================================ { "targets": { "rxjs-dev": { "hosting": { "stable": [ "rxjs-dev" ] } } } } ================================================ FILE: apps/rxjs.dev/.gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output /dist /out-tsc /src/generated /tmp # dependencies /node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.angular/cache /.sass-cache /connect.lock /coverage /libpeerconnection.log debug.log npm-debug.log testem.log /typings yarn-error.log # e2e /e2e/*.js /e2e/*.map protractor-results*.txt # System Files .DS_Store Thumbs.db assets/images/svgs/* !assets/images/svgs/.gitkeep ================================================ FILE: apps/rxjs.dev/README.md ================================================ # RxJS documentation project Everything in this folder is part of the documentation project. This includes - the web site for displaying the documentation - the dgeni configuration for converting source files to rendered files that can be viewed in the web site. ## Developer tasks We use `npm` to manage the dependencies and to run build tasks. You should run all these tasks from the `apps/rxjs.dev` folder. Here are the most important tasks you might need to use: - `npm install` - install all the dependencies. - `yarn setup` - install all the dependencies and run dgeni on the docs. - `yarn build` - create a production build of the application (after installing dependencies, etc). - `npm start` - run a development web server that watches the files; then builds the doc-viewer and reloads the page, as necessary. - `yarn serve-and-sync` - run both the `docs-watch` and `start` in the same console. - `yarn lint` - check that the doc-viewer code follows our style rules. - `npm test` - watch all the source files, for the doc-viewer, and run all the unit tests when any change. - `npm test -- --watch=false` - run all the unit tests once. - `yarn e2e` - run all the e2e tests for the doc-viewer. - `yarn docs` - generate all the docs from the source files. - `yarn docs-watch` - watch the RxJS source and the docs files and run a short-circuited doc-gen for the docs that changed (don't work properly at the moment). - `yarn docs-lint` - check that the doc gen code follows our style rules. - `yarn docs-test` - run the unit tests for the doc generation code. ## Using ServiceWorker locally Running `yarn start` (even when explicitly targeting production mode) does not set up the ServiceWorker. If you want to test the ServiceWorker locally, you can use `yarn build` and then serve the files in `dist/` with `yarn http-server -- dist -p 4200`. ## Running on Docker The docs app (rxjs.dev) can run as a docker container. In order to run the docs app on docker, use the following commands (**run from the rxjs folder**): - `docker build -t rxjs-docs:6.4.1 .` - building the rxjs docs app image - `docker run -p :4200 rxjs-docs:6.4.1` - starting the container, listening on __ for your choice. The container will run the documentation app with the script `start:docker` with the **stable configuration** and with 0.0.0.0 host support. - Saving the image for later offline usage is available by building the container and then using `sudo docker save rxjs-docs:6.4.1 > .tar` and loading it afterwards with `sudo docker load < .tar`. > tested on ubuntu 18.04.2 with Docker 18.09.4 ## Guide to authoring There are two types of content in the documentation: - **API docs**: descriptions of the modules, classes, interfaces, etc that make up RxJS. API docs are generated directly from the source code. The source code is contained in TypeScript files, located in the `/packages/rxjs/src` folder. Each API item may have a preceding comment, which contains JSDoc style tags and content. The content is written in markdown. - **Other content**: guides, tutorials, and other marketing material. All other content is written using markdown in text files, located in the `apps/rxjs.dev/content` folder. More specifically, there are sub-folders that contain particular types of content: guides, tutorial and marketing. ### Generating the complete docs The main task for generating the docs is `yarn docs`. This will process all the source files (API and other), extracting the documentation and generating JSON files that can be consumed by the doc-viewer. ### Partial doc generation for editors Full doc generation can take up to one minute. That's too slow for efficient document creation and editing. You can make small changes in a smart editor that displays formatted markdown: > In VS Code, _Cmd-K, V_ opens markdown preview in side pane; _Cmd-B_ toggles left sidebar You also want to see those changes displayed properly in the doc viewer with a quick, edit/view cycle time. For this purpose, use the `yarn docs-watch` task, which watches for changes to source files and only re-processes the files necessary to generate the docs that are related to the file that has changed. Since this task takes shortcuts, it is much faster (often less than 1 second) but it won't produce full fidelity content. For example, links to other docs and code examples may not render correctly. This is most particularly noticed in links to other docs and in the embedded examples, which may not always render correctly. The general setup is as follows: - Open a terminal, ensure the dependencies are installed; run an initial doc generation; then start the doc-viewer: ```bash yarn setup yarn start ``` - Open a second terminal and start watching the docs ```bash yarn docs-watch ``` > Alternatively, try the consolidated `serve-and-sync` command that builds, watches and serves in the same terminal window ```bash yarn serve-and-sync ``` - Open a browser at https://localhost:4200/ and navigate to the document on which you want to work. You can automatically open the browser by using `npm start -- -o` in the first terminal. - Make changes to the page's associated doc or example files. Every time a file is saved, the doc will be regenerated, the app will rebuild and the page will reload. - If you get a build error complaining about examples or any other odd behavior, be sure to consult the [Authors Style Guide](https://angular.io/guide/docs-style-guide). ## Disclaimer Starting the new documentation, we worked closely together with the Angular team and therefore adapted their way of generating docs. This leads to the effect, that there may be some references to angular (e.g. variable names, file names ...). Don't be confused by this, this shouldn't bother you. Thanks to the Angular Team for their support. Anyway RxJS will always be an independent project, which aims to work closely with other technologies and frameworks! ================================================ FILE: apps/rxjs.dev/angular.json ================================================ { "$schema": "../../node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { "packageManager": "yarn", "analytics": false }, "newProjectRoot": "projects", "projects": { "site": { "root": "", "sourceRoot": "src", "projectType": "application", "prefix": "aio", "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { "outputPath": "dist", "index": "src/index.html", "main": "src/main.ts", "tsConfig": "tsconfig.app.json", "webWorkerTsConfig": "tsconfig.worker.json", "namedChunks": true, "polyfills": "src/polyfills.ts", "assets": [ "src/img", "src/assets", "src/generated", "src/app/search/search-worker.js", "src/pwa-manifest.json", "src/google385281288605d160.html", { "glob": "custom-elements.min.js", "input": "node_modules/@webcomponents/custom-elements", "output": "/assets/js" }, { "glob": "native-shim.js", "input": "node_modules/@webcomponents/custom-elements/src", "output": "/assets/js" } ], "styles": ["src/styles.scss"], "scripts": [], "vendorChunk": true, "extractLicenses": false, "buildOptimizer": false, "sourceMap": true, "optimization": { "fonts": true, "scripts": true, "styles": { "inlineCritical": false, "minify": true } }, "outputHashing": "all" }, "configurations": { "fast": { "budgets": [ { "type": "anyComponentStyle", "maximumWarning": "6kb" } ] }, "next": { "budgets": [ { "type": "anyComponentStyle", "maximumWarning": "6kb" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.next.ts" } ], "serviceWorker": true }, "stable": { "budgets": [ { "type": "anyComponentStyle", "maximumWarning": "6kb" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.stable.ts" } ], "serviceWorker": true }, "archive": { "budgets": [ { "type": "anyComponentStyle", "maximumWarning": "6kb" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.archive.ts" } ], "serviceWorker": true }, "production": { "budgets": [ { "type": "anyComponentStyle", "maximumWarning": "6kb" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "serviceWorker": true } }, "defaultConfiguration": "" }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { "browserTarget": "site:build" }, "configurations": { "fast": { "browserTarget": "site:build:fast" }, "next": { "browserTarget": "site:build:next" }, "stable": { "browserTarget": "site:build:stable" }, "archive": { "browserTarget": "site:build:archive" }, "production": { "browserTarget": "site:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { "browserTarget": "site:build" } }, "test": { "builder": "@angular-devkit/build-angular:karma", "options": { "main": "src/test.ts", "karmaConfig": "src/karma.conf.js", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", "scripts": [], "styles": ["src/styles.scss"], "assets": [ "src/img", "src/assets", "src/generated", "src/app/search/search-worker.js", "src/pwa-manifest.json", "src/google385281288605d160.html", { "glob": "custom-elements.min.js", "input": "node_modules/@webcomponents/custom-elements", "output": "/assets/js" }, { "glob": "native-shim.js", "input": "node_modules/@webcomponents/custom-elements/src", "output": "/assets/js" } ] } }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { "lintFilePatterns": ["src/!(generated)/**/*.ts", "src/!(generated)/**/*.html", "tests/**/*.ts"] } }, "e2e": { "builder": "@angular-devkit/build-angular:protractor", "options": { "protractorConfig": "tests/e2e/protractor.conf.js", "devServerTarget": "site:serve" } } } } }, "defaultProject": "site", "schematics": { "@schematics/angular:application": { "strict": true }, "@schematics/angular:component": { "inlineStyle": true, "style": "scss" } } } ================================================ FILE: apps/rxjs.dev/content/6-to-7-change-summary.md ================================================ # RxJS 6.x to 7.x Detailed Change List This document contains a detailed list of changes between RxJS 6.x and RxJS 7.x, presented in the order they can be found when diffing the TypeScript APIs in various module files. ## module `rxjs` ### Breaking changes #### AsyncSubject - `_subscribe` method is no longer `public` and is now `protected`. - no longer has its own implementation of the `error` method inherited from `Subject`. #### BehaviorSubject - `_subscribe` method is no longer `public` and is now `protected`. - `value` property is a getter `get value()` instead of `readonly value`, and can no longer be forcibly set. #### bindCallback - Generic signatures have changed. Do not explicitly pass generics. #### combineLatest - Generic signatures have changed. Do not explicitly pass generics. #### concat - Generic signatures have changed. Do not explicitly pass generics. #### ConnectableObservable - `_isComplete` is no longer a property. - `_subscribe` method is no longer `public` and is now `protected`. #### defer - Generic argument no longer extends `void`. - `defer` no longer allows factories to return void or undefined. All factories passed to `defer` must return a proper `ObservableInput`, such as `Observable`, `Promise`, et al. To get the same behavior as you may have relied on previously, `return EMPTY` or `return of()` from the factory. #### forkJoin - Generic signatures have changed. Do not explicitly pass generics. #### fromEvent - The `fromEvent` signatures have been changed and there are now separate signatures for each type of target - DOM, Node, jQuery, etc. That means that an attempt to pass options - like `{ once: true }` - to a target that does not support an options argument will result in a TypeScript error. #### GroupedObservable - No longer publicly exposes `_subscribe` - `key` properly is `readonly`. - No longer publicly exposes `constructor`. #### iif - Generic signatures have changed. Do not explicitly pass generics. - `iif` will no longer allow result arguments that are `undefined`. This was a bad call pattern that was likely an error in most cases. If for some reason you are relying on this behavior, simply substitute `EMPTY` in place of the `undefined` argument. This ensures that the behavior was intentional and desired, rather than the result of an accidental `undefined` argument. #### isObservable - No longer has a generic and returns `Observable`, you must cast the result. #### merge - Generic signatures have changed. Do not explicitly pass generics. #### Notification - The `error` property is now `readonly`. - The `hasValue` property is now `readonly`. - The `kind` property is now `readonly`. - The `value` property is now `readonly` and may be `undefined`. - `constructor` signature now only allows valid construction. For example `new Notification('C', 'some_value')` will be an error in TypeScript. #### Observable - `_isScalar` property removed. - `_subscribe` method is no longer `public` and is now marked `@internal`. - `_trySubscribe` method is no longer `public` and is now `@internal`. - `pipe` method calls with `9` or more arguments will now return `Observable` rather than `Observable<{}>`. - `toPromise` method now correctly returns `Promise` instead of `Promise`. This is a correction without a runtime change, because if the observable does not emit a value before completion, the promise will resolve with `undefined`. - `static if` and `static throw` properties are no longer defined. They were unused in version 6. - `lift`, `source`, and `operator` properties are still **deprecated**, and should not be used. They are implementation details, and will very likely be renamed or missing in version 8. #### of - Generic signatures have changed. Do not explicitly pass generics. #### onErrorResumeNext - Generic signatures have changed. Do not explicitly pass generics. #### pairs - Generic signatures have changed. Do not explicitly pass generics. - `pairs` will no longer function in IE without a polyfill for `Object.entries`. `pairs` itself is also deprecated in favor of users just using `from(Object.entries(obj))`. #### partition - Generic signatures have changed. Do not explicitly pass generics. #### pipe - Calls with `9` or more arguments will now return `(arg: A) => unknown` rather than `(arg: A) => {}`. #### race - Generic signatures have changed. Do not explicitly pass generics. - `race` will no longer subscribe to subsequent observables if a provided source synchronously errors or completes. This means side effects that might have occurred during subscription in those rare cases will no longer occur. #### ReplaySubject - `_getNow` method has been removed. - `_subscribe` method is no longer `public` and is now `protected`. #### Subscribable - `subscribe` will accept `Partial>` now. All overloads with functions as arguments have been removed. This is because `Subscribable` is intended to map to the basic observable contract from the TC39 proposal and the return type of a call to `[Symbol.observable]()`. #### SubscribableOrPromise - See notes on `Subscribable` above. #### Subscriber - `destination` property must now be a `Subscriber` or full `Observer`. - `syncErrorThrowable` property has been removed. - `syncErrorThrown` property has been removed. - `syncErrorValue` property has been removed. - `_unsubscribeAndRecycle` method has been removed. #### Subscription - `_parentOrParents` property has been removed. - `add` method returns `void` and no longer returns a `Subscription`. Returning `Subscription` was an old behavior from the early days of version 5. If you add a function to a subscription (i.e. `subscription.add(fn)`), you can remove that function directly by calling `remove` with the same function instance. (i.e. `subscription.remove(fn)`). Previously, you needed to get the returned `Subscription` object and pass _that_ to `remove`. In version 6 and lower, the `Subscription` returned by calling `add` with another `Subscription` was always the same subscription you passed in. (meaning `subscription.add(subs1).add(subs2)` was an antipattern and the same as `subscription.add(subs1); subs1.add(subs2);`. #### VirtualAction - The static `sortActions` method has been removed. #### zip - Generic signatures have changed. Do not explicitly pass generics. - Zipping a single array will now have a different result. This is an extreme corner-case, because it is very unlikely that anyone would want to zip an array with nothing at all. The workaround would be to wrap the array in another array `zip([[1,2,3]])`. But again, that's pretty weird. --- ### New Features #### animationFrames - A new method for creating a stream of animation frames. Each event will carry with it a high-resolution timestamp, and an elapsed time since observation was started. #### config ##### onUnhandledError - A handler for dealing with errors that make it all the way down to the "end" of the observation chain when there is no error handler in the observer. Useful for doing things like logging unhandled errors in RxJS observable chains. ##### onStoppedNotification - A handler for edge cases where a subscriber within RxJS is notified after it has already "stopped", that is, a point in time where it has received an error or complete, but hasn't yet finalized. This is mostly useful for logging purposes. ##### useDeprecatedNextContext - In RxJS 6, a little-used feature allowed users to access the `subscriber` directly as `this` within a call to the `next` handler. The problem with this is it incurred heavy performance penalties. That behavior has been changed (because it wasn't really documented and it was barely ever used) to not change the `this` context of any user-provided subscription handlers. If you need to get that feature back, you can switch it on with this flag. Note this behavior will be removed completely in version 8. #### connectable - This is the new means for creating a `ConnectableObservable`, and really is a replacement for non-selector usage of `multicast` and `publish` variants. Simply pass your source observable to `connectable` with the `Subject` you'd like to connect through. #### firstValueFrom - A better, more tree-shakable replacement for `toPromise()` (which is now deprecated). This function allows the user to convert any `Observable` into a `Promise` that will resolve when the source observable emits its first value. If the source observable closes without emitting a value, the returned promise will reject with an `EmptyError`, or it will resolve with a configured `defaultValue`. For more information, see the [deprecation guide](/deprecations/to-promise). #### lastValueFrom - A better, more tree-shakable replacement for `toPromise()` (which is now deprecated). This function allows the user to convert any `Observable` in to a `Promise` that will resolve when the source observable emits the last value. If the source observable closes without emitting a value, the returned promise will reject with an `EmptyError`, or it will resolve with a configured `defaultValue`. For more information, see the [deprecation guide](/deprecations/to-promise). #### ObservableInput - This is just a type, but it's important. This type defines the allowed types that can be passed to almost every API within RxJS that accepts an Observable. It has always accepted `Observable`, `Promise`, `Iterable`, and `ArrayLike`. Now it will also accept `AsyncIterable` and `ReadableStream`. ##### AsyncIterable - `AsyncIterables` such as those defined by `IxJS` or by async generators (`async function*`), may now be passed to any API that accepts an observable, and can be converted to an `Observable` directly using `from`. ##### ReadableStream - `ReadableStream` such as those returned by `fetch`, et al, can be passed to any API that accepts an observable, and can be converted to `Observable` directly using `from`. #### ReplaySubject - A [bug was fixed](https://github.com/ReactiveX/rxjs/pull/5696) that prevented a completed or errored `ReplaySubject` from accumulating values in its buffer when resubscribed to another source. This breaks some uses - like [this StackOverflow answer](https://stackoverflow.com/a/54957061) - that depended upon the buggy behavior. #### Subscription - Now allows adding and removing of functions directly via `add` and `remove` methods. #### throwError - Now accepts an `errorFactory` of `() => any` to defer the creation of the error until the time it will be emitted. It is recommended to use this method, as Errors created in most popular JavaScript runtimes will retain all values in the current scope for debugging purposes. ## module `rxjs/operators` ### Breaking Changes #### audit - The observable returned by the `audit` operator's duration selector must emit a next notification to end the duration. Complete notifications no longer end the duration. - `audit` now emits the last value from the source when the source completes. Previously, `audit` would mirror the completion without emitting the value. #### auditTime - `auditTime` now emits the last value from the source when the source completes, after the audit duration elapses. Previously, `auditTime` would mirror the completion without emitting the value and without waiting for the audit duration to elapse. #### buffer - `buffer` now subscribes to the source observable before it subscribes to the closing notifier. Previously, it subscribed to the closing notifier first. - Final buffered values will now always be emitted. To get the same behavior as the previous release, you can use `endWith` and `skipLast(1)`, like so: `source$.pipe(buffer(notifier$.pipe(endWith(true))), skipLast(1))` - `closingNotifier` completion no longer completes the result of `buffer`. If that is truly a desired behavior, then you should use `takeUntil`. Something like: `source$.pipe(buffer(notifier$), takeUntil(notifier$.pipe(ignoreElements(), endWith(true))))`, where `notifier$` is multicast, although there are many ways to compose this behavior. #### bufferToggle - The observable returned by the `bufferToggle` operator's closing selector must emit a next notification to close the buffer. Complete notifications no longer close the buffer. #### bufferWhen - The observable returned by the `bufferWhen` operator's closing selector must emit a next notification to close the buffer. Complete notifications no longer close the buffer. #### combineLatest - Generic signatures have changed. Do not explicitly pass generics. #### concat - Generic signatures have changed. Do not explicitly pass generics. - Still deprecated, use the new `concatWith`. #### concatAll - Generic signatures have changed. Do not explicitly pass generics. #### concatMapTo - Generic signatures have changed. Do not explicitly pass generics. #### count - No longer passes `source` observable as a third argument to the predicate. That feature was rarely used, and of limited value. The workaround is to simply close over the source inside of the function if you need to access it in there. #### debounce - The observable returned by the `debounce` operator's duration selector must emit a next notification to end the duration. Complete notifications no longer end the duration. #### debounceTime - The `debounceTime` implementation is more efficient and no longer schedules an action for each received next notification. However, because the implementation now uses the scheduler's concept of time, any tests using Jasmine's `clock` will need to ensure that [`jasmine.clock().mockDate()`](https://jasmine.github.io/api/edge/Clock.html#mockDate) is called after `jasmine.clock().install()` - because Jasmine does not mock `Date.now()` by default. #### defaultIfEmpty - Generic signatures have changed. Do not explicitly pass generics. - `defaultIfEmpty` requires a value be passed. Will no longer convert `undefined` to `null` for no good reason. #### delayWhen - `delayWhen` will no longer emit if the duration selector simply completes without a value. Notifiers must notify with a value, not a completion. #### endWith - Generic signatures have changed. Do not explicitly pass generics. #### expand - Generic signatures have changed. Do not explicitly pass generics. #### finalize - `finalize` will now unsubscribe from its source _before_ it calls its callback. That means that `finalize` callbacks will run in the order in which they occur in the pipeline: `source.pipe(finalize(() => console.log(1)), finalize(() => console.log(2)))` will log `1` and then `2`. Previously, callbacks were called in the reverse order. #### map - `thisArg` will now default to `undefined`. The previous default of `MapSubscriber` never made any sense. This will only affect code that calls map with a `function` and references `this` like so: `source.pipe(map(function () { console.log(this); }))`. There wasn't anything useful about doing this, so the breakage is expected to be very minimal. If anything we're no longer leaking an implementation detail. #### merge - Generic signatures have changed. Do not explicitly pass generics. - Still deprecated, use the new `mergeWith`. #### mergeAll - Generic signatures have changed. Do not explicitly pass generics. #### mergeScan - `mergeScan` will no longer emit its inner state again upon completion. #### pluck - Generic signatures have changed. Do not explicitly pass generics. #### race - Generic signatures have changed. Do not explicitly pass generics. #### reduce - Generic signatures have changed. Do not explicitly pass generics. #### sample - The `sample` operator's notifier observable must emit a next notification to effect a sample. Complete notifications no longer effect a sample. #### scan - Generic signatures have changed. Do not explicitly pass generics. #### single - The `single` operator will now throw for scenarios where values coming in are either not present, or do not match the provided predicate. Error types have thrown have also been updated, please check documentation for changes. #### skipLast - `skipLast` will no longer error when passed a negative number, rather it will simply return the source, as though `0` was passed. #### startWith - Generic signatures have changed. Do not explicitly pass generics. #### switchAll - Generic signatures have changed. Do not explicitly pass generics. #### switchMapTo - Generic signatures have changed. Do not explicitly pass generics. #### take - `take` and will now throw runtime error for arguments that are negative or NaN, this includes non-TS calls like `take()`. #### takeLast - `takeLast` now has runtime assertions that throw `TypeError`s for invalid arguments. Calling `takeLast` without arguments or with an argument that is `NaN` will throw a `TypeError`. #### throttle - The observable returned by the `throttle` operator's duration selector must emit a next notification to end the duration. Complete notifications no longer end the duration. #### throwError - In an extreme corner case for usage, `throwError` is no longer able to emit a function as an error directly. If you need to push a function as an error, you will have to use the factory function to return the function like so: `throwError(() => functionToEmit)`, in other words `throwError(() => () => console.log('called later'))`. #### window - The `windowBoundaries` observable no longer completes the result. It was only ever meant to notify of the window boundary. To get the same behavior as the old behavior, you would need to add an `endWith` and a `skipLast(1)` like so: `source$.pipe(window(notifier$.pipe(endWith(true))), skipLast(1))`. #### windowToggle - The observable returned by the `windowToggle` operator's closing selector must emit a next notification to close the window. Complete notifications no longer close the window. #### withLatestFrom - Generic signatures have changed. Do not explicitly pass generics. #### zip - Generic signatures have changed. Do not explicitly pass generics. - Still deprecated, use the new `zipWith`. - `zip` operators will no longer iterate provided iterables "as needed", instead the iterables will be treated as push-streams just like they would be everywhere else in RxJS. This means that passing an endless iterable will result in the thread locking up, as it will endlessly try to read from that iterable. This puts us in line with all other Rx implementations. To work around this, it is probably best to use `map` or some combination of `map` and `zip`. For example, `zip(source$, iterator)` could be `source$.pipe(map(value => [value, iterator.next().value]))`. ### New Features #### connect - New operator to cover the use cases of `publish` variants that use a `selector`. Wherein the selector allows the user to define multicast behavior prior to connection to the source observable for the multicast. #### share - Added functionality to allow complete configuration of what type of `Subject` is used to multicast, and when that subject is reset. #### timeout - Added more configuration options to `timeout`, so it could be used to timeout just if the first item doesn't arrive quickly enough, or it could be used as a timeout between each item. Users may also pass a `Date` object to define an absolute time for a timeout for the first time to arrive. Adds additional information to the timeout error, and the ability to pass along metadata with the timeout for identification purposes. #### zipWith, concatWith, mergeWith, raceWith - Simply renamed versions of the operators `zip`, `concat`, `merge`, and `race`. So we can deprecate those old names and use the new names without collisions. ## module `rxjs/ajax` ### Breaking Changes #### ajax - `ajax` body serialization will now use default XHR behavior in all cases. If the body is a `Blob`, `ArrayBuffer`, any array buffer view (like a byte sequence, e.g. `Uint8Array`, etc), `FormData`, `URLSearchParams`, `string`, or `ReadableStream`, default handling is used. If the `body` is otherwise `typeof` `"object"`, then it will be converted to JSON via `JSON.stringify`, and the `Content-Type` header will be set to `application/json;charset=utf-8`. All other types will emit an error. - The `Content-Type` header passed to `ajax` configuration no longer has any effect on the serialization behavior of the AJAX request. - For TypeScript users, `AjaxRequest` is no longer the type that should be explicitly used to create an `ajax`. It is now `AjaxConfig`, although the two types are compatible, only `AjaxConfig` has `progressSubscriber` and `createXHR`. - Ajax implementation drops support for IE10 and lower. This puts us in line with other implementations and helps clean up code in this area #### AjaxRequest - `AjaxRequest` is no longer used to type the configuration argument for calls to `ajax`. The new type is `AjaxConfig`. This was done to disambiguate two very similar types with different use cases. `AjaxRequest` is still there, but properties have changed, and it is used to show what final request information was sent as part of an event response. ### New Features #### AjaxResponse - Now includes `responseHeaders`. - Now includes event `type` and `total` numbers for examining upload and download progress (see `includeUploadProgress` and `includeDownloadProgress`). #### includeUploadProgress - A flag to make a request that will include streaming upload progress events in the returned observable. #### includeDownloadProgress - A flag to make a request that will include streaming upload progress events in the returned observable. #### queryParams - Configuration for setting query parameters in the URL of the request to be made. #### XSRF (CSRF) additions: - `xsrfCookieName` and `xsrfHeaderName` were added for cross-site request forgery prevention capabilities. ## module `rxjs/fetch` No changes. ## module `rxjs/testing` ### New Features #### TestScheduler expectObservable().toEqual() - A new means of comparing the equality of two observables. If all emissions are the same, and at the same time, then they are equal. This is primarily useful for refactoring operator chains and making sure that they are equivalent. ================================================ FILE: apps/rxjs.dev/content/blackLivesMatter.md ================================================

BLACK LIVES MATTER

We stand in solidarity with the Black Lives Matter movement. We believe that technologists must not be silent in the fight to end racial inequality.

We ask you to stand with us and help educate your team members and those in your network on how to help dismantle a system that oppresses Black people. Find a list of starting resources here:

  • Let's get to the root of racial injustice by Megan Ming Francis
  • What Leaders can do for Black Employees by Dr. Akilah Cadet
  • Hey Employers: Do Black Lives Matter? by Pariss Athena
  • Algorithms of Oppression by Safiya Umoja Noble
  • Rage Inside The Machine by Robert Smith
  • Technically Wrong by Sara Wachter-Boettcher

In solidarity, we ask you to consider financially supporting efforts such as Black Lives Matter, The Equal Justice Initiative or local charity organizations.

================================================ FILE: apps/rxjs.dev/content/code-of-conduct.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting Ben Lesh (ben@benlesh.com), Tracy Lee (tracy@thisdot.co) or OJ Kwon (kwon.ohjoong@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: apps/rxjs.dev/content/deprecations/array-argument.md ================================================ # Array Arguments To unify the API surface of `forkJoin` and `combineLatest` we deprecated some signatures. Since that it is recommended to either pass an Object or an Array to these operators.
This deprecation was introduced in RxJS 6.5.
## Operators affected by this Change - [combineLatest](/api/index/function/combineLatest) - [forkJoin](/api/index/function/forkJoin) ## How to Refactor We deprecated the signatures, where just pass all Observables directly as parameters to these operators. ```ts import {forkJoin, from} from 'rxjs'; const odd$ = from([1,3,5]); const even$ = from([2,4,6]); // deprecated forkJoin(odd$, even$); // suggested change forkJoin([odd$, even$]); // or forkJoin({odd: odd$, even: even$}) ``` ================================================ FILE: apps/rxjs.dev/content/deprecations/breaking-changes.md ================================================ # Breaking Changes in Version 7 ## General - **TS:** RxJS requires TS 4.2 - **rxjs-compat:** `rxjs-compat` is not published for v7 - **toPromise:** toPromise return type now returns `T | undefined` in TypeScript, which is correct, but may break builds. - **Subscription:** `add` no longer returns an unnecessary Subscription reference. This was done to prevent confusion caused by a legacy behavior. You can now add and remove functions and Subscriptions as teardowns to and from a `Subscription` using `add` and `remove` directly. Before this, `remove` only accepted subscriptions. - **Observable:** `lift` no longer exposed. It was _NEVER_ documented that end users of the library should be creating operators using `lift`. Lift has a [variety of issues](https://github.com/ReactiveX/rxjs/issues/5431) and was always an internal implementation detail of rxjs that might have been used by a few power users in the early days when it had the most value. The value of `lift`, originally, was that subclassed `Observable`s would compose through all operators that implemented lift. The reality is that feature is not widely known, used, or supported, and it was never documented as it was very experimental when it was first added. Until the end of v7, `lift` will remain on Observable. Standard JavaScript users will notice no difference. However, TypeScript users might see complaints about `lift` not being a member of observable. To workaround this issue there are two things you can do: 1. Rewrite your operators as [outlined in the documentation](https://rxjs.dev/guide/operators), such that they return `new Observable`. or 2. cast your observable as `any` and access `lift` that way. Method 1 is recommended if you do not want things to break when we move to version 8. - **Subscriber:** `new Subscriber` no longer takes 0-3 arguments. To create a `Subscriber` with 0-3 arguments, use `Subscriber.create`. However, please note that there is little to no reason that you should be creating `Subscriber` references directly, and `Subscriber.create` and `new Subscriber` are both deprecated. - **onUnhandledError:** Errors that occur during setup of an observable subscription after the subscription has emitted an error or completed will now throw in their own call stack. Before it would call `console.warn`. This is potentially breaking in edge cases for node applications, which may be configured to terminate for unhandled exceptions. In the unlikely event this affects you, you can configure the behavior to `console.warn` in the new configuration setting like so: `import { config } from 'rxjs'; config.onUnhandledError = (err) => console.warn(err);` - **RxJS Error types** Tests that are written with naive expectations against errors may fail now that errors have a proper `stack` property. In some testing frameworks, a deep equality check on two error instances will check the values in `stack`, which could be different. - `unsubscribe` no longer available via the `this` context of observer functions. To reenable, set `config.useDeprecatedNextContext = true` on the rxjs `config` found at `import { config } from 'rxjs';`. Note that enabling this will result in a performance penalty for all consumer subscriptions. - Leaked implementation detail `_unsubscribeAndRecycle` of `Subscriber` has been removed. Just use new `Subscription` objects - The static `sortActions` method on `VirtualTimeScheduler` is no longer publicly exposed by our TS types. - `Notification.createNext(undefined)` will no longer return the exact same reference every time. - Type signatures tightened up around `Notification` and `dematerialize`, may uncover issues with invalid types passed to those operators. - Experimental support for `for await` has been removed. Use https://github.com/benlesh/rxjs-for-await instead. - `ReplaySubject` no longer schedules emissions when a scheduler is provided. If you need that behavior, please compose in `observeOn` using `pipe`, for example: `new ReplaySubject(2, 3000).pipe(observeOn(asap))` - **rxjs-compat:** `rxjs/Rx` is no longer a valid import site. ## Operators ### concat - **concat:** Generic signature changed. Recommend not explicitly passing generics, just let inference do its job. If you must, cast with `as`. - **of:** Generic signature changed, do not specify generics, allow them to be inferred or use `as` ### count - **count:** No longer passes `source` observable as a third argument to the predicate. That feature was rarely used, and of limited value. The workaround is to simply close over the source inside of the function if you need to access it in there. ### defer - `defer` no longer allows factories to return `void` or `undefined`. All factories passed to defer must return a proper `ObservableInput`, such as `Observable`, `Promise`, et al. To get the same behavior as you may have relied on previously, `return EMPTY` or `return of()` from the factory. ### map - **map:** `thisArg` will now default to `undefined`. The previous default of `MapSubscriber` never made any sense. This will only affect code that calls map with a `function` and references `this` like so: `source.pipe(map(function () { console.log(this); }))`. There wasn't anything useful about doing this, so the breakage is expected to be very minimal. If anything we're no longer leaking an implementation detail. ### mergeScan - **mergeScan:** `mergeScan` will no longer emit its inner state again upon completion. ### of - **of:** Use with more than 9 arguments, where the last argument is a `SchedulerLike` may result in the wrong type which includes the `SchedulerLike`, even though the run time implementation does not support that. Developers should be using `scheduled` instead ### pairs - **pairs:** `pairs` will no longer function in IE without a polyfill for `Object.entries`. `pairs` itself is also deprecated in favor of users just using `from(Object.entries(obj))`. ### race - **race:** `race()` will no longer subscribe to subsequent observables if a provided source synchronously errors or completes. This means side effects that might have occurred during subscription in those rare cases will no longer occur. ### repeat - An undocumented behavior where passing a negative count argument to `repeat` would result in an observable that repeats forever. ### retry - Removed an undocumented behavior where passing a negative count argument to `retry` would result in an observable that repeats forever. ### single - `single` operator will now throw for scenarios where values coming in are either not present, or do not match the provided predicate. Error types have thrown have also been updated, please check documentation for changes. ### skipLast - **skipLast:** `skipLast` will no longer error when passed a negative number, rather it will simply return the source, as though `0` was passed. ### startWith - **startWith:** `startWith` will return incorrect types when called with more than 7 arguments and a scheduler. Passing scheduler to startWith is deprecated ### take - `take` and will now throw runtime error for arguments that are negative or NaN, this includes non-TS calls like `take()`. ### takeLast - `takeLast` now has runtime assertions that throw `TypeError`s for invalid arguments. Calling takeLast without arguments or with an argument that is `NaN` will throw a `TypeError` ### throwError - **throwError:** In an extreme corner case for usage, `throwError` is no longer able to emit a function as an error directly. If you need to push a function as an error, you will have to use the factory function to return the function like so: `throwError(() => functionToEmit)`, in other words `throwError(() => () => console.log('called later'))`. ### timestamp - `timestamp` operator accepts a `TimestampProvider`, which is any object with a `now` method that returns a number. This means pulling in less code for the use of the `timestamp` operator. This may cause issues with `TestScheduler` run mode. (see [Issue here](https://github.com/ReactiveX/rxjs/issues/5553)) ### zip - **zip:** Zipping a single array will now have a different result. This is an extreme corner-case, because it is very unlikely that anyone would want to zip an array with nothing at all. The workaround would be to wrap the array in another array `zip([[1,2,3]])`. But again, that's pretty weird. - **zip:** `zip` operators will no longer iterate provided iterables "as needed", instead the iterables will be treated as push-streams just like they would be everywhere else in RxJS. This means that passing an endless iterable will result in the thread locking up, as it will endlessly try to read from that iterable. This puts us in-line with all other Rx implementations. To work around this, it is probably best to use `map` or some combination of `map` and `zip`. For example, `zip(source$, iterator)` could be `source$.pipe(map(value => [value, iterator.next().value]))`. ## ajax - `ajax` body serialization will now use default XHR behavior in all cases. If the body is a `Blob`, `ArrayBuffer`, any array buffer view (like a byte sequence, e.g. `Uint8Array`, etc), `FormData`, `URLSearchParams`, `string`, or `ReadableStream`, default handling is use. If the `body` is otherwise `typeof` `"object"`, then it will be converted to JSON via `JSON.stringify`, and the `Content-Type` header will be set to `application/json;charset=utf-8`. All other types will emit an error. - The `Content-Type` header passed to `ajax` configuration no longer has any effect on the serialization behavior of the AJAX request. - For TypeScript users, `AjaxRequest` is no longer the type that should be explicitly used to create an `ajax`. It is now `AjaxConfig`, although the two types are compatible, only `AjaxConfig` has `progressSubscriber` and `createXHR`. - **ajax:** In an extreme corner-case... If an error occurs, the responseType is `"json"`, we're in IE, and the `responseType` is not valid JSON, the `ajax` observable will no longer emit a syntax error, rather it will emit a full `AjaxError` with more details. - **ajax:** Ajax implementation drops support for IE10 and lower. This puts us in-line with other implementations and helps clean up code in this area ================================================ FILE: apps/rxjs.dev/content/deprecations/index.md ================================================ # Deprecations and Breaking Changes While the core team always tries to limit changes, sometimes we have to deprecate APIs or do breaking changes for various reasons. This section aims to describe some of the deprecations and breaking changes we did more in detail. Some of the changes are to extensive to describe them appropriately in a changelog. Additionally, we can provide code examples in the documentation, to make required changes more comprehensible and therefore lower migration efforts. Do notice that this is not a complete list, please see the [changelog](https://github.com/ReactiveX/rxjs/blob/master/CHANGELOG.md) for the complete list. ================================================ FILE: apps/rxjs.dev/content/deprecations/multicasting.md ================================================ # Multicasting In version 7, the multicasting APIs were simplified to just a few functions: - [connectable](/api/index/function/connectable) - [connect](/api/operators/connect) - [share](/api/operators/share) And [shareReplay](/api/operators/shareReplay) - which is a thin wrapper around the now highly-configurable [share](/api/operators/share) operator. Other APIs that relate to multicasting are now deprecated.
These deprecations were introduced in RxJS 7.0 and will become breaking in RxJS 8.
## APIs affected by this Change - [ConnectableObservable](/api/index/class/ConnectableObservable) - [multicast](/api/operators/multicast) - [publish](/api/operators/publish) - [publishBehavior](/api/operators/publishBehavior) - [publishLast](/api/operators/publishLast) - [publishReplay](/api/operators/publishReplay) - [refCount](/api/operators/refCount) ## How to refactor ### ConnectableObservable Instead of creating a [ConnectableObservable](/api/index/class/ConnectableObservable) instance, call the [connectable](/api/index/function/connectable) function to obtain a connectable observable. ```ts import { ConnectableObservable, timer, Subject } from 'rxjs'; // deprecated const tick$ = new ConnectableObservable( timer(1_000), () => new Subject()); tick$.connect(); ``` ```ts import { connectable, timer, Subject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new Subject() }); tick$.connect(); ``` In situations in which the `refCount` method is used, the [share](/api/operators/share) operator can be used instead. ```ts import { ConnectableObservable, timer, Subject } from 'rxjs'; // deprecated const tick$ = new ConnectableObservable( timer(1_000), () => new Subject() ).refCount(); ``` ```ts import { timer, share, Subject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ connector: () => new Subject() }) ); ``` ### multicast Where [multicast](/api/operators/multicast) is called with a subject factory, can be replaced with [connectable](/api/index/function/connectable). ```ts import { timer, multicast, Subject, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( multicast(() => new Subject()) ) as ConnectableObservable; ``` ```ts import { connectable, timer, Subject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new Subject() }); ``` Where [multicast](/api/operators/multicast) is called with a subject instance, it can be replaced with [connectable](/api/index/function/connectable) and a local subject instance. ```ts import { timer, multicast, Subject, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( multicast(new Subject()) ) as ConnectableObservable; ``` ```ts import { connectable, timer, Subject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new Subject(), resetOnDisconnect: false }); ``` Where [multicast](/api/operators/multicast) is used in conjunction with [refCount](/api/operators/refCount), it can be replaced with [share](/api/index/function/connectable). ```ts import { timer, multicast, Subject, refCount } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( multicast(() => new Subject()), refCount() ); ``` ```ts import { timer, share, Subject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ connector: () => new Subject() }) ); ``` Where [multicast](/api/operators/multicast) is used with a selector, it can be replaced with [connect](/api/index/function/connect). ```ts import { timer, multicast, Subject, combineLatest } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( multicast( () => new Subject(), (source) => combineLatest([source, source]) ) ); ``` ```ts import { timer, connect, combineLatest, Subject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( connect((source) => combineLatest([source, source]), { connector: () => new Subject() }) ); ``` ### publish If you're using [publish](/api/operators/publish) to create a [ConnectableObservable](/api/index/class/ConnectableObservable), you can use [connectable](/api/index/function/connectable) instead. ```ts import { timer, publish, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publish() ) as ConnectableObservable; ``` ```ts import { connectable, timer, Subject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new Subject(), resetOnDisconnect: false }); ``` And if [refCount](/api/operators/refCount) is being applied to the result of [publish](/api/operators/publish), you can use [share](/api/operators/share) to replace both. ```ts import { timer, publish, refCount } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publish(), refCount() ); ``` ```ts import { timer, share } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false }) ); ``` If [publish](/api/operators/publish) is being called with a selector, you can use the [connect](/api/operators/connect) operator instead. ```ts import { timer, publish, combineLatest } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publish((source) => combineLatest([source, source])) ); ``` ```ts import { timer, connect, combineLatest } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( connect((source) => combineLatest([source, source])) ); ``` ### publishBehavior If you're using [publishBehavior](/api/operators/publishBehavior) to create a [ConnectableObservable](/api/index/class/ConnectableObservable), you can use [connectable](/api/index/function/connectable) and a [BehaviorSubject](api/index/class/BehaviorSubject) instead. ```ts import { timer, publishBehavior, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishBehavior(0) ) as ConnectableObservable; ``` ```ts import { connectable, timer, BehaviorSubject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new BehaviorSubject(0), resetOnDisconnect: false }); ``` And if [refCount](/api/operators/refCount) is being applied to the result of [publishBehavior](/api/operators/publishBehavior), you can use the [share](/api/operators/share) operator - with a [BehaviorSubject](api/index/class/BehaviorSubject) connector - to replace both. ```ts import { timer, publishBehavior, refCount } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishBehavior(0), refCount() ); ``` ```ts import { timer, share, BehaviorSubject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ connector: () => new BehaviorSubject(0), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false }) ); ``` ### publishLast If you're using [publishLast](/api/operators/publishLast) to create a [ConnectableObservable](/api/index/class/ConnectableObservable), you can use [connectable](/api/index/function/connectable) and an [AsyncSubject](api/index/class/AsyncSubject) instead. ```ts import { timer, publishLast, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishLast() ) as ConnectableObservable; ``` ```ts import { connectable, timer, AsyncSubject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new AsyncSubject(), resetOnDisconnect: false }); ``` And if [refCount](/api/operators/refCount) is being applied to the result of [publishLast](/api/operators/publishLast), you can use the [share](/api/operators/share) operator - with an [AsyncSubject](api/index/class/AsyncSubject) connector - to replace both. ```ts import { timer, publishLast, refCount } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishLast(), refCount() ); ``` ```ts import { timer, share, AsyncSubject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ connector: () => new AsyncSubject(), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false }) ); ``` ### publishReplay If you're using [publishReplay](/api/operators/publishReplay) to create a [ConnectableObservable](/api/index/class/ConnectableObservable), you can use [connectable](/api/index/function/connectable) and a [ReplaySubject](api/index/class/ReplaySubject) instead. ```ts import { timer, publishReplay, ConnectableObservable } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishReplay(1) ) as ConnectableObservable; ``` ```ts import { connectable, timer, ReplaySubject } from 'rxjs'; // suggested refactor const tick$ = connectable(timer(1_000), { connector: () => new ReplaySubject(1), resetOnDisconnect: false }); ``` And if [refCount](/api/operators/refCount) is being applied to the result of [publishReplay](/api/operators/publishReplay), you can use the [share](/api/operators/share) operator - with a [ReplaySubject](api/index/class/ReplaySubject) connector - to replace both. ```ts import { timer, publishReplay, refCount } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishReplay(1), refCount() ); ``` ```ts import { timer, share, ReplaySubject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( share({ connector: () => new ReplaySubject(1), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false }) ); ``` If [publishReplay](/api/operators/publishReplay) is being called with a selector, you can use the [connect](/api/operators/connect) operator - with a [ReplaySubject](api/index/class/ReplaySubject) connector - instead. ```ts import { timer, publishReplay, combineLatest } from 'rxjs'; // deprecated const tick$ = timer(1_000).pipe( publishReplay(1, undefined, (source) => combineLatest([source, source])) ); ``` ```ts import { timer, connect, combineLatest, ReplaySubject } from 'rxjs'; // suggested refactor const tick$ = timer(1_000).pipe( connect((source) => combineLatest([source, source]), { connector: () => new ReplaySubject(1) }) ); ``` ### refCount Instead of applying the [refCount](/api/operators/refCount) operator to the [ConnectableObservable](/api/index/class/ConnectableObservable) obtained from a [multicast](/api/operators/multicast) or [publish](/api/operators/publish) operator, use the [share](/api/operators/share) operator to replace both. The properties passed to [share](/api/operators/share) will depend upon the operators that are being replaced. The refactors for using [refCount](/api/operators/refCount) with [multicast](/api/operators/multicast), [publish](/api/operators/publish), [publishBehavior](/api/operators/publishBehavior), [publishLast](/api/operators/publishLast) and [publishReplay](/api/operators/publishReplay) are detailed above. ================================================ FILE: apps/rxjs.dev/content/deprecations/resultSelector.md ================================================ # ResultSelector Parameter Some operator supported a resultSelector argument that acted as mapping function on the result of that operator. The same behavior can be reproduced with the `map` operator, therefore this argument became deprecated.
This deprecation was introduced in RxJS 6.0 and will become breaking with RxJS 8.
There were two reasons for actually deprecating those parameters: 1. It increases the bundle size of every operator 2. In some scenarios values had to be retained in memory causing a general memory pressure ## Operators affected by this Change - [concatMap](/api/operators/concatMap) - [concatMapTo](/api/operators/concatMapTo) - [exhaustMap](/api/operators/exhaustMap) - [mergeMap](/api/operators/mergeMap) - [mergeMapTo](/api/operators/mergeMapTo) - [switchMap](/api/operators/switchMap) - [switchMapTo](/api/operators/switchMapTo) ## How to Refactor Instead of using the `resultSelector` Argument, you can leverage the [`map`](/api/operators/map) operator on the inner Observable: ```ts import { fromEvent, switchMap, interval, map } from 'rxjs'; // deprecated fromEvent(document, 'click').pipe( switchMap((x) => interval(1000), (_, x) => x + 1) ); // suggested change fromEvent(document, 'click').pipe( switchMap((x) => interval(1000).pipe(map((x) => x + 1))) ); ``` ================================================ FILE: apps/rxjs.dev/content/deprecations/scheduler-argument.md ================================================ # Scheduler Argument To limit the API surface of some operators, but also prepare for a [major refactoring in V8](https://github.com/ReactiveX/rxjs/pull/4583), we agreed on deprecating the `scheduler` argument from many operators. It solely deprecates those methods where this argument is rarely used. So `time` related operators, like [`interval`](https://rxjs.dev/api/index/function/interval) are not affected by this deprecation. To support this transition the [scheduled creation function](/api/index/function/scheduled) was added.
This deprecation was introduced in RxJS 6.5 and will become breaking with RxJS 8.
## Operators affected by this Change - [from](/api/index/function/from) - [of](/api/index/function/of) - [merge](/api/index/function/merge) - [concat](/api/index/function/concat) - [startWith](/api/operators/startWith) - [endWith](/api/operators/endWith) - [combineLatest](/api/index/function/combineLatest) ## How to Refactor If you use any operator from the above list and you're passing the `scheduler` argument, you have three potential refactoring options. ### Refactoring of `of` and `from` `scheduled` is kinda copying the behavior of `from`. Therefore if you used `from` with a `scheduler` argument, you can just replace them. For the `of` creation function you need to replace this Observable with `scheduled` and instead of passing the `scheduler` argument to `of` pass it to `scheduled`. Following code example demonstrate this process. ```ts import { of, asyncScheduler, scheduled } from 'rxjs'; // Deprecated approach of(1, 2, 3, asyncScheduler).subscribe((x) => console.log(x)); // suggested approach scheduled([1, 2, 3], asyncScheduler).subscribe((x) => console.log(x)); ``` ### Refactoring of `merge`, `concat`, `combineLatest`, `startWith` and `endWith` In case you used to pass a scheduler argument to one of these operators you probably had code like this: ```ts import { concat, of, asyncScheduler } from 'rxjs'; concat(of('hello '), of('World'), asyncScheduler).subscribe((x) => console.log(x)); ``` To work around this deprecation you can leverage the [`scheduled`](/api/index/function/scheduled) function. ```ts import { scheduled, of, asyncScheduler, concatAll } from 'rxjs'; scheduled([of('hello '), of('World')], asyncScheduler) .pipe(concatAll()) .subscribe((x) => console.log(x)); ``` You can apply this pattern to refactor deprecated usage of `concat`, `startWith` and `endWith` but do notice that you will want to use [mergeAll](/api/operators/mergeAll) to refactor the deprecated usage of `merge`. With `combineLatest`, you will want to use [combineLatestAll](/api/operators/combineLatestAll) E.g. code that used to look like this: ```ts import { combineLatest, of, asyncScheduler } from 'rxjs'; combineLatest(of('hello '), of('World'), asyncScheduler).subscribe(console.log); ``` would become: ```ts import { scheduled, of, asyncScheduler, combineLatestAll } from 'rxjs'; scheduled([of('hello '), of('World')], asyncScheduler) .pipe(combineLatestAll()) .subscribe((x) => console.log(x)); ``` ================================================ FILE: apps/rxjs.dev/content/deprecations/subscribe-arguments.md ================================================ # Subscribe Arguments You might have seen that we deprecated some signatures of the `subscribe` method, which might have caused some confusion. The `subscribe` method itself is not deprecated. This deprecation also affects the [`tap` operator](../../api/operators/tap), as tap supports the same signature as the `subscribe` method. This is to get ready for a future where we may allow configuration of `subscribe` via the second argument, for things like `AbortSignal` or the like (imagine `source$.subscribe(fn, { signal })`, etc). This deprecation is also because 2-3 function arguments can contribute to harder-to-read code. For example someone could name functions poorly and confuse the next reader: `source$.subscribe(doSomething, doSomethingElse, lol)` With that signature, you have to know unapparent details about `subscribe`, where using a partial observer solves that neatly: `source$.subscribe({ next: doSomething, error: doSomethingElse, complete: lol })`.
This deprecation was introduced in RxJS 6.4.
In short we deprecated all signatures where you specified an anonymous `error` or `complete` callback and passed an empty function to one of the callbacks before. ## What Signature is affected **We have deprecated all signatures of `subscribe` that take more than 1 argument.** We deprecated signatures for just passing the `complete` callback. ```ts import { of } from 'rxjs'; // deprecated of([1,2,3]).subscribe(null, null, console.info); // difficult to read // suggested change of([1,2,3]).subscribe({complete: console.info}); ``` Similarly, we also deprecated signatures for solely passing the `error` callback. ```ts import { throwError } from 'rxjs'; // deprecated throwError('I am an error').subscribe(null, console.error); // suggested change throwError('I am an error').subscribe({error: console.error}); ``` Do notice, in general it is recommended only to use the anonymous function if you only specify the `next` callback otherwise we recommend to pass an `Observer` ```ts import { of } from 'rxjs'; // recommended of([1,2,3]).subscribe((v) => console.info(v)); // also recommended of([1,2,3]).subscribe({ next: (v) => console.log(v), error: (e) => console.error(e), complete: () => console.info('complete') }) ``` ================================================ FILE: apps/rxjs.dev/content/deprecations/to-promise.md ================================================ # Conversion to Promises The similarity between Observables and Promises is that both [collections](/guide/observable) may produce values over time, but the difference is that Observables may produce none or more than one value, while Promises produce only one value when resolved successfully. ## Issues For this reason, in RxJS 7, the return type of the Observable's [`toPromise()`](/api/index/class/Observable#toPromise) method has been fixed to better reflect the fact that Observables can yield zero values. This may be a **breaking change** to some projects as the return type was changed from `Promise` to `Promise`. Also, `toPromise()` method name was never indicating what emitted value a Promise will resolve with because Observables can produce multiple values over time. When converting to a Promise, you might want to choose which value to pick - either the first value that has arrived or the last one. To fix all these issues, we decided to deprecate `toPromise()`, and to introduce the two new helper functions for conversion to Promises. ## Use one of the two new functions As a replacement to the deprecated `toPromise()` method, you should use one of the two built in static conversion functions {@link firstValueFrom} or {@link lastValueFrom}. ### `lastValueFrom` The `lastValueFrom` is almost exactly the same as `toPromise()` meaning that it will resolve with the last value that has arrived when the Observable completes, but with the difference in behavior when Observable completes without emitting a single value. When Observable completes without emitting, `toPromise()` will successfully resolve with `undefined` (thus the return type change), while the `lastValueFrom` will reject with the {@link EmptyError}. Thus, the return type of the `lastValueFrom` is `Promise`, just like `toPromise()` had in RxJS 6. #### Example ```ts import { interval, take, lastValueFrom } from 'rxjs'; async function execute() { const source$ = interval(2000).pipe(take(10)); const finalNumber = await lastValueFrom(source$); console.log(`The final number is ${finalNumber}`); } execute(); // Expected output: // "The final number is 9" ``` ### `firstValueFrom` However, you might want to take the first value as it arrives without waiting an Observable to complete, thus you can use `firstValueFrom`. The `firstValueFrom` will resolve a Promise with the first value that was emitted from the Observable and will immediately unsubscribe to retain resources. The `firstValueFrom` will also reject with an {@link EmptyError} if the Observable completes with no values emitted. #### Example ```ts import { interval, firstValueFrom } from 'rxjs'; async function execute() { const source$ = interval(2000); const firstNumber = await firstValueFrom(source$); console.log(`The first number is ${firstNumber}`); } execute(); // Expected output: // "The first number is 0" ``` Both functions will return a Promise that rejects if the source Observable errors. The Promise will reject with the same error that the Observable has errored with. ## Use default value If you don't want Promises created by `lastValueFrom` or `firstValueFrom` to reject with {@link EmptyError} if there were no emissions before completion, you can use the second parameter. The second parameter is expected to be an object with `defaultValue` parameter. The value in the `defaultValue` will be used to resolve a Promise when source Observable completes without emitted values. ```ts import { firstValueFrom, EMPTY } from 'rxjs'; const result = await firstValueFrom(EMPTY, { defaultValue: 0 }); console.log(result); // Expected output: // 0 ``` ## Warning Only use `lastValueFrom` function if you _know_ an Observable will eventually complete. The `firstValueFrom` function should be used if you _know_ an Observable will emit at least one value _or_ will eventually complete. If the source Observable does not complete or emit, you will end up with a Promise that is hung up, and potentially all of the state of an async function hanging out in memory. To avoid this situation, look into adding something like {@link timeout}, {@link take}, {@link takeWhile}, or {@link takeUntil} amongst others. ================================================ FILE: apps/rxjs.dev/content/file-not-found.md ================================================ @description

Page Not Found

We're sorry. The page you are looking for cannot be found.

================================================ FILE: apps/rxjs.dev/content/guide/core-semantics.md ================================================ # RxJS Core Semantics Starting in version 8, all RxJS operators that are provided in the core library MUST meet the following semantics. In the current version, version 7, all operators SHOULD meet the following semantics (as guidelines). If they do not, we need to track the issue on [GitHub](https://github.com/ReactiveX/rxjs/issues). ## Purpose The purpose of these semantics is provide predictable behavior for the users of our library, and to ensure consistent behavior between our many different operators. It should be noted that at the time of this writing, we don't always adhere to these semantic guidelines. This document is to serve as a goalpost for upcoming changes and work as much as it is to help describe the library. This is also a "living document" and is subject to change. ## General Design Guidelines **Functions such as operators, constructors, and creation functions, should use named parameters in cases where there is more than 1 argument, and arguments after the first are non-obvious.** The primary use case should be streamlined to work without configuration. For example, `fakeFlattenMap(n => of(n))` is fine, but `fakeFlattenMap(n => of(n), 1)` is less readable than `fakeFlattenMap(n => of(n), { maxConcurrent: 1 })`. Other things, like `of(1, 2, 3)` are obvious enough that named parameters don't make sense. ## Operators - MUST be a function that returns an [operator function](https://rxjs.dev/api/index/interface/OperatorFunction). That is `(source: Observable) => Observable`. - The returned operator function MUST be [referentially transparent](https://en.wikipedia.org/wiki/Referential_transparency). That is to say, that if you capture the return value of the operator (e.g. `const double => map(x => x + x)`), you can use that value to operate on any many observables as you like without changing any underlying state in the operator reference. (e.g. `a$.pipe(double)` and `b$.pipe(double)`). - The observable returned by the operator function MUST subscribe to the source. - If the operation performed by the operator can tell it not change anything about the output of the source, it MUST return the reference to the source. For example `take(Infinity)` or `skip(0)`. - Operators that accept a "notifier", that is another observable source that is used to trigger some behavior, must accept any type that can be converted to an `Observable` with `from`. For example `takeUntil`. - Operators that accept "notifiers" (as described above), MUST ONLY recognized next values from the notifier as "notifications". Emitted completions may not be used a source of notification. - "Notifiers" provided directly to the operator MUST be subscribed to _before_ the source is subscribed to. "Notifiers" created via factory function provided to the operator SHOULD be subscribed to at the earliest possible moment. - The observable returned by the operator function is considered to be the "consumer" of the source. As such, the consumer MUST unsubscribe from the source as soon as it knows it no longer needs values before proceeding to do _any_ action. - Events that happen after the completion of a source SHOULD happen after the source finalizes. This is to ensure that finalization always happens in a predictable time frame relative to the event. - `Error` objects MUST NOT be retained longer than necessary. This is a possible source of memory pressure. - `Promise` references MUST NOT be retained longer than necessary. This is a possible source of memory pressure. - IF they perform a related operation to a creation function, they SHOULD share the creation function's name only with the suffix `With`. (e.g. `concat` and `concatWith`). - SHOULD NOT have "result selectors". This is a secondary argument that provides the ability to "map" values after performing the primary operation of the operator. ## Creation Functions - Names MUST NOT end in `With`. That is reserved for the operator counter parts of creation functions. - MAY have "result selectors". This is a secondary argument that provides the ability to "map" values before they're emitted from the resulting observable. - IF the creation function accepts a "result selector", it must not accept "n-arguments" ahead of that result selector. Instead, it should accept an array or possibly an object. (bad: `combineThings(sourceA$, sourceB$, (a, b) => a + b)`, good: `combineThings([sourceA$, sourceB$], (a, b) => a + b)`. In this case, it may be okay to provide the result selector as a second argument, rather than as a named parameter, as the use should be fairly obvious. ================================================ FILE: apps/rxjs.dev/content/guide/glossary-and-semantics.md ================================================ # RxJS: Glossary And Semantics When discussing and documenting observables, it's important to have a common language and a known set of rules around what is going on. This document is an attempt to standardize these things so we can try to control the language in our docs, and hopefully other publications about RxJS, so we can discuss reactive programming with RxJS on consistent terms. While not all of the documentation for RxJS reflects this terminology, it is a goal of the team to ensure it does, and to ensure the language and names around the library use this document as a source of truth and unified language. ## Major Entities There are high level entities that are frequently discussed. It's important to define them separately from other lower-level concepts, because they relate to the nature of observable. ### Consumer The code that is subscribing to the observable. This is whoever is being _notified_ of [nexted](#next) values, and [errors](#error) or [completions](#complete). ### Producer Any system or thing that is the source of values that are being pushed out of the observable subscription to the consumer. This can be a wide variety of things, from a `WebSocket` to a simple iteration over an `Array`. The producer is most often created during the [subscribe](#subscribe) action, and therefor "owned" by a [subscription](#subscription) in a 1:1 way, but that is not always the case. A producer may be shared between many subscriptions, if it is created outside of the [subscribe](#subscribe) action, in which case it is one-to-many, resulting in a [multicast](#multicast). ### Subscription A contract where a [consumer](#consumer) is [observing](#observation) values pushed by a [producer](#producer). The subscription (not to be confused with the `Subscription` class or type), is an ongoing process that amounts to the function of the observable from the Consumer's perspective. Subscription starts the moment a [subscribe](#subscribe) action is initiated, even before the [subscribe](#subscribe) action is finished. ### Observable The primary type in RxJS. At its highest level, an observable represents a template for connecting an [Observer](#observer), as a [consumer](#consumer), to a [producer](#producer), via a [subscribe](#subscribe) action, resulting in a [subscription](#subscription). ### Observer The manifestation of a [consumer](#consumer). A type that may have some (or all) handlers for each type of [notification](#notification): [next](#next), [error](#error), and [complete](#complete). Having all three types of handlers generally gets this to be called an "observer", where if it is missing any of the notification handlers, it may be called a ["partial observer"](#partial-observer). ## Major Actions There are specific actions and events that occur between major entities in RxJS that need to be defined. These major actions are the highest level events that occur within various parts in RxJS. ### Subscribe The act of a [consumer](#consumer) requesting from an Observable to set up a [subscription](#subscription) so that it may [observe](#observation) a [producer](#producer). A subscribe action can occur with an observable via many different mechanisms. The primary mechanism is the [`subscribe` method](/api/index/class/Observable#subscribe) on the [Observable class](/api/index/class/Observable). Other mechanisms include the [`forEach` method](/api/index/class/Observable#forEach), functions like [`lastValueFrom`](/api/index/function/lastValueFrom), and [`firstValueFrom`](/api/index/function/firstValueFrom), and the deprecated [`toPromise` method](/api/index/class/Observable#forEach). ### Finalization The act of cleaning up resources used by a producer. This is guaranteed to happen on `error`, `complete`, or if unsubscription occurs. This is not to be confused with [unsubscription](#unsubscription), but it does always happen during unsubscription. ### Unsubscription The act of a [consumer](#consumer) telling a [producer](#producer) is no longer interested in receiving values. Causes [Finalization](#finalization) ### Observation A [consumer](#consumer) reacting to [next](#next), [error](#error), or [complete](#complete) [notifications](#notification). This can only happen _during_ [subscription](#subscription). ### Observation Chain When an [observable](#observable) uses another [observable](#observable) as a [producer](#producer), an "observation chain" is set up. That is a chain of [observation](#observation) such that multiple [observers](#observer) are [notifying](#notification) each other in a unidirectional way toward the final [consumer](#consumer). ### Next A value has been pushed to the [consumer](#consumer) to be [observed](#observation). Will only happen during [subscription](#subscription), and cannot happen after [error](#error), [complete](#error), or [unsubscription](#unsubscription). Logically, this also means it cannot happen after [finalization](#finalization). ### Error The [producer](#producer) has encountered a problem and is notifying the [consumer](#consumer). This is a notification that the [producer](#producer) will no longer send values and will [finalize](#finalization). This cannot occur after [complete](#complete), any other [error](#error), or [unsubscription](#unsubscription). Logically, this also means it cannot happen after [finalization](#finalization). ### Complete The [producer](#producer) is notifying the [consumer](#consumer) that it is done [nexting](#Next) values, without error, will send no more values, and it will [finalize](#finalization). [Completion](#complete) cannot occur after an [error](#error), or [unsubscribe](#unsubscription). [Complete](#complete) cannot be called twice. [Complete](#complete), if it occurs, will always happen before [finalization](#finalization). ### Notification The act of a [producer](#producer) pushing [nexted](#next) values, [errors](#error) or [completions](#complete) to a [consumer](#consumer) to be [observed](#observation). Not to be confused with the [`Notification` type](/api/index/class/Notification), which is notification manifested as a JavaScript object. ## Major Concepts Some of what we discuss is conceptual. These are mostly common traits of behaviors that can manifest in observables or in push-based reactive systems. ### Multicast The act of one [producer](#producer) being [observed](#observation) by **many** [consumers](#consumer). ### Unicast The act of one [producer](#producer) being [observed](#observation) by **only one** [consumer](#consumer). An observable is "unicast" when it only connects one [producer](#producer) to one [consumer](#consumer). Unicast doesn't necessarily mean ["cold"](#cold). ### Cold An observable is "cold" when it creates a new [producer](#producer) during [subscribe](#subscribe) for every new [subscription](#subscription). As a result, "cold" observables are _always_ [unicast](#unicast), being one [producer](#producer) [observed](#observation) by one [consumer](#consumer). Cold observables can be made [hot](#hot) but not the other way around. ### Hot An observable is "hot", when its [producer](#producer) was created outside of the context of the [subscribe](#subscribe) action. This means that the "hot" observable is almost always [multicast](#multicast). It is possible that a "hot" observable is still _technically_ unicast, if it is engineered to only allow one [subscription](#subscription) at a time, however, there is no straightforward mechanism for this in RxJS, and the scenario is an unlikely one. For the purposes of discussion, all "hot" observables can be assumed to be [multicast](#multicast). Hot observables cannot be made [cold](#cold). ### Push [Observables](#observable) are a push-based type. That means rather than having the [consumer](#consumer) call a function or perform some other action to get a value, the [consumer](#consumer) receives values as soon as the [producer](#producer) has produced them, via a registered [next](#next) handler. ### Pull Pull-based systems are the opposite of [push](#push)-based. In a pull-based type or system, the [consumer](#consumer) must request each value the [producer](#producer) has produced manually, perhaps long after the [producer](#producer) has actually done so. Examples of such systems are [Functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) and [Iterators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) ## Minor Entities ### Operator A factory function that creates an [operator function](#operator-function). Examples of this in rxjs are functions like [`map`](/api/operators/map) and [`mergeMap`](/api/operators/mergeMap), which are generally passed to [`pipe`](/api/index/class/Observable#pipe). The result of calling many operators, and passing their resulting [operator functions](#operator-function) into pipe on an observable [source](#source) will be another [observable](#observable), and will generally not result in [subscription](#subscription). ### Operator Function A function that takes an [observable](#observable), and maps it to a new [observable](#observable). Nothing more, nothing less. Operator functions are created by [operators](#operator). If you were to call an rxjs operator like [map](/api/operators/map) and put the return value in a variable, the returned value would be an operator function. ### Operation An action taken while handling a [notification](#notification), as set up by an [operator](#operator) and/or [operator function](#operator-function). In RxJS, a developer can chain several [operator functions](#operator-function) together by calling [operators](#operator) and passing the created [operator functions](#operator-function) to the [`pipe`](/api/index/class/Observable#pipe) method of [`Observable`](/api/index/class/Observable), which results in a new [observable](#observable). During [subscription](#subscription) to that observable, operations are performed in an order dictated by the [observation chain](#observation-chain). ### Stream A "stream" or "streaming" in the case of observables, refers to the collection of [operations](#operation), as they are processed during a [subscription](#subscription). This is not to be confused with node [Streams](https://nodejs.org/api/stream.html), and the word "stream", on its own, should be used _sparingly_ in documentation and articles. Instead, prefer [observation chain](#observation-chain), [operations](#operation), or [subscription](#subscription). "Streaming" is less ambiguous, and is fine to use given this defined meaning. ### Source An [observable](#observable) or [valid observable input](#observable-inputs) having been converted to an observable, that will supply values to another [observable](#observable), either as the result of an [operator](#operator) or other function that creates one observable as another. This [source](#source), will be the [producer](#producer) for the resulting [observable](#observable) and all of its [subscriptions](#subscriptions). Sources may generally be any type of observable. ### Observable Inputs An "observable input" ([defined as a type here](/api/index/type-alias/ObservableInput)), is any type that can be easily converted to an [Observable](#observable). Observable Inputs may sometimes be referred to as "valid observable sources". ### Notifier An [observable](#observable) that is being used to notify another [observable](#observable) that it needs to perform some action. The action should only occur on a [next notification](#next), and never on [error](#error) or [complete](#complete). Generally, notifiers are used with specific operators, such as [`takeUntil`](/api/operators/takeUntil), [`buffer`](/api/operators/buffer), or [`delayWhen`](/api/operators/delayWhen). A notifier may be passed directly, or it may be returned by a callback. ### Inner Source One, of possibly many [sources](#source), which are [subscribed](#subscribe) to automatically within a single [subscription](#subscription) to another observable. Examples of an "inner source" include the [observable inputs](#observable-inputs) returned by the mapping function in a [mergeMap](/api/operators/mergeMap) [operator](#operator). (e.g. `source.pipe(mergeMap(value => createInnerSource(value)))`, where `createInnerSource` returns any valid [observable input](#observable-inputs)). ### Partial Observer An [observer](#observer) that lacks all necessary [notification](#notification) handlers. Generally these are supplied by user-land [consumer](#consumer) code. A "full observer" or "observer" would simply be an observer that has all [notification](#notification) handlers. ## Other Concepts ### Unhandled Errors An "unhandled error" is any [error](#error) that is not handled by a [consumer](#consumer)-provided function, which is generally provided during the [subscribe](#subscribe) action. If no error handler was provided, RxJS will assume the error is "unhandled" and rethrow the error on a new callstack to prevent ["producer interference"](#producer-interference). ### Producer Interference [Producer](#producer) interference happens when an error is allowed to unwind the RxJS callstack during [notification](#notification). When this happens, the error could break things like for-loops in [upstream](#upstream-and-downstream) [sources](#source) that are [notifying](#notification) [consumers](#consumer) during a [multicast](#multicast). That would cause the other [consumers](#consumer) in that [multicast](#multicast) to suddenly stop receiving values without logical explanation. As of version 6, RxJS goes out of its way to prevent producer interference by ensuring that all unhandled errors are thrown on a separate callstack. ### Upstream And Downstream The order in which [notifications](#notification) are processed by [operations](#operation) in a [stream](#stream) have a directionality to them. "Upstream" refers to an [operation](#operation) that was already processed before the current [operation](#operation), and "downstream" refers to an [operation](#operation) that _will_ be processed _after_ the current [operation](#operation). See also: [Streaming](#stream). ================================================ FILE: apps/rxjs.dev/content/guide/higher-order-observables.md ================================================ # Higher-order Observables Observables most commonly emit ordinary values like strings and numbers, but surprisingly often, it is necessary to handle Observables *of* Observables, so-called higher-order Observables. For example, imagine you have an Observable emitting strings that are the URLs of files you want to fetch. The code might look like this: ```ts const fileObservable = urlObservable.pipe( map(url => http.get(url)), ); ``` `http.get()` returns an Observable for each URL. Now you have an Observable *of* Observables, a higher-order Observable. But how do you work with a higher-order Observable? Typically, by _flattening_: by converting a higher-order Observable into an ordinary Observable. For example: ```ts const fileObservable = urlObservable.pipe( concatMap(url => http.get(url)), ); ``` The Observable returned in the `concatMap` function is usually referred to as a so-called "inner" Observable, while in this context the `urlObservable` is the so-called "outer" Observable. The [`concatMap()`](/api/operators/concatMap) operator subscribes to each "inner" Observable, buffers all further emissions of the "outer" Observable, and copies all the emitted values until the inner Observable completes, and continues processing the values of the "outer Observable". All of the values are in that way concatenated. Other useful flattening operators are * [`mergeMap()`](/api/operators/mergeMap) — subscribes to each inner Observable as it arrives, then emits each value as it arrives * [`switchMap()`](/api/operators/switchMap) — subscribes to the first inner Observable when it arrives, and emits each value as it arrives, but when the next inner Observable arrives, unsubscribes to the previous one, and subscribes to the new one. * [`exhaustMap()`](/api/operators/exhaustMap) — subscribes to the first inner Observable when it arrives, and emits each value as it arrives, discarding all newly arriving inner Observables until that first one completes, then waits for the next inner Observable. ================================================ FILE: apps/rxjs.dev/content/guide/importing.md ================================================ # Importing instructions There are different ways you can {@link guide/installation install} RxJS. Using/importing RxJS depends on the used RxJS version, but also depends on the used installation method. [Pipeable operators](https://v6.rxjs.dev/guide/v6/pipeable-operators) were introduced in RxJS version 5.5. This enabled all operators to be exported from a single place. This new export site was introduced with RxJS version 6 where all pipeable operators could have been imported from `'rxjs/operators'`. For example, `import { map } from 'rxjs/operators'`. ## New in RxJS v7.2.0 **With RxJS v7.2.0, most operators have been moved to `{@link api#index 'rxjs'}` export site. This means that the preferred way to import operators is from `'rxjs'`, while `'rxjs/operators'` export site has been deprecated.** For example, instead of using: ```ts import { map } from 'rxjs/operators'; ``` **the preferred way** is to use: ```ts import { map } from 'rxjs'; ``` Although the old way of importing operators is still active, it will be removed in one of the next major versions. Click {@link #how-to-migrate here to see} how to migrate. ## Export sites RxJS v7 exports 6 different locations out of which you can import what you need. Those are: - `{@link api#index 'rxjs'}` - for example: `import { of } from 'rxjs';` - `{@link api#operators 'rxjs/operators'}` - for example: `import { map } from 'rxjs/operators';` - `{@link api#ajax 'rxjs/ajax'}` - for example: `import { ajax } from 'rxjs/ajax';` - `{@link api#fetch 'rxjs/fetch'}` - for example: `import { fromFetch } from 'rxjs/fetch';` - `{@link api#webSocket 'rxjs/webSocket'}` - for example: `import { webSocket } from 'rxjs/webSocket';` - `{@link api#testing 'rxjs/testing'}` - for example: `import { TestScheduler } from 'rxjs/testing';` ### How to migrate? While nothing has been removed from `'rxjs/operators'`, it is strongly recommended doing the operator imports from `'rxjs'`. Almost all operator function exports have been moved to `'rxjs'`, but only a couple of old and deprecated operators have stayed in the `'rxjs/operators'`. Those operator functions are now mostly deprecated and most of them have their either static operator substitution or are kept as operators, but have a new name so that they are different to their static creation counter-part (usually ending with `With`). Those are: | `'rxjs/operators'` Operator | Replace With Static Creation Operator | Replace With New Operator Name | | ------------------------------------------------------- | ------------------------------------- | ------------------------------ | | [`combineLatest`](/api/operators/combineLatest) | {@link combineLatest} | {@link combineLatestWith} | | [`concat`](/api/operators/concat) | {@link concat} | {@link concatWith} | | [`merge`](/api/operators/merge) | {@link merge} | {@link mergeWith} | | [`onErrorResumeNext`](/api/operators/onErrorResumeNext) | {@link onErrorResumeNext} | {@link onErrorResumeNextWith} | | [`race`](/api/operators/race) | {@link race} | {@link raceWith} | | [`zip`](/api/operators/zip) | {@link zip} | {@link zipWith} | `partition`, the operator, is a special case, as it is deprecated and you should be using the `partition` creation function exported from `'rxjs'` instead. For example, the old and deprecated way of using [`merge`](/api/operators/merge) from `'rxjs/operators'` is: ```ts import { merge } from 'rxjs/operators'; a$.pipe(merge(b$)).subscribe(); ``` But this should be avoided and replaced with one of the next two examples. For example, this could be replaced by using a static creation {@link merge} function: ```ts import { merge } from 'rxjs'; merge(a$, b$).subscribe(); ``` Or it could be written using a pipeable {@link mergeWith} operator: ```ts import { mergeWith } from 'rxjs'; a$.pipe(mergeWith(b$)).subscribe(); ``` Depending on the preferred style, you can choose which one to follow, they are completely equal. Since a new way of importing operators is introduced with RxJS v7.2.0, instructions will be split to prior and after this version. ### ES6 via npm If you've installed RxJS using {@link guide/installation#es6-via-npm ES6 via npm} and installed version is: #### v7.2.0 or later Import only what you need: ```ts import { of, map } from 'rxjs'; of(1, 2, 3).pipe(map((x) => x + '!!!')); // etc ``` To import the entire set of functionality: ```ts import * as rxjs from 'rxjs'; rxjs.of(1, 2, 3).pipe(rxjs.map((x) => x + '!!!')); // etc; ``` To use with a globally imported bundle: ```js const { of, map } = rxjs; of(1, 2, 3).pipe(map((x) => x + '!!!')); // etc ``` If you installed RxJS version: #### v7.1.0 or older Import only what you need: ```ts import { of } from 'rxjs'; import { map } from 'rxjs/operators'; of(1, 2, 3).pipe(map((x) => x + '!!!')); // etc ``` To import the entire set of functionality: ```ts import * as rxjs from 'rxjs'; import * as operators from 'rxjs'; rxjs.of(1, 2, 3).pipe(operators.map((x) => x + '!!!')); // etc; ``` To use with a globally imported bundle: ```js const { of } = rxjs; const { map } = rxjs.operators; of(1, 2, 3).pipe(map((x) => x + '!!!')); // etc ``` ### CDN If you installed a library {@link guide/installation#cdn using CDN}, the global namespace for rxjs is `rxjs`. #### v7.2.0 or later ```js const { range, filter, map } = rxjs; range(1, 200) .pipe( filter((x) => x % 2 === 1), map((x) => x + x) ) .subscribe((x) => console.log(x)); ``` #### v7.1.0 or older ```js const { range } = rxjs; const { filter, map } = rxjs.operators; range(1, 200) .pipe( filter((x) => x % 2 === 1), map((x) => x + x) ) .subscribe((x) => console.log(x)); ``` ================================================ FILE: apps/rxjs.dev/content/guide/installation.md ================================================ # Installation Instructions Here are different ways you can install RxJS: ## ES2015 via npm ```shell npm install rxjs ``` By default, RxJS 7.x will provide different variants of the code based on the consumer: - When RxJS 7.x is used on Node.js regardless of whether it is consumed via `require` or `import`, CommonJS code targeting ES5 will be provided for execution. - When RxJS 7.4+ is used via a bundler targeting a browser (or other non-Node.js platform) ES module code targeting ES5 will be provided by default with the option to use ES2015 code. 7.x versions prior to 7.4.0 will only provide ES5 code. If the target browsers for a project support ES2015+ or the bundle process supports down-leveling to ES5 then the bundler can optionally be configured to allow the ES2015 RxJS code to be used instead. You can enable support for using the ES2015 RxJS code by configuring a bundler to use the `es2015` custom export condition during module resolution. Configuring a bundler to use the `es2015` custom export condition is specific to each bundler. If you are interested in using this option, please consult the documentation of your bundler for additional information. However, some general information can be found here: - https://webpack.js.org/guides/package-exports/#conditions-custom - https://github.com/rollup/plugins/blob/node-resolve-v11.0.0/packages/node-resolve/README.md#exportconditions To import only what you need, please {@link guide/importing#es6-via-npm check out this} guide. ## CommonJS via npm If you receive an error like error TS2304: Cannot find name 'Promise' or error TS2304: Cannot find name 'Iterable' when using RxJS you may need to install a supplemental set of typings. 1. For typings users: ```shell typings install es6-shim --ambient ``` 2. If you're not using typings the interfaces can be copied from /es6-shim/es6-shim.d.ts. 3. Add type definition file included in tsconfig.json or CLI argument. ## All Module Types (CJS/ES6/AMD/TypeScript) via npm To install this library via npm version 3, use the following command: ```shell npm install @reactivex/rxjs ``` If you are using npm version 2, you need to specify the library version explicitly: ```shell npm install @reactivex/rxjs@7.3.0 ``` ================================================ FILE: apps/rxjs.dev/content/guide/observable.md ================================================ # Observable Observables are lazy Push collections of multiple values. They fill the missing spot in the following table: | | Single | Multiple | | -------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | **Pull** | [`Function`](https://developer.mozilla.org/en-US/docs/Glossary/Function) | [`Iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) | | **Push** | [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) | [`Observable`](/api/index/class/Observable) | **Example.** The following is an Observable that pushes the values `1`, `2`, `3` immediately (synchronously) when subscribed, and the value `4` after one second has passed since the subscribe call, then completes: ```ts import { Observable } from 'rxjs'; const observable = new Observable((subscriber) => { subscriber.next(1); subscriber.next(2); subscriber.next(3); setTimeout(() => { subscriber.next(4); subscriber.complete(); }, 1000); }); ``` To invoke the Observable and see these values, we need to _subscribe_ to it: ```ts import { Observable } from 'rxjs'; const observable = new Observable((subscriber) => { subscriber.next(1); subscriber.next(2); subscriber.next(3); setTimeout(() => { subscriber.next(4); subscriber.complete(); }, 1000); }); console.log('just before subscribe'); observable.subscribe({ next(x) { console.log('got value ' + x); }, error(err) { console.error('something wrong occurred: ' + err); }, complete() { console.log('done'); }, }); console.log('just after subscribe'); ``` Which executes as such on the console: ```none just before subscribe got value 1 got value 2 got value 3 just after subscribe got value 4 done ``` ## Pull versus Push _Pull_ and _Push_ are two different protocols that describe how a data _Producer_ can communicate with a data _Consumer_. **What is Pull?** In Pull systems, the Consumer determines when it receives data from the data Producer. The Producer itself is unaware of when the data will be delivered to the Consumer. Every JavaScript Function is a Pull system. The function is a Producer of data, and the code that calls the function is consuming it by "pulling" out a _single_ return value from its call. ES2015 introduced [generator functions and iterators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) (`function*`), another type of Pull system. Code that calls `iterator.next()` is the Consumer, "pulling" out _multiple_ values from the iterator (the Producer). | | Producer | Consumer | | -------- | ------------------------------------------ | ------------------------------------------- | | **Pull** | **Passive:** produces data when requested. | **Active:** decides when data is requested. | | **Push** | **Active:** produces data at its own pace. | **Passive:** reacts to received data. | **What is Push?** In Push systems, the Producer determines when to send data to the Consumer. The Consumer is unaware of when it will receive that data. Promises are the most common type of Push system in JavaScript today. A Promise (the Producer) delivers a resolved value to registered callbacks (the Consumers), but unlike functions, it is the Promise which is in charge of determining precisely when that value is "pushed" to the callbacks. RxJS introduces Observables, a new Push system for JavaScript. An Observable is a Producer of multiple values, "pushing" them to Observers (Consumers). - A **Function** is a lazily evaluated computation that synchronously returns a single value on invocation. - A **generator** is a lazily evaluated computation that synchronously returns zero to (potentially) infinite values on iteration. - A **Promise** is a computation that may (or may not) eventually return a single value. - An **Observable** is a lazily evaluated computation that can synchronously or asynchronously return zero to (potentially) infinite values from the time it's invoked onwards. For more info about what to use when converting Observables to Promises, please refer to [this guide](/deprecations/to-promise). ## Observables as generalizations of functions Contrary to popular claims, Observables are not like EventEmitters nor are they like Promises for multiple values. Observables _may act_ like EventEmitters in some cases, namely when they are multicasted using RxJS Subjects, but usually they don't act like EventEmitters. Observables are like functions with zero arguments, but generalize those to allow multiple values. Consider the following: ```ts function foo() { console.log('Hello'); return 42; } const x = foo.call(); // same as foo() console.log(x); const y = foo.call(); // same as foo() console.log(y); ``` We expect to see as output: ```none "Hello" 42 "Hello" 42 ``` You can write the same behavior above, but with Observables: ```ts import { Observable } from 'rxjs'; const foo = new Observable((subscriber) => { console.log('Hello'); subscriber.next(42); }); foo.subscribe((x) => { console.log(x); }); foo.subscribe((y) => { console.log(y); }); ``` And the output is the same: ```none "Hello" 42 "Hello" 42 ``` This happens because both functions and Observables are lazy computations. If you don't call the function, the `console.log('Hello')` won't happen. Also with Observables, if you don't "call" it (with `subscribe`), the `console.log('Hello')` won't happen. Plus, "calling" or "subscribing" is an isolated operation: two function calls trigger two separate side effects, and two Observable subscribes trigger two separate side effects. As opposed to EventEmitters which share the side effects and have eager execution regardless of the existence of subscribers, Observables have no shared execution and are lazy. Subscribing to an Observable is analogous to calling a Function. Some people claim that Observables are asynchronous. That is not true. If you surround a function call with logs, like this: ```ts console.log('before'); console.log(foo.call()); console.log('after'); ``` You will see the output: ```none "before" "Hello" 42 "after" ``` And this is the same behavior with Observables: ```ts console.log('before'); foo.subscribe((x) => { console.log(x); }); console.log('after'); ``` And the output is: ```none "before" "Hello" 42 "after" ``` Which proves the subscription of `foo` was entirely synchronous, just like a function. Observables are able to deliver values either synchronously or asynchronously. What is the difference between an Observable and a function? **Observables can "return" multiple values over time**, something which functions cannot. You can't do this: ```ts function foo() { console.log('Hello'); return 42; return 100; // dead code. will never happen } ``` Functions can only return one value. Observables, however, can do this: ```ts import { Observable } from 'rxjs'; const foo = new Observable((subscriber) => { console.log('Hello'); subscriber.next(42); subscriber.next(100); // "return" another value subscriber.next(200); // "return" yet another }); console.log('before'); foo.subscribe((x) => { console.log(x); }); console.log('after'); ``` With synchronous output: ```none "before" "Hello" 42 100 200 "after" ``` But you can also "return" values asynchronously: ```ts import { Observable } from 'rxjs'; const foo = new Observable((subscriber) => { console.log('Hello'); subscriber.next(42); subscriber.next(100); subscriber.next(200); setTimeout(() => { subscriber.next(300); // happens asynchronously }, 1000); }); console.log('before'); foo.subscribe((x) => { console.log(x); }); console.log('after'); ``` With output: ```none "before" "Hello" 42 100 200 "after" 300 ``` Conclusion: - `func.call()` means "_give me one value synchronously_" - `observable.subscribe()` means "_give me any amount of values, either synchronously or asynchronously_" ## Anatomy of an Observable Observables are **created** using `new Observable` or a creation operator, are **subscribed** to with an Observer, **execute** to deliver `next` / `error` / `complete` notifications to the Observer, and their execution may be **disposed**. These four aspects are all encoded in an Observable instance, but some of these aspects are related to other types, like Observer and Subscription. Core Observable concerns: - **Creating** Observables - **Subscribing** to Observables - **Executing** the Observable - **Disposing** Observables ### Creating Observables The `Observable` constructor takes one argument: the `subscribe` function. The following example creates an Observable to emit the string `'hi'` every second to a subscriber. ```ts import { Observable } from 'rxjs'; const observable = new Observable(function subscribe(subscriber) { const id = setInterval(() => { subscriber.next('hi'); }, 1000); }); ``` Observables can be created with `new Observable`. Most commonly, observables are created using creation functions, like `of`, `from`, `interval`, etc. In the example above, the `subscribe` function is the most important piece to describe the Observable. Let's look at what subscribing means. ### Subscribing to Observables The Observable `observable` in the example can be _subscribed_ to, like this: ```ts observable.subscribe((x) => console.log(x)); ``` It is not a coincidence that `observable.subscribe` and `subscribe` in `new Observable(function subscribe(subscriber) {...})` have the same name. In the library, they are different, but for practical purposes you can consider them conceptually equal. This shows how `subscribe` calls are not shared among multiple Observers of the same Observable. When calling `observable.subscribe` with an Observer, the function `subscribe` in `new Observable(function subscribe(subscriber) {...})` is run for that given subscriber. Each call to `observable.subscribe` triggers its own independent setup for that given subscriber. Subscribing to an Observable is like calling a function, providing callbacks where the data will be delivered to. This is drastically different to event handler APIs like `addEventListener` / `removeEventListener`. With `observable.subscribe`, the given Observer is not registered as a listener in the Observable. The Observable does not even maintain a list of attached Observers. A `subscribe` call is simply a way to start an "Observable execution" and deliver values or events to an Observer of that execution. ### Executing Observables The code inside `new Observable(function subscribe(subscriber) {...})` represents an "Observable execution", a lazy computation that only happens for each Observer that subscribes. The execution produces multiple values over time, either synchronously or asynchronously. There are three types of values an Observable Execution can deliver: - "Next" notification: sends a value such as a Number, a String, an Object, etc. - "Error" notification: sends a JavaScript Error or exception. - "Complete" notification: does not send a value. "Next" notifications are the most important and most common type: they represent actual data being delivered to a subscriber. "Error" and "Complete" notifications may happen only once during the Observable Execution, and there can only be either one of them. These constraints are expressed best in the so-called _Observable Grammar_ or _Contract_, written as a regular expression: ```none next*(error|complete)? ``` In an Observable Execution, zero to infinite Next notifications may be delivered. If either an Error or Complete notification is delivered, then nothing else can be delivered afterwards. The following is an example of an Observable execution that delivers three Next notifications, then completes: ```ts import { Observable } from 'rxjs'; const observable = new Observable(function subscribe(subscriber) { subscriber.next(1); subscriber.next(2); subscriber.next(3); subscriber.complete(); }); ``` Observables strictly adhere to the Observable Contract, so the following code would not deliver the Next notification `4`: ```ts import { Observable } from 'rxjs'; const observable = new Observable(function subscribe(subscriber) { subscriber.next(1); subscriber.next(2); subscriber.next(3); subscriber.complete(); subscriber.next(4); // Is not delivered because it would violate the contract }); ``` It is a good idea to wrap any code in `subscribe` with `try`/`catch` block that will deliver an Error notification if it catches an exception: ```ts import { Observable } from 'rxjs'; const observable = new Observable(function subscribe(subscriber) { try { subscriber.next(1); subscriber.next(2); subscriber.next(3); subscriber.complete(); } catch (err) { subscriber.error(err); // delivers an error if it caught one } }); ``` ### Disposing Observable Executions Because Observable Executions may be infinite, and it's common for an Observer to want to abort execution in finite time, we need an API for canceling an execution. Since each execution is exclusive to one Observer only, once the Observer is done receiving values, it has to have a way to stop the execution, in order to avoid wasting computation power or memory resources. When `observable.subscribe` is called, the Observer gets attached to the newly created Observable execution. This call also returns an object, the `Subscription`: ```ts const subscription = observable.subscribe((x) => console.log(x)); ``` The Subscription represents the ongoing execution, and has a minimal API which allows you to cancel that execution. Read more about the [`Subscription` type here](./guide/subscription). With `subscription.unsubscribe()` you can cancel the ongoing execution: ```ts import { from } from 'rxjs'; const observable = from([10, 20, 30]); const subscription = observable.subscribe((x) => console.log(x)); // Later: subscription.unsubscribe(); ``` When you subscribe, you get back a Subscription, which represents the ongoing execution. Just call `unsubscribe()` to cancel the execution. Each Observable must define how to dispose resources of that execution when we create the Observable using `create()`. You can do that by returning a custom `unsubscribe` function from within `function subscribe()`. For instance, this is how we clear an interval execution set with `setInterval`: ```ts import { Observable } from 'rxjs'; const observable = new Observable(function subscribe(subscriber) { // Keep track of the interval resource const intervalId = setInterval(() => { subscriber.next('hi'); }, 1000); // Provide a way of canceling and disposing the interval resource return function unsubscribe() { clearInterval(intervalId); }; }); ``` Just like `observable.subscribe` resembles `new Observable(function subscribe() {...})`, the `unsubscribe` we return from `subscribe` is conceptually equal to `subscription.unsubscribe`. In fact, if we remove the ReactiveX types surrounding these concepts, we're left with rather straightforward JavaScript. ```ts function subscribe(subscriber) { const intervalId = setInterval(() => { subscriber.next('hi'); }, 1000); return function unsubscribe() { clearInterval(intervalId); }; } const unsubscribe = subscribe({ next: (x) => console.log(x) }); // Later: unsubscribe(); // dispose the resources ``` The reason why we use Rx types like Observable, Observer, and Subscription is to get safety (such as the Observable Contract) and composability with Operators. ================================================ FILE: apps/rxjs.dev/content/guide/observer.md ================================================ # Observer **What is an Observer?** An Observer is a consumer of values delivered by an Observable. Observers are simply a set of callbacks, one for each type of notification delivered by the Observable: `next`, `error`, and `complete`. The following is an example of a typical Observer object: ```ts const observer = { next: x => console.log('Observer got a next value: ' + x), error: err => console.error('Observer got an error: ' + err), complete: () => console.log('Observer got a complete notification'), }; ``` To use the Observer, provide it to the `subscribe` of an Observable: ```ts observable.subscribe(observer); ``` Observers are just objects with three callbacks, one for each type of notification that an Observable may deliver. Observers in RxJS may also be *partial*. If you don't provide one of the callbacks, the execution of the Observable will still happen normally, except some types of notifications will be ignored, because they don't have a corresponding callback in the Observer. The example below is an `Observer` without the `complete` callback: ```ts const observer = { next: x => console.log('Observer got a next value: ' + x), error: err => console.error('Observer got an error: ' + err), }; ``` When subscribing to an `Observable`, you may also just provide the next callback as an argument, without being attached to an `Observer` object, for instance like this: ```ts observable.subscribe(x => console.log('Observer got a next value: ' + x)); ``` Internally in `observable.subscribe`, it will create an `Observer` object using the callback argument as the `next` handler. ================================================ FILE: apps/rxjs.dev/content/guide/operators.md ================================================ # RxJS Operators RxJS is mostly useful for its _operators_, even though the Observable is the foundation. Operators are the essential pieces that allow complex asynchronous code to be easily composed in a declarative manner. ## What are operators? Operators are **functions**. There are two kinds of operators: **Pipeable Operators** are the kind that can be piped to Observables using the syntax `observableInstance.pipe(operator)` or, more commonly, `observableInstance.pipe(operatorFactory())`. Operator factory functions include, [`filter(...)`](/api/operators/filter), and [`mergeMap(...)`](/api/operators/mergeMap). When Pipeable Operators are called, they do not _change_ the existing Observable instance. Instead, they return a _new_ Observable, whose subscription logic is based on the first Observable. A Pipeable Operator is a function that takes an Observable as its input and returns another Observable. It is a pure operation: the previous Observable stays unmodified. A Pipeable Operator Factory is a function that can take parameters to set the context and return a Pipeable Operator. The factory’s arguments belong to the operator’s lexical scope. A Pipeable Operator is essentially a pure function which takes one Observable as input and generates another Observable as output. Subscribing to the output Observable will also subscribe to the input Observable. **Creation Operators** are the other kind of operator, which can be called as standalone functions to create a new Observable. For example: `of(1, 2, 3)` creates an observable that will emit 1, 2, and 3, one right after another. Creation operators will be discussed in more detail in a later section. For example, the operator called [`map`](/api/operators/map) is analogous to the Array method of the same name. Just as `[1, 2, 3].map(x => x * x)` will yield `[1, 4, 9]`, the Observable created like this: ```ts import { of, map } from 'rxjs'; of(1, 2, 3) .pipe(map((x) => x * x)) .subscribe((v) => console.log(`value: ${v}`)); // Logs: // value: 1 // value: 4 // value: 9 ``` will emit `1`, `4`, `9`. Another useful operator is [`first`](/api/operators/first): ```ts import { of, first } from 'rxjs'; of(1, 2, 3) .pipe(first()) .subscribe((v) => console.log(`value: ${v}`)); // Logs: // value: 1 ``` Note that `map` logically must be constructed on the fly, since it must be given the mapping function to. By contrast, `first` could be a constant, but is nonetheless constructed on the fly. As a general practice, all operators are constructed, whether they need arguments or not. ## Piping Pipeable operators are functions, so they _could_ be used like ordinary functions: `op()(obs)` — but in practice, there tend to be many of them convolved together, and quickly become unreadable: `op4()(op3()(op2()(op1()(obs))))`. For that reason, Observables have a method called `.pipe()` that accomplishes the same thing while being much easier to read: ```ts obs.pipe(op1(), op2(), op3(), op4()); ``` As a stylistic matter, `op()(obs)` is never used, even if there is only one operator; `obs.pipe(op())` is universally preferred. ## Creation Operators **What are creation operators?** Distinct from pipeable operators, creation operators are functions that can be used to create an Observable with some common predefined behavior or by joining other Observables. A typical example of a creation operator would be the `interval` function. It takes a number (not an Observable) as input argument, and produces an Observable as output: ```ts import { interval } from 'rxjs'; const observable = interval(1000 /* number of milliseconds */); ``` See the list of all static creation operators [here](#creation-operators-list). ## Higher-order Observables Observables most commonly emit ordinary values like strings and numbers, but surprisingly often, it is necessary to handle Observables _of_ Observables, so-called higher-order Observables. For example, imagine you had an Observable emitting strings that were the URLs of files you wanted to see. The code might look like this: ```ts const fileObservable = urlObservable.pipe(map((url) => http.get(url))); ``` `http.get()` returns an Observable (of string or string arrays probably) for each individual URL. Now you have an Observable _of_ Observables, a higher-order Observable. But how do you work with a higher-order Observable? Typically, by _flattening_: by (somehow) converting a higher-order Observable into an ordinary Observable. For example: ```ts const fileObservable = urlObservable.pipe( map((url) => http.get(url)), concatAll() ); ``` The [`concatAll()`](/api/operators/concatAll) operator subscribes to each "inner" Observable that comes out of the "outer" Observable, and copies all the emitted values until that Observable completes, and goes on to the next one. All of the values are in that way concatenated. Other useful flattening operators (called [_join operators_](#join-operators)) are - [`mergeAll()`](/api/operators/mergeAll) — subscribes to each inner Observable as it arrives, then emits each value as it arrives - [`switchAll()`](/api/operators/switchAll) — subscribes to the first inner Observable when it arrives, and emits each value as it arrives, but when the next inner Observable arrives, unsubscribes to the previous one, and subscribes to the new one. - [`exhaustAll()`](/api/operators/exhaustAll) — subscribes to the first inner Observable when it arrives, and emits each value as it arrives, discarding all newly arriving inner Observables until that first one completes, then waits for the next inner Observable. Just as many array libraries combine [`map()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) and [`flat()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat) (or `flatten()`) into a single [`flatMap()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap), there are mapping equivalents of all the RxJS flattening operators [`concatMap()`](/api/operators/concatMap), [`mergeMap()`](/api/operators/mergeMap), [`switchMap()`](/api/operators/switchMap), and [`exhaustMap()`](/api/operators/exhaustMap). ## Marble diagrams To explain how operators work, textual descriptions are often not enough. Many operators are related to time, they may for instance delay, sample, throttle, or debounce value emissions in different ways. Diagrams are often a better tool for that. _Marble Diagrams_ are visual representations of how operators work, and include the input Observable(s), the operator and its parameters, and the output Observable. In a marble diagram, time flows to the right, and the diagram describes how values ("marbles") are emitted on the Observable execution. Below you can see the anatomy of a marble diagram. Throughout this documentation site, we extensively use marble diagrams to explain how operators work. They may be really useful in other contexts too, like on a whiteboard or even in our unit tests (as ASCII diagrams). ## Categories of operators There are operators for different purposes, and they may be categorized as: creation, transformation, filtering, joining, multicasting, error handling, utility, etc. In the following list you will find all the operators organized in categories. For a complete overview, see the [references page](/api). ### Creation Operators - [`ajax`](/api/ajax/ajax) - [`bindCallback`](/api/index/function/bindCallback) - [`bindNodeCallback`](/api/index/function/bindNodeCallback) - [`defer`](/api/index/function/defer) - [`EMPTY`](/api/index/const/EMPTY) - [`from`](/api/index/function/from) - [`fromEvent`](/api/index/function/fromEvent) - [`fromEventPattern`](/api/index/function/fromEventPattern) - [`generate`](/api/index/function/generate) - [`interval`](/api/index/function/interval) - [`of`](/api/index/function/of) - [`range`](/api/index/function/range) - [`throwError`](/api/index/function/throwError) - [`timer`](/api/index/function/timer) - [`iif`](/api/index/function/iif) ### Join Creation Operators These are Observable creation operators that also have join functionality -- emitting values of multiple source Observables. - [`combineLatest`](/api/index/function/combineLatest) - [`concat`](/api/index/function/concat) - [`forkJoin`](/api/index/function/forkJoin) - [`merge`](/api/index/function/merge) - [`partition`](/api/index/function/partition) - [`race`](/api/index/function/race) - [`zip`](/api/index/function/zip) ### Transformation Operators - [`buffer`](/api/operators/buffer) - [`bufferCount`](/api/operators/bufferCount) - [`bufferTime`](/api/operators/bufferTime) - [`bufferToggle`](/api/operators/bufferToggle) - [`bufferWhen`](/api/operators/bufferWhen) - [`concatMap`](/api/operators/concatMap) - [`concatMapTo`](/api/operators/concatMapTo) - [`exhaustMap`](/api/operators/exhaustMap) - [`expand`](/api/operators/expand) - [`groupBy`](/api/operators/groupBy) - [`map`](/api/operators/map) - [`mapTo`](/api/operators/mapTo) - [`mergeMap`](/api/operators/mergeMap) - [`mergeMapTo`](/api/operators/mergeMapTo) - [`mergeScan`](/api/operators/mergeScan) - [`pairwise`](/api/operators/pairwise) - [`partition`](/api/operators/partition) - [`scan`](/api/operators/scan) - [`switchScan`](/api/operators/switchScan) - [`switchMap`](/api/operators/switchMap) - [`switchMapTo`](/api/operators/switchMapTo) - [`window`](/api/operators/window) - [`windowCount`](/api/operators/windowCount) - [`windowTime`](/api/operators/windowTime) - [`windowToggle`](/api/operators/windowToggle) - [`windowWhen`](/api/operators/windowWhen) ### Filtering Operators - [`audit`](/api/operators/audit) - [`auditTime`](/api/operators/auditTime) - [`debounce`](/api/operators/debounce) - [`debounceTime`](/api/operators/debounceTime) - [`distinct`](/api/operators/distinct) - [`distinctUntilChanged`](/api/operators/distinctUntilChanged) - [`distinctUntilKeyChanged`](/api/operators/distinctUntilKeyChanged) - [`elementAt`](/api/operators/elementAt) - [`filter`](/api/operators/filter) - [`first`](/api/operators/first) - [`ignoreElements`](/api/operators/ignoreElements) - [`last`](/api/operators/last) - [`sample`](/api/operators/sample) - [`sampleTime`](/api/operators/sampleTime) - [`single`](/api/operators/single) - [`skip`](/api/operators/skip) - [`skipLast`](/api/operators/skipLast) - [`skipUntil`](/api/operators/skipUntil) - [`skipWhile`](/api/operators/skipWhile) - [`take`](/api/operators/take) - [`takeLast`](/api/operators/takeLast) - [`takeUntil`](/api/operators/takeUntil) - [`takeWhile`](/api/operators/takeWhile) - [`throttle`](/api/operators/throttle) - [`throttleTime`](/api/operators/throttleTime) ### Join Operators Also see the [Join Creation Operators](#join-creation-operators) section above. - [`combineLatestAll`](/api/operators/combineLatestAll) - [`concatAll`](/api/operators/concatAll) - [`exhaustAll`](/api/operators/exhaustAll) - [`mergeAll`](/api/operators/mergeAll) - [`switchAll`](/api/operators/switchAll) - [`startWith`](/api/operators/startWith) - [`withLatestFrom`](/api/operators/withLatestFrom) ### Multicasting Operators - [`share`](/api/operators/share) ### Error Handling Operators - [`catchError`](/api/operators/catchError) - [`retry`](/api/operators/retry) - [`retryWhen`](/api/operators/retryWhen) ### Utility Operators - [`tap`](/api/operators/tap) - [`delay`](/api/operators/delay) - [`delayWhen`](/api/operators/delayWhen) - [`dematerialize`](/api/operators/dematerialize) - [`materialize`](/api/operators/materialize) - [`observeOn`](/api/operators/observeOn) - [`subscribeOn`](/api/operators/subscribeOn) - [`timeInterval`](/api/operators/timeInterval) - [`timestamp`](/api/operators/timestamp) - [`timeout`](/api/operators/timeout) - [`timeoutWith`](/api/operators/timeoutWith) - [`toArray`](/api/operators/toArray) ### Conditional and Boolean Operators - [`defaultIfEmpty`](/api/operators/defaultIfEmpty) - [`every`](/api/operators/every) - [`find`](/api/operators/find) - [`findIndex`](/api/operators/findIndex) - [`isEmpty`](/api/operators/isEmpty) ### Mathematical and Aggregate Operators - [`count`](/api/operators/count) - [`max`](/api/operators/max) - [`min`](/api/operators/min) - [`reduce`](/api/operators/reduce) ## Creating custom operators ### Use the `pipe()` function to make new operators If there is a commonly used sequence of operators in your code, use the `pipe()` function to extract the sequence into a new operator. Even if a sequence is not that common, breaking it out into a single operator can improve readability. For example, you could make a function that discarded odd values and doubled even values like this: ```ts import { pipe, filter, map } from 'rxjs'; function discardOddDoubleEven() { return pipe( filter((v) => !(v % 2)), map((v) => v + v) ); } ``` (The `pipe()` function is analogous to, but not the same thing as, the `.pipe()` method on an Observable.) ### Creating new operators from scratch It is more complicated, but if you have to write an operator that cannot be made from a combination of existing operators (a rare occurrence), you can write an operator from scratch using the Observable constructor, like this: ```ts import { Observable, of } from 'rxjs'; function delay(delayInMillis: number) { return (observable: Observable) => new Observable((subscriber) => { // this function will be called each time this // Observable is subscribed to. const allTimerIDs = new Set(); let hasCompleted = false; const subscription = observable.subscribe({ next(value) { // Start a timer to delay the next value // from being pushed. const timerID = setTimeout(() => { subscriber.next(value); // after we push the value, we need to clean up the timer timerID allTimerIDs.delete(timerID); // If the source has completed, and there are no more timers running, // we can complete the resulting observable. if (hasCompleted && allTimerIDs.size === 0) { subscriber.complete(); } }, delayInMillis); allTimerIDs.add(timerID); }, error(err) { // We need to make sure we're propagating our errors through. subscriber.error(err); }, complete() { hasCompleted = true; // If we still have timers running, we don't want to complete yet. if (allTimerIDs.size === 0) { subscriber.complete(); } }, }); // Return the finalization logic. This will be invoked when // the result errors, completes, or is unsubscribed. return () => { subscription.unsubscribe(); // Clean up our timers. for (const timerID of allTimerIDs) { clearTimeout(timerID); } }; }); } // Try it out! of(1, 2, 3).pipe(delay(1000)).subscribe(console.log); ``` Note that you must 1. implement all three Observer functions, `next()`, `error()`, and `complete()` when subscribing to the input Observable. 2. implement a "finalization" function that cleans up when the Observable completes (in this case by unsubscribing and clearing any pending timeouts). 3. return that finalization function from the function passed to the Observable constructor. Of course, this is only an example; the [`delay()`](/api/operators/delay) operator already exists. ================================================ FILE: apps/rxjs.dev/content/guide/overview.md ================================================ # Introduction RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the [Observable](./guide/observable), satellite types (Observer, Schedulers, Subjects) and operators inspired by `Array` methods (`map`, `filter`, `reduce`, `every`, etc) to allow handling asynchronous events as collections. Think of RxJS as Lodash for events. ReactiveX combines the [Observer pattern](https://en.wikipedia.org/wiki/Observer_pattern) with the [Iterator pattern](https://en.wikipedia.org/wiki/Iterator_pattern) and [functional programming with collections](http://martinfowler.com/articles/collection-pipeline/#NestedOperatorExpressions) to fill the need for an ideal way of managing sequences of events. The essential concepts in RxJS which solve async event management are: - **Observable:** represents the idea of an invokable collection of future values or events. - **Observer:** is a collection of callbacks that knows how to listen to values delivered by the Observable. - **Subscription:** represents the execution of an Observable, is primarily useful for cancelling the execution. - **Operators:** are pure functions that enable a functional programming style of dealing with collections with operations like `map`, `filter`, `concat`, `reduce`, etc. - **Subject:** is equivalent to an EventEmitter, and the only way of multicasting a value or event to multiple Observers. - **Schedulers:** are centralized dispatchers to control concurrency, allowing us to coordinate when computation happens on e.g. `setTimeout` or `requestAnimationFrame` or others. ## First examples Normally you register event listeners. ```ts document.addEventListener('click', () => console.log('Clicked!')); ``` Using RxJS you create an observable instead. ```ts import { fromEvent } from 'rxjs'; fromEvent(document, 'click').subscribe(() => console.log('Clicked!')); ``` ### Purity What makes RxJS powerful is its ability to produce values using pure functions. That means your code is less prone to errors. Normally you would create an impure function, where other pieces of your code can mess up your state. ```ts let count = 0; document.addEventListener('click', () => console.log(`Clicked ${++count} times`)); ``` Using RxJS you isolate the state. ```ts import { fromEvent, scan } from 'rxjs'; fromEvent(document, 'click') .pipe(scan((count) => count + 1, 0)) .subscribe((count) => console.log(`Clicked ${count} times`)); ``` The **scan** operator works just like **reduce** for arrays. It takes a value which is exposed to a callback. The returned value of the callback will then become the next value exposed the next time the callback runs. ### Flow RxJS has a whole range of operators that helps you control how the events flow through your observables. This is how you would allow at most one click per second, with plain JavaScript: ```ts let count = 0; let rate = 1000; let lastClick = Date.now() - rate; document.addEventListener('click', () => { if (Date.now() - lastClick >= rate) { console.log(`Clicked ${++count} times`); lastClick = Date.now(); } }); ``` With RxJS: ```ts import { fromEvent, throttleTime, scan } from 'rxjs'; fromEvent(document, 'click') .pipe( throttleTime(1000), scan((count) => count + 1, 0) ) .subscribe((count) => console.log(`Clicked ${count} times`)); ``` Other flow control operators are [**filter**](../api/operators/filter), [**delay**](../api/operators/delay), [**debounceTime**](../api/operators/debounceTime), [**take**](../api/operators/take), [**takeUntil**](../api/operators/takeUntil), [**distinct**](../api/operators/distinct), [**distinctUntilChanged**](../api/operators/distinctUntilChanged) etc. ### Values You can transform the values passed through your observables. Here's how you can add the current mouse x position for every click, in plain JavaScript: ```ts let count = 0; const rate = 1000; let lastClick = Date.now() - rate; document.addEventListener('click', (event) => { if (Date.now() - lastClick >= rate) { count += event.clientX; console.log(count); lastClick = Date.now(); } }); ``` With RxJS: ```ts import { fromEvent, throttleTime, map, scan } from 'rxjs'; fromEvent(document, 'click') .pipe( throttleTime(1000), map((event) => event.clientX), scan((count, clientX) => count + clientX, 0) ) .subscribe((count) => console.log(count)); ``` Other value producing operators are [**pairwise**](../api/operators/pairwise), [**sample**](../api/operators/sample) etc. ================================================ FILE: apps/rxjs.dev/content/guide/scheduler.md ================================================ # Scheduler **What is a Scheduler?** A scheduler controls when a subscription starts and when notifications are delivered. It consists of three components. - **A Scheduler is a data structure.** It knows how to store and queue tasks based on priority or other criteria. - **A Scheduler is an execution context.** It denotes where and when the task is executed (e.g. immediately, or in another callback mechanism such as setTimeout or process.nextTick, or the animation frame). - **A Scheduler has a (virtual) clock.** It provides a notion of "time" by a getter method `now()` on the scheduler. Tasks being scheduled on a particular scheduler will adhere only to the time denoted by that clock. A Scheduler lets you define in what execution context will an Observable deliver notifications to its Observer. In the example below, we take the usual simple Observable that emits values `1`, `2`, `3` synchronously, and use the operator `observeOn` to specify the `asyncScheduler` scheduler to use for delivering those values. ```ts import { Observable, observeOn, asyncScheduler } from 'rxjs'; const observable = new Observable((observer) => { observer.next(1); observer.next(2); observer.next(3); observer.complete(); }).pipe( observeOn(asyncScheduler) ); console.log('just before subscribe'); observable.subscribe({ next(x) { console.log('got value ' + x); }, error(err) { console.error('something wrong occurred: ' + err); }, complete() { console.log('done'); }, }); console.log('just after subscribe'); ``` Which executes with the output: ```none just before subscribe just after subscribe got value 1 got value 2 got value 3 done ``` Notice how the notifications `got value...` were delivered after `just after subscribe`, which is different to the default behavior we have seen so far. This is because `observeOn(asyncScheduler)` introduces a proxy Observer between `new Observable` and the final Observer. Let's rename some identifiers to make that distinction obvious in the example code: ```ts import { Observable, observeOn, asyncScheduler } from 'rxjs'; const observable = new Observable((proxyObserver) => { proxyObserver.next(1); proxyObserver.next(2); proxyObserver.next(3); proxyObserver.complete(); }).pipe( observeOn(asyncScheduler) ); const finalObserver = { next(x) { console.log('got value ' + x); }, error(err) { console.error('something wrong occurred: ' + err); }, complete() { console.log('done'); }, }; console.log('just before subscribe'); observable.subscribe(finalObserver); console.log('just after subscribe'); ``` The `proxyObserver` is created in `observeOn(asyncScheduler)`, and its `next(val)` function is approximately the following: ```ts const proxyObserver = { next(val) { asyncScheduler.schedule( (x) => finalObserver.next(x), 0 /* delay */, val /* will be the x for the function above */ ); }, // ... }; ``` The `asyncScheduler` Scheduler operates with a `setTimeout` or `setInterval`, even if the given `delay` was zero. As usual, in JavaScript, `setTimeout(fn, 0)` is known to run the function `fn` earliest on the next event loop iteration. This explains why `got value 1` is delivered to the `finalObserver` after `just after subscribe` happened. The `schedule()` method of a Scheduler takes a `delay` argument, which refers to a quantity of time relative to the Scheduler's own internal clock. A Scheduler's clock need not have any relation to the actual wall-clock time. This is how temporal operators like `delay` operate not on actual time, but on time dictated by the Scheduler's clock. This is specially useful in testing, where a _virtual time Scheduler_ may be used to fake wall-clock time while in reality executing scheduled tasks synchronously. ## Scheduler Types The `asyncScheduler` Scheduler is one of the built-in schedulers provided by RxJS. Each of these can be created and returned by using static properties of the `Scheduler` object. | Scheduler | Purpose | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `null` | By not passing any scheduler, notifications are delivered synchronously and recursively. Use this for constant-time operations or tail recursive operations. | | `queueScheduler` | Schedules on a queue in the current event frame (trampoline scheduler). Use this for iteration operations. | | `asapScheduler` | Schedules on the micro task queue, which is the same queue used for promises. Basically after the current job, but before the next job. Use this for asynchronous conversions. | | `asyncScheduler` | Schedules work with `setInterval`. Use this for time-based operations. | | `animationFrameScheduler` | Schedules task that will happen just before next browser content repaint. Can be used to create smooth browser animations. | ## Using Schedulers You may have already used schedulers in your RxJS code without explicitly stating the type of schedulers to be used. This is because all Observable operators that deal with concurrency have optional schedulers. If you do not provide the scheduler, RxJS will pick a default scheduler by using the principle of least concurrency. This means that the scheduler which introduces the least amount of concurrency that satisfies the needs of the operator is chosen. For example, for operators returning an observable with a finite and small number of messages, RxJS uses no Scheduler, i.e. `null` or `undefined`. For operators returning a potentially large or infinite number of messages, `queueScheduler` Scheduler is used. For operators which use timers, `asyncScheduler` is used. Because RxJS uses the least concurrency scheduler, you can pick a different scheduler if you want to introduce concurrency for performance purpose. To specify a particular scheduler, you can use those operator methods that take a scheduler, e.g., `from([10, 20, 30], asyncScheduler)`. **Static creation operators usually take a Scheduler as argument.** For instance, `from(array, scheduler)` lets you specify the Scheduler to use when delivering each notification converted from the `array`. It is usually the last argument to the operator. The following static creation operators take a Scheduler argument: - `bindCallback` - `bindNodeCallback` - `combineLatest` - `concat` - `empty` - `from` - `fromPromise` - `interval` - `merge` - `of` - `range` - `throw` - `timer` **Use `subscribeOn` to schedule in what context will the `subscribe()` call happen.** By default, a `subscribe()` call on an Observable will happen synchronously and immediately. However, you may delay or schedule the actual subscription to happen on a given Scheduler, using the instance operator `subscribeOn(scheduler)`, where `scheduler` is an argument you provide. **Use `observeOn` to schedule in what context will notifications be delivered.** As we saw in the examples above, instance operator `observeOn(scheduler)` introduces a mediator Observer between the source Observable and the destination Observer, where the mediator schedules calls to the destination Observer using your given `scheduler`. **Instance operators may take a Scheduler as argument.** Time-related operators like `bufferTime`, `debounceTime`, `delay`, `auditTime`, `sampleTime`, `throttleTime`, `timeInterval`, `timeout`, `timeoutWith`, `windowTime` all take a Scheduler as the last argument, and otherwise operate by default on the `asyncScheduler`. Other instance operators that take a Scheduler as argument: `combineLatest`, `concat`, `expand`, `merge`, `startWith`. ================================================ FILE: apps/rxjs.dev/content/guide/subject.md ================================================ # Subject **What is a Subject?** An RxJS Subject is a special type of Observable that allows values to be multicasted to many Observers. While plain Observables are unicast (each subscribed Observer owns an independent execution of the Observable), Subjects are multicast. A Subject is like an Observable, but can multicast to many Observers. Subjects are like EventEmitters: they maintain a registry of many listeners. **Every Subject is an Observable.** Given a Subject, you can `subscribe` to it, providing an Observer, which will start receiving values normally. From the perspective of the Observer, it cannot tell whether the Observable execution is coming from a plain unicast Observable or a Subject. Internally to the Subject, `subscribe` does not invoke a new execution that delivers values. It simply registers the given Observer in a list of Observers, similarly to how `addListener` usually works in other libraries and languages. **Every Subject is an Observer.** It is an object with the methods `next(v)`, `error(e)`, and `complete()`. To feed a new value to the Subject, just call `next(theValue)`, and it will be multicasted to the Observers registered to listen to the Subject. In the example below, we have two Observers attached to a Subject, and we feed some values to the Subject: ```ts import { Subject } from 'rxjs'; const subject = new Subject(); subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); subject.next(1); subject.next(2); // Logs: // observerA: 1 // observerB: 1 // observerA: 2 // observerB: 2 ``` Since a Subject is an Observer, this also means you may provide a Subject as the argument to the `subscribe` of any Observable, like the example below shows: ```ts import { Subject, from } from 'rxjs'; const subject = new Subject(); subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); const observable = from([1, 2, 3]); observable.subscribe(subject); // You can subscribe providing a Subject // Logs: // observerA: 1 // observerB: 1 // observerA: 2 // observerB: 2 // observerA: 3 // observerB: 3 ``` With the approach above, we essentially just converted a unicast Observable execution to multicast, through the Subject. This demonstrates how Subjects are the only way of making any Observable execution be shared to multiple Observers. There are also a few specializations of the `Subject` type: `BehaviorSubject`, `ReplaySubject`, and `AsyncSubject`. ## Multicasted Observables A "multicasted Observable" passes notifications through a Subject which may have many subscribers, whereas a plain "unicast Observable" only sends notifications to a single Observer. A multicasted Observable uses a Subject under the hood to make multiple Observers see the same Observable execution. Under the hood, this is how the `multicast` operator works: Observers subscribe to an underlying Subject, and the Subject subscribes to the source Observable. The following example is similar to the previous example which used `observable.subscribe(subject)`: ```ts import { from, Subject, multicast } from 'rxjs'; const source = from([1, 2, 3]); const subject = new Subject(); const multicasted = source.pipe(multicast(subject)); // These are, under the hood, `subject.subscribe({...})`: multicasted.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); multicasted.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); // This is, under the hood, `source.subscribe(subject)`: multicasted.connect(); ``` `multicast` returns an Observable that looks like a normal Observable, but works like a Subject when it comes to subscribing. `multicast` returns a `ConnectableObservable`, which is simply an Observable with the `connect()` method. The `connect()` method is important to determine exactly when the shared Observable execution will start. Because `connect()` does `source.subscribe(subject)` under the hood, `connect()` returns a Subscription, which you can unsubscribe from in order to cancel the shared Observable execution. ### Reference counting Calling `connect()` manually and handling the Subscription is often cumbersome. Usually, we want to _automatically_ connect when the first Observer arrives, and automatically cancel the shared execution when the last Observer unsubscribes. Consider the following example where subscriptions occur as outlined by this list: 1. First Observer subscribes to the multicasted Observable 2. **The multicasted Observable is connected** 3. The `next` value `0` is delivered to the first Observer 4. Second Observer subscribes to the multicasted Observable 5. The `next` value `1` is delivered to the first Observer 6. The `next` value `1` is delivered to the second Observer 7. First Observer unsubscribes from the multicasted Observable 8. The `next` value `2` is delivered to the second Observer 9. Second Observer unsubscribes from the multicasted Observable 10. **The connection to the multicasted Observable is unsubscribed** To achieve that with explicit calls to `connect()`, we write the following code: ```ts import { interval, Subject, multicast } from 'rxjs'; const source = interval(500); const subject = new Subject(); const multicasted = source.pipe(multicast(subject)); let subscription1, subscription2, subscriptionConnect; subscription1 = multicasted.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); // We should call `connect()` here, because the first // subscriber to `multicasted` is interested in consuming values subscriptionConnect = multicasted.connect(); setTimeout(() => { subscription2 = multicasted.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); }, 600); setTimeout(() => { subscription1.unsubscribe(); }, 1200); // We should unsubscribe the shared Observable execution here, // because `multicasted` would have no more subscribers after this setTimeout(() => { subscription2.unsubscribe(); subscriptionConnect.unsubscribe(); // for the shared Observable execution }, 2000); ``` If we wish to avoid explicit calls to `connect()`, we can use ConnectableObservable's `refCount()` method (reference counting), which returns an Observable that keeps track of how many subscribers it has. When the number of subscribers increases from `0` to `1`, it will call `connect()` for us, which starts the shared execution. Only when the number of subscribers decreases from `1` to `0` will it be fully unsubscribed, stopping further execution. `refCount` makes the multicasted Observable automatically start executing when the first subscriber arrives, and stop executing when the last subscriber leaves. Below is an example: ```ts import { interval, Subject, multicast, refCount } from 'rxjs'; const source = interval(500); const subject = new Subject(); const refCounted = source.pipe(multicast(subject), refCount()); let subscription1, subscription2; // This calls `connect()`, because // it is the first subscriber to `refCounted` console.log('observerA subscribed'); subscription1 = refCounted.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); setTimeout(() => { console.log('observerB subscribed'); subscription2 = refCounted.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); }, 600); setTimeout(() => { console.log('observerA unsubscribed'); subscription1.unsubscribe(); }, 1200); // This is when the shared Observable execution will stop, because // `refCounted` would have no more subscribers after this setTimeout(() => { console.log('observerB unsubscribed'); subscription2.unsubscribe(); }, 2000); // Logs // observerA subscribed // observerA: 0 // observerB subscribed // observerA: 1 // observerB: 1 // observerA unsubscribed // observerB: 2 // observerB unsubscribed ``` The `refCount()` method only exists on ConnectableObservable, and it returns an `Observable`, not another ConnectableObservable. ## BehaviorSubject One of the variants of Subjects is the `BehaviorSubject`, which has a notion of "the current value". It stores the latest value emitted to its consumers, and whenever a new Observer subscribes, it will immediately receive the "current value" from the `BehaviorSubject`. BehaviorSubjects are useful for representing "values over time". For instance, an event stream of birthdays is a Subject, but the stream of a person's age would be a BehaviorSubject. In the following example, the BehaviorSubject is initialized with the value `0` which the first Observer receives when it subscribes. The second Observer receives the value `2` even though it subscribed after the value `2` was sent. ```ts import { BehaviorSubject } from 'rxjs'; const subject = new BehaviorSubject(0); // 0 is the initial value subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); subject.next(1); subject.next(2); subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); subject.next(3); // Logs // observerA: 0 // observerA: 1 // observerA: 2 // observerB: 2 // observerA: 3 // observerB: 3 ``` ## ReplaySubject A `ReplaySubject` is similar to a `BehaviorSubject` in that it can send old values to new subscribers, but it can also _record_ a part of the Observable execution. A `ReplaySubject` records multiple values from the Observable execution and replays them to new subscribers. When creating a `ReplaySubject`, you can specify how many values to replay: ```ts import { ReplaySubject } from 'rxjs'; const subject = new ReplaySubject(3); // buffer 3 values for new subscribers subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); subject.next(1); subject.next(2); subject.next(3); subject.next(4); subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); subject.next(5); // Logs: // observerA: 1 // observerA: 2 // observerA: 3 // observerA: 4 // observerB: 2 // observerB: 3 // observerB: 4 // observerA: 5 // observerB: 5 ``` You can also specify a _window time_ in milliseconds, besides of the buffer size, to determine how old the recorded values can be. In the following example we use a large buffer size of `100`, but a window time parameter of just `500` milliseconds. ```ts import { ReplaySubject } from 'rxjs'; const subject = new ReplaySubject(100, 500 /* windowTime */); subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); let i = 1; setInterval(() => subject.next(i++), 200); setTimeout(() => { subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); }, 1000); // Logs // observerA: 1 // observerA: 2 // observerA: 3 // observerA: 4 // observerA: 5 // observerB: 3 // observerB: 4 // observerB: 5 // observerA: 6 // observerB: 6 // ... ``` ## AsyncSubject The AsyncSubject is a variant where only the last value of the Observable execution is sent to its observers, and only when the execution completes. ```js import { AsyncSubject } from 'rxjs'; const subject = new AsyncSubject(); subject.subscribe({ next: (v) => console.log(`observerA: ${v}`), }); subject.next(1); subject.next(2); subject.next(3); subject.next(4); subject.subscribe({ next: (v) => console.log(`observerB: ${v}`), }); subject.next(5); subject.complete(); // Logs: // observerA: 5 // observerB: 5 ``` The AsyncSubject is similar to the [`last()`](/api/operators/last) operator, in that it waits for the `complete` notification in order to deliver a single value. ## Void subject Sometimes the emitted value doesn't matter as much as the fact that a value was emitted. For instance, the code below signals that one second has passed. ```ts const subject = new Subject(); setTimeout(() => subject.next('dummy'), 1000); ``` Passing a dummy value this way is clumsy and can confuse users. By declaring a _void subject_, you signal that the value is irrelevant. Only the event itself matters. ```ts const subject = new Subject(); setTimeout(() => subject.next(), 1000); ``` A complete example with context is shown below: ```ts import { Subject } from 'rxjs'; const subject = new Subject(); // Shorthand for Subject subject.subscribe({ next: () => console.log('One second has passed'), }); setTimeout(() => subject.next(), 1000); ``` Before version 7, the default type of Subject values was `any`. `Subject` disables type checking of the emitted values, whereas `Subject` prevents accidental access to the emitted value. If you want the old behavior, then replace `Subject` with `Subject`. ================================================ FILE: apps/rxjs.dev/content/guide/subscription.md ================================================ # Subscription **What is a Subscription?** A Subscription is an object that represents a disposable resource, usually the execution of an Observable. A Subscription has one important method, `unsubscribe`, that takes no argument and just disposes the resource held by the subscription. In previous versions of RxJS, Subscription was called "Disposable". ```ts import { interval } from 'rxjs'; const observable = interval(1000); const subscription = observable.subscribe(x => console.log(x)); // Later: // This cancels the ongoing Observable execution which // was started by calling subscribe with an Observer. subscription.unsubscribe(); ``` A Subscription essentially just has an `unsubscribe()` function to release resources or cancel Observable executions. Subscriptions can also be put together, so that a call to an `unsubscribe()` of one Subscription may unsubscribe multiple Subscriptions. You can do this by "adding" one subscription into another: ```ts import { interval } from 'rxjs'; const observable1 = interval(400); const observable2 = interval(300); const subscription = observable1.subscribe(x => console.log('first: ' + x)); const childSubscription = observable2.subscribe(x => console.log('second: ' + x)); subscription.add(childSubscription); setTimeout(() => { // Unsubscribes BOTH subscription and childSubscription subscription.unsubscribe(); }, 1000); ``` When executed, we see in the console: ```none second: 0 first: 0 second: 1 first: 1 second: 2 ``` Subscriptions also have a `remove(otherSubscription)` method, in order to undo the addition of a child Subscription. ================================================ FILE: apps/rxjs.dev/content/guide/testing/marble-testing.md ================================================ # Testing RxJS Code with Marble Diagrams
This guide refers to usage of marble diagrams when using the new testScheduler.run(callback). Some details here do not apply to using the TestScheduler manually, without using the run() helper.
We can test our _asynchronous_ RxJS code _synchronously_ and deterministically by virtualizing time using the TestScheduler. **Marble diagrams** provide a visual way for us to represent the behavior of an Observable. We can use them to assert that a particular Observable behaves as expected, as well as to create [hot and cold Observables](https://medium.com/@benlesh/hot-vs-cold-observables-f8094ed53339) we can use as mocks. > At this time, the TestScheduler can only be used to test code that uses RxJS schedulers - `AsyncScheduler`, etc. If the code consumes a Promise, for example, it cannot be reliably tested with `TestScheduler`, but instead should be tested more traditionally. See the [Known Issues](#known-issues) section for more details. ```ts import { TestScheduler } from 'rxjs/testing'; import { throttleTime } from 'rxjs'; const testScheduler = new TestScheduler((actual, expected) => { // asserting the two objects are equal - required // for TestScheduler assertions to work via your test framework // e.g. using chai. expect(actual).deep.equal(expected); }); // This test runs synchronously. it('generates the stream correctly', () => { testScheduler.run((helpers) => { const { cold, time, expectObservable, expectSubscriptions } = helpers; const e1 = cold(' -a--b--c---|'); const e1subs = ' ^----------!'; const t = time(' ---| '); // t = 3 const expected = '-a-----c---|'; expectObservable(e1.pipe(throttleTime(t))).toBe(expected); expectSubscriptions(e1.subscriptions).toBe(e1subs); }); }); ``` ## API The callback function you provide to `testScheduler.run(callback)` is called with `helpers` object that contains functions you'll use to write your tests.
When the code inside this callback is being executed, any operator that uses timers/AsyncScheduler (like delay, debounceTime, etc.,) will automatically use the TestScheduler instead, so that we have "virtual time". You do not need to pass the TestScheduler to them, like in the past.
```ts testScheduler.run((helpers) => { const { cold, hot, expectObservable, expectSubscriptions, flush, time, animate } = helpers; // use them }); ``` Although `run()` executes entirely synchronously, the helper functions inside your callback function do not! These functions **schedule assertions** that will execute either when your callback completes or when you explicitly call `flush()`. Be wary of calling synchronous assertions, for example `expect`, from your testing library of choice, from within the callback. See [Synchronous Assertion](#synchronous-assertion) for more information on how to do this. - `cold(marbleDiagram: string, values?: object, error?: any)` - creates a "cold" observable whose subscription starts when the test begins. - `hot(marbleDiagram: string, values?: object, error?: any)` - creates a "hot" observable (like a subject) that will behave as though it's already "running" when the test begins. An interesting difference is that `hot` marbles allow a `^` character to signal where the "zero frame" is. That is the point at which the subscription to observables being tested begins. - `expectObservable(actual: Observable, subscriptionMarbles?: string).toBe(marbleDiagram: string, values?: object, error?: any)` - schedules an assertion for when the TestScheduler flushes. Give `subscriptionMarbles` as parameter to change the schedule of subscription and unsubscription. If you don't provide the `subscriptionMarbles` parameter it will subscribe at the beginning and never unsubscribe. Read below about subscription marble diagram. - `expectSubscriptions(actualSubscriptionLogs: SubscriptionLog[]).toBe(subscriptionMarbles: string)` - like `expectObservable` schedules an assertion for when the testScheduler flushes. Both `cold()` and `hot()` return an observable with a property `subscriptions` of type `SubscriptionLog[]`. Give `subscriptions` as parameter to `expectSubscriptions` to assert whether it matches the `subscriptionsMarbles` marble diagram given in `toBe()`. Subscription marble diagrams are slightly different than Observable marble diagrams. Read more below. - `flush()` - immediately starts virtual time. Not often used since `run()` will automatically flush for you when your callback returns, but in some cases you may wish to flush more than once or otherwise have more control. - `time()` - converts marbles into a number indicating number of frames. It can be used by operators expecting a specific timeout. It measures time based on the position of the complete (`|`) signal: ```ts testScheduler.run((helpers) => { const { time, cold } = helpers; const source = cold('---a--b--|'); const t = time(' --| '); // --| const expected = ' -----a--b|'; const result = source.pipe(delay(t)); expectObservable(result).toBe(expected); }); ``` - `animate()` - specifies when requested animation frames will be 'painted'. `animate` accepts a marble diagram and each value emission in the diagram indicates when a 'paint' occurs - at which time, any queued `requestAnimationFrame` callbacks will be executed. Call `animate` at the beginning of your test and align the marble diagrams so that it's clear when the callbacks will be executed: ```ts testScheduler.run((helpers) => { const { animate, cold } = helpers; animate(' ---x---x---x---x'); const requests = cold('-r-------r------'); /* ... */ const expected = ' ---a-------b----'; }); ``` ## Marble syntax In the context of TestScheduler, a marble diagram is a string containing special syntax representing events happening over virtual time. Time progresses by _frames_. The first character of any marble string always represents the _zero frame_, or the start of time. Inside of `testScheduler.run(callback)` the frameTimeFactor is set to 1, which means one frame is equal to one virtual millisecond. How many virtual milliseconds one frame represents depends on the value of `TestScheduler.frameTimeFactor`. For legacy reasons the value of `frameTimeFactor` is 1 _only_ when your code inside the `testScheduler.run(callback)` callback is running. Outside of it, it's set to 10. This will likely change in a future version of RxJS so that it is always 1. > IMPORTANT: This syntax guide refers to usage of marble diagrams when using the new `testScheduler.run(callback)`. The semantics of marble diagrams when using the TestScheduler manually are different, and some features like the new time progression syntax are not supported. - `' '` whitespace: horizontal whitespace is ignored, and can be used to help vertically align multiple marble diagrams. - `'-'` frame: 1 "frame" of virtual time passing (see above description of frames). - `[0-9]+[ms|s|m]` time progression: the time progression syntax lets you progress virtual time by a specific amount. It's a number, followed by a time unit of `ms` (milliseconds), `s` (seconds), or `m` (minutes) without any space between them, e.g. `a 10ms b`. See [Time progression syntax](#time-progression-syntax) for more details. - `'|'` complete: The successful completion of an observable. This is the observable producer signaling `complete()`. - `'#'` error: An error terminating the observable. This is the observable producer signaling `error()`. - `[a-z0-9]` e.g. `'a'` any alphanumeric character: Represents a value being emitted by the producer signaling `next()`. Also consider that you could map this into an object or an array like this: ```ts const expected = '400ms (a-b|)'; const values = { a: 'value emitted', b: 'another value emitted', }; expectObservable(someStreamForTesting).toBe(expected, values); // This would work also const expected = '400ms (0-1|)'; const values = [ 'value emitted', 'another value emitted' ]; expectObservable(someStreamForTesting).toBe(expected, values); ``` - `'()'` sync groupings: When multiple events need to be in the same frame synchronously, parentheses are used to group those events. You can group next'd values, a completion, or an error in this manner. The position of the initial `(` determines the time at which its values are emitted. While it can be counter-intuitive at first, after all the values have synchronously emitted time will progress a number of frames equal to the number of ASCII characters in the group, including the parentheses. e.g. `'(abc)'` will emit the values of a, b, and c synchronously in the same frame and then advance virtual time by 5 frames, `'(abc)'.length === 5`. This is done because it often helps you vertically align your marble diagrams, but it's a known pain point in real-world testing. [Learn more about known issues](#known-issues). - `'^'` subscription point: (hot observables only) shows the point at which the tested observables will be subscribed to the hot observable. This is the "zero frame" for that observable, every frame before the `^` will be negative. Negative time might seem pointless, but there are in fact advanced cases where this is necessary, usually involving ReplaySubjects. ### Time progression syntax The new time progression syntax takes inspiration from the CSS duration syntax. It's a number (integer or floating point) immediately followed by a unit; ms (milliseconds), s (seconds), m (minutes). e.g. `100ms`, `1.4s`, `5.25m`. When it's not the first character of the diagram it must be padded a space before/after to disambiguate it from a series of marbles. e.g. `a 1ms b` needs the spaces because `a1msb` will be interpreted as `['a', '1', 'm', 's', 'b']` where each of these characters is a value that will be next()'d as-is. **NOTE**: You may have to subtract 1 millisecond from the time you want to progress because the alphanumeric marbles (representing an actual emitted value) _advance time 1 virtual frame_ themselves already, after they emit. This can be counter-intuitive and frustrating, but for now it is indeed correct. ```ts const input = ' -a-b-c|'; const expected = '-- 9ms a 9ms b 9ms (c|)'; // Depending on your personal preferences you could also // use frame dashes to keep vertical alignment with the input. // const input = ' -a-b-c|'; // const expected = '------- 4ms a 9ms b 9ms (c|)'; // or // const expected = '-----------a 9ms b 9ms (c|)'; const result = cold(input).pipe( concatMap((d) => of(d).pipe( delay(10) )) ); expectObservable(result).toBe(expected); ``` ### Examples `'-'` or `'------'`: Equivalent to {@link NEVER}, or an observable that never emits or errors or completes. `|`: Equivalent to {@link EMPTY}, or an observable that never emits and completes immediately. `#`: Equivalent to {@link throwError}, or an observable that never emits and errors immediately. `'--a--'`: An observable that waits 2 "frames", emits value `a` on frame 2 and then never completes. `'--a--b--|'`: On frame 2 emit `a`, on frame 5 emit `b`, and on frame 8, `complete`. `'--a--b--#'`: On frame 2 emit `a`, on frame 5 emit `b`, and on frame 8, `error`. `'-a-^-b--|'`: In a hot observable, on frame -2 emit `a`, then on frame 2 emit `b`, and on frame 5, `complete`. `'--(abc)-|'`: on frame 2 emit `a`, `b`, and `c`, then on frame 8, `complete`. `'-----(a|)'`: on frame 5 emit `a` and `complete`. `'a 9ms b 9s c|'`: on frame 0 emit `a`, on frame 10 emit `b`, on frame 9,011 emit `c`, then on frame 9,012 `complete`. `'--a 2.5m b'`: on frame 2 emit `a`, on frame 150,003 emit `b` and never complete. ## Subscription marbles The `expectSubscriptions` helper allows you to assert that a `cold()` or `hot()` Observable you created was subscribed/unsubscribed to at the correct point in time. The `subscriptionMarbles` parameter to `expectObservable` allows your test to defer subscription to a later virtual time, and/or unsubscribe even if the observable being tested has not yet completed. The subscription marble syntax is slightly different to conventional marble syntax. - `'-'` time: 1 frame time passing. - `[0-9]+[ms|s|m]` time progression: the time progression syntax lets you progress virtual time by a specific amount. It's a number, followed by a time unit of `ms` (milliseconds), `s` (seconds), or `m` (minutes) without any space between them, e.g. `a 10ms b`. See [Time progression syntax](#time-progression-syntax) for more details. - `'^'` subscription point: shows the point in time at which a subscription happens. - `'!'` unsubscription point: shows the point in time at which a subscription is unsubscribed. There should be **at most one** `^` point in a subscription marble diagram, and **at most one** `!` point. Other than that, the `-` character is the only one allowed in a subscription marble diagram. ### Examples `'-'` or `'------'`: no subscription ever happened. `'--^--'`: a subscription happened after 2 "frames" of time passed, and the subscription was not unsubscribed. `'--^--!-'`: on frame 2 a subscription happened, and on frame 5 was unsubscribed. `'500ms ^ 1s !'`: on frame 500 a subscription happened, and on frame 1,501 was unsubscribed. Given a hot source, test multiple subscribers that subscribe at different times: ```ts testScheduler.run(({ hot, expectObservable }) => { const source = hot('--a--a--a--a--a--a--a--'); const sub1 = ' --^-----------!'; const sub2 = ' ---------^--------!'; const expect1 = ' --a--a--a--a--'; const expect2 = ' -----------a--a--a-'; expectObservable(source, sub1).toBe(expect1); expectObservable(source, sub2).toBe(expect2); }); ``` Manually unsubscribe from a source that will never complete: ```ts it('should repeat forever', () => { const testScheduler = createScheduler(); testScheduler.run(({ expectObservable }) => { const foreverStream$ = interval(1).pipe(mapTo('a')); // Omitting this arg may crash the test suite. const unsub = '------!'; expectObservable(foreverStream$, unsub).toBe('-aaaaa'); }); }); ``` ## Synchronous Assertion Sometimes, we need to assert changes in state _after_ an observable stream has completed - such as when a side effect like `tap` updates a variable. Outside of Marbles testing with TestScheduler, we might think of this as creating a delay or waiting before making our assertion. For example: ```ts let eventCount = 0; const s1 = cold('--a--b|', { a: 'x', b: 'y' }); // side effect using 'tap' updates a variable const result = s1.pipe(tap(() => eventCount++)); expectObservable(result).toBe('--a--b|', { a: 'x', b: 'y' }); // flush - run 'virtual time' to complete all outstanding hot or cold observables flush(); expect(eventCount).toBe(2); ``` In the above situation we need the observable stream to complete so that we can test the variable was set to the correct value. The TestScheduler runs in 'virtual time' (synchronously), but doesn't normally run (and complete) until the testScheduler callback returns. The flush() method manually triggers the virtual time so that we can test the local variable after the observable completes. --- ## Known issues ### RxJS code that consumes Promises cannot be directly tested If you have RxJS code that uses asynchronous scheduling - e.g. Promises, etc. - you can't reliably use marble diagrams _for that particular code_. This is because those other scheduling methods won't be virtualized or known to TestScheduler. The solution is to test that code in isolation, with the traditional asynchronous testing methods of your testing framework. The specifics depend on your testing framework of choice, but here's a pseudo-code example: ```ts // Some RxJS code that also consumes a Promise, so TestScheduler won't be able // to correctly virtualize and the test will always be really asynchronous. const myAsyncCode = () => from(Promise.resolve('something')); it('has async code', (done) => { myAsyncCode().subscribe((d) => { assertEqual(d, 'something'); done(); }); }); ``` On a related note, you also can't currently assert delays of zero, even with `AsyncScheduler`, e.g. `delay(0)` is like saying `setTimeout(work, 0)`. This schedules a new ["task" aka "macrotask"](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/), so it's asynchronous, but without an explicit passage of time. ### Behavior is different outside of `testScheduler.run(callback)` The `TestScheduler` has been around since v5, but was actually intended for testing RxJS itself by the maintainers, rather than for use in regular user apps. Because of this, some of the default behaviors and features of the TestScheduler did not work well (or at all) for users. In v6 we introduced the `testScheduler.run(callback)` method which allowed us to provide new defaults and features in a non-breaking way, but it's still possible to [use the TestScheduler outside](https://github.com/ReactiveX/rxjs/blob/7113ae4b451dd8463fae71b68edab96079d089df/docs_app/content/guide/testing/internal-marble-tests.md) of `testScheduler.run(callback)`. It's important to note that if you do so, there are some major differences in how it will behave. - `TestScheduler` helper methods have more verbose names, like `testScheduler.createColdObservable()` instead of `cold()`. - The testScheduler instance is _not_ automatically used by operators that use `AsyncScheduler`, e.g. `delay`, `debounceTime`, etc., so you have to explicitly pass it to them. - There is NO support for time progression syntax e.g. `-a 100ms b-|`. - 1 frame is 10 virtual milliseconds by default. i.e. `TestScheduler.frameTimeFactor = 10`. - Each whitespace `' '` equals 1 frame, same as a hyphen `'-'`. - There is a hard maximum number of frames set at 750 i.e. `maxFrames = 750`. After 750 they are silently ignored. - You must explicitly flush the scheduler. While at this time usage of the TestScheduler outside of `testScheduler.run(callback)` has not been officially deprecated, it is discouraged because it is likely to cause confusion. ================================================ FILE: apps/rxjs.dev/content/license.md ================================================ @title @description The MIT License Copyright (c) 2014-2018 Google, Inc., RxJS Team Members and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: apps/rxjs.dev/content/maintainer-guidelines.md ================================================ # Maintainer Guidelines These are guidelines for maintainers of this repository as (mostly) [gifted to us by](https://github.com/ReactiveX/RxJS/issues/121#issue-97747542) His Beardliness, @jeffbcross. They are words to live by for those that are tasked with reviewing and merging pull requests and otherwise shepherding the community. As the roster of trusted maintainers grows, we'll expect these guidelines to stay pretty much the same (but suggestions are always welcome). ### The ~~10~~ 6 Commandments - **[Code of Conduct](../../../CODE_OF_CONDUCT.md)**. We should be setting a good example and be welcoming to all. We should be listening to all feedback from everyone in our community and respect their viewpoints and opinions. - **Be sure PRs meet [Contribution Guidelines](../../../CONTRIBUTING.md)**. It's important we keep our code base and repository consistent. The best way to do this is to know and enforce the contribution guidelines. - **Clean, flat commit history**. We never click the green merge button on PRs, but instead we pull down the PR branch and rebase it against master then replace master with the PR branch. See [example gist](https://gist.github.com/jeffbcross/307c6da45d26e29030ef). This reduces noise in the commit history, removing all of the merge commits, and keeps history flat. The flat history is beneficial to tools/scripts that analyze commit ancestry. - **Always green master**. Failing master builds tend to cascade into other broken builds, and frustration among other contributors who have rebased against a broken master. Much of our deployment and other infrastructure is based on the assumption that master is always green, nothing should be merged before Travis has confirmed that a PR is green, even for seemingly insignificant changes. Nothing should be merged into a red master, and whomever broke it should drop everything and fix it right away. Fixes should be submitted as a PR and verified as green instead of immediately merging to master. - **No force pushes to master**. Only in rare circumstances should a force push to master be made, and other maintainers should be notified beforehand. The most common situation for a justified force push is when a commit has been pushed with an invalid message. The force push should be made as soon as possible to reduce side effects. - **Small, logical commits**. A PR should be focused on a single problem, though that problem may be reasonable to be broken into a few logical commits. For example, a global renaming may be best to be broken into a single commit that renames all files, and then a commit that renames symbols within files. This makes the review process simpler easier, so the diff of the meaty commit (where symbols are renamed) can be easily understood than if both were done in the same commit, in which case github would just show a deleted file and an added file. ================================================ FILE: apps/rxjs.dev/content/marketing/announcements.json ================================================ [ { "startDate": "2019-12-20", "endDate": "2020-03-20", "message": "RxJS Live Conference in London
March 19th-20th, 2020", "imageUrl": "generated/images/marketing/home/rxjs-live-london.svg", "linkUrl": "https://www.rxjs.live/london" } ] ================================================ FILE: apps/rxjs.dev/content/marketing/api.html ================================================

API List

================================================ FILE: apps/rxjs.dev/content/marketing/contributors.json ================================================ { "ben": { "name": "Ben Lesh", "role": "Developer", "github": "https://github.com/benlesh", "picture": "https://avatars2.githubusercontent.com/u/1540597", "twitter": "https://twitter.com/BenLesh", "website": "https://benlesh.com", "group": "Core Team" }, "paul": { "name": "Paul Taylor", "role": "Developer", "github": "https://github.com/trxcllnt", "picture": "https://avatars2.githubusercontent.com/u/178183", "twitter": "https://twitter.com/trxcllnt", "website": "http://graphistry.com", "group": "Core Team" }, "oj": { "name": "OJ Kwon", "role": "Developer", "github": "https://github.com/kwonoj", "picture": "https://avatars1.githubusercontent.com/u/1210596", "twitter": "https://twitter.com/_ojkwon", "group": "Core Team" }, "david": { "name": "David Driscoll", "role": "Developer", "github": "https://github.com/david-driscoll", "picture": "https://avatars0.githubusercontent.com/u/1269157", "twitter": "https://twitter.com/david_dotnet", "website": "http://david-driscoll.github.io", "group": "Core Team" }, "tracy": { "name": "Tracy Lee", "role": "Developer", "github": "https://github.com/ladyleet", "picture": "https://avatars0.githubusercontent.com/u/8270563", "twitter": "https://twitter.com/ladyleet", "website": "http://thisdot.co", "group": "Core Team" }, "nic": { "name": "Nicholas Jamieson", "group": "Core Team", "github": "https://github.com/cartant", "picture": "https://avatars0.githubusercontent.com/u/3878593", "twitter": "https://twitter.com/ncjamieson", "website": "http://cartant.com", "role": "Developer" }, "tracy-lee": { "name": "Tracy Lee", "role": "Developer", "github": "https://github.com/ladyleet", "picture": "https://avatars0.githubusercontent.com/u/8270563", "twitter": "https://twitter.com/ladyleet", "website": "http://thisdot.co", "group": "Learning Team" }, "ashwin": { "name": "Ashwin Sureshkumar", "role": "Developer", "github": "https://github.com/ashwin-sureshkumar", "picture": "https://avatars0.githubusercontent.com/u/4744080", "twitter": "https://twitter.com/Sureshkumar_Ash", "website": "https://t.co/XduklnxpK3", "group": "Learning Team" }, "brian": { "name": "Brian Troncone", "role": "Developer", "github": "https://github.com/btroncone", "picture": "https://avatars3.githubusercontent.com/u/5085101", "twitter": "http://twitter.com/btroncone", "group": "Learning Team" }, "sumit": { "name": "Sumit Arora", "role": "Developer", "github": "https://github.com/sumitarora", "picture": "https://avatars3.githubusercontent.com/u/198247", "twitter": "https://twitter.com/arorasumit", "website": "http://www.arorasumit.com/", "group": "Learning Team" }, "jen": { "name": "Jen Luker", "role": "Developer, A11y", "github": "https://github.com/knitcodemonkey", "picture": "https://avatars0.githubusercontent.com/u/1584489", "twitter": "https://twitter.com/knitcodemonkey", "website": "http://jenluker.com", "group": "Learning Team" }, "jan": { "name": "Jan-Niklas Wortmann", "role": "Developer", "github": "https://github.com/JWO719", "picture": "https://avatars3.githubusercontent.com/u/6104311", "twitter": "https://twitter.com/niklas_wortmann", "group": "Learning Team" }, "matthew": { "name": "Matthew Podwysocki", "role": "Developer", "github": "https://github.com/mattpodwysocki", "picture": "https://avatars0.githubusercontent.com/u/49051", "twitter": "https://twitter.com/mattpodwysocki", "group": "Alumn" }, "andre": { "name": "André Staltz", "role": "Developer", "github": "https://github.com/staltz", "picture": "https://avatars0.githubusercontent.com/u/90512", "twitter": "https://twitter.com/andrestaltz", "website": "http://staltz.com", "group": "Alumn" }, "jay": { "name": "Jay Phelps", "role": "Developer", "github": "https://github.com/jayphelps", "picture": "https://avatars0.githubusercontent.com/u/762949", "twitter": "https://twitter.com/_jayphelps", "website": "http://jayphelps.com", "group": "Alumn" }, "nat": { "name": "Natalie Smith", "role": "Developer", "github": "https://github.com/natmegs", "picture": "https://avatars0.githubusercontent.com/u/19582796", "twitter": "https://twitter.com/natalie_megan", "website": "http://nataliesmith.ca/", "group": "Contributors" }, "cedric": { "name": "Cédric Soulas", "role": "Developer", "github": "https://github.com/cedricss", "picture": "https://avatars0.githubusercontent.com/u/802010", "twitter": "https://twitter.com/CedricSoulas", "website": "http://reactive.how/", "group": "Contributors" }, "jason": { "name": "Jason Aden", "role": "Developer", "github": "https://github.com/jasonaden", "picture": "https://avatars1.githubusercontent.com/u/516168", "twitter": "https://twitter.com/jasonaden1", "group": "Contributors" }, "jan-niklas": { "name": "Jan-Niklas Wortmann", "role": "Developer", "github": "https://github.com/JWO719", "picture": "https://avatars3.githubusercontent.com/u/6104311", "twitter": "https://twitter.com/niklas_wortmann", "group": "Core Team" }, "mladen": { "name": "Mladen Jakovljević", "role": "Developer", "github": "https://github.com/jakovljevic-mladen", "picture": "https://avatars3.githubusercontent.com/u/28087049", "twitter": "https://twitter.com/jakovljevicMla", "group": "Core Team" } } ================================================ FILE: apps/rxjs.dev/content/marketing/index.html ================================================
RxJS logo

RxJS

Reactive Extensions Library for JavaScript
Get Started API Docs

Version 7 released!

Here are a some of the benefits of running on the latest version

  • ~50% smaller
  • Improved typings
  • More consistent APIs
  • and much more...

If you want to know more about the breaking changes, click here...

Reactive Extensions Library for JavaScript

RxJS is a library for reactive programming using Observables, to make it easier to compose asynchronous or callback-based code. This project is a rewrite of Reactive-Extensions/RxJS with better performance, better modularity, better debuggable call stacks, while staying mostly backwards compatible, with some breaking changes that reduce the API surface


When participating in our community, you must follow our

Code of Conduct
================================================ FILE: apps/rxjs.dev/content/marketing/operator-decision-tree.html ================================================ ================================================ FILE: apps/rxjs.dev/content/marketing/team.html ================================================ ================================================ FILE: apps/rxjs.dev/content/navigation.json ================================================ { "TopBar": [ { "url": "guide/overview", "title": "Overview" }, { "url": "api", "title": "Reference" }, { "url": "team", "title": "Team" } ], "TopBarNarrow": [], "SideNav": [ { "url": "guide/overview", "title": "Overview", "tooltip": "RxJS Overview", "children": [ { "url": "guide/observable", "title": "Observables" }, { "url": "guide/observer", "title": "Observer" }, { "url": "guide/operators", "title": "Operators" }, { "url": "guide/subscription", "title": "Subscription" }, { "url": "guide/subject", "title": "Subjects" }, { "url": "guide/scheduler", "title": "Scheduler" }, { "url": "guide/testing/marble-testing", "title": "Marble Testing" } ] }, { "url": "guide/installation", "title": "Installation", "tooltip": "Installation" }, { "url": "guide/importing", "title": "Importing", "tooltip": "RxJS Importing" }, { "url": "api", "title": "Reference", "tooltip": "RxJS Reference" }, { "url": "guide/glossary-and-semantics", "title": "Glossary", "tooltip": "Glossary and Semantics" }, { "tooltip": "Operator Decision Tree", "url": "operator-decision-tree", "title": "Operator Decision Tree" }, { "title": "Deprecations & Breaking Changes", "children": [ { "url": "deprecations/breaking-changes", "title": "Breaking Changes" }, { "url": "deprecations/scheduler-argument", "title": "Scheduler Argument" }, { "url": "deprecations/subscribe-arguments", "title": "Subscribe Arguments" }, { "url": "deprecations/resultSelector", "title": "ResultSelector Arguments" }, { "url": "deprecations/array-argument", "title": "Array Arguments" }, { "url": "deprecations/multicasting", "title": "Multicasting" }, { "url": "deprecations/to-promise", "title": "Conversion to Promises" } ] }, { "url": "6-to-7-change-summary", "title": "Detailed Change List", "tooltip": "Detailed Change List" }, { "url": "code-of-conduct", "title": "Code of Conduct", "tooltip": "Code of Conduct" } ], "docVersions": [ { "title": "next", "url": "https://next.rxjs.dev/" }, { "title": "stable", "url": "https://rxjs.dev/" }, { "title": "v6", "url": "https://v6.rxjs.dev/" } ] } ================================================ FILE: apps/rxjs.dev/content/operator-decision-tree.yml ================================================ - label: 'I have one existing Observable, and' children: - label: I want to change each emitted value children: - label: to be a constant value children: - label: mapTo - label: to be a value calculated through a formula children: - label: map - label: I want to pick a property off each emitted value children: - label: map - label: I want to spy the values being emitted without affecting them children: - label: tap - label: I want to allow some values to pass children: - label: based on custom logic children: - label: filter - label: if they are at the start of the Observable children: - label: and only the first value children: - label: first - label: based on a given amount children: - label: take - label: based on custom logic children: - label: takeWhile - label: if they are exactly the n-th emission children: - label: elementAt - label: if they are at the end of the Observable children: - label: and only the last value children: - label: last - label: based on a given amount children: - label: takeLast - label: until another Observable emits a value children: - label: takeUntil - label: I want to ignore values children: - label: altogether children: - label: ignoreElements - label: from the start of the Observable children: - label: based on a given amount children: - label: skip - label: based on custom logic children: - label: skipWhile - label: from the end of the Observable children: - label: skipLast - label: until another Observable emits a value children: - label: skipUntil - label: that match some previous value children: - label: according to value equality children: - label: emitted just before the current value children: - label: distinctUntilChanged - label: emitted some time in the past children: - label: distinct - label: according to a key or object property children: - label: emitted just before the current value children: - label: distinctUntilKeyChanged - label: that occur too frequently children: - label: by emitting the first value in each time window children: - label: where time windows are determined by another Observable's emissions children: - label: throttle - label: where time windows are determined by a time duration children: - label: throttleTime - label: by emitting the last value in each time window children: - label: where time windows are determined by another Observable's emissions children: - label: audit - label: where time windows are determined by a time duration children: - label: auditTime - label: by emitting the last value as soon as enough silence has occurred children: - label: where the silence duration threshold is determined by another Observable children: - label: debounce - label: where the silence duration threshold is determined by a time duration children: - label: debounceTime - label: I want to compute a formula using all values emitted children: - label: and only output the final computed value children: - label: reduce - label: and output the computed values when the source emits a value children: - label: scan - label: and output the computed values as a nested Observable when the source emits a value children: - label: mergeScan - label: and output the computed values as a nested Observable when the source emits a value while unsubscribing from the previous nested Observable children: - label: switchScan - label: I want to wrap its messages with metadata children: - label: that describes each notification (next, error, or complete) children: - label: materialize - label: that includes the time past since the last emitted value children: - label: timeInterval - label: after a period of inactivity children: - label: I want to throw an error children: - label: timeout - label: I want to switch to another Observable children: - label: timeoutWith - label: I want to ensure there is only one value children: - label: single - label: I want to know how many values it emits children: - label: count - label: I want to prepend one value children: - label: startWith - label: I want to delay the emissions children: - label: based on a given amount of time children: - label: delay - label: based on the emissions of another Observable children: - label: delayWhen - label: I want to group the values children: - label: until the Observable completes children: - label: and convert to an array children: - label: toArray - label: and convert to a Promise children: - label: Observable method: toPromise - label: consecutively in pairs, as arrays children: - label: pairwise - label: 'based on a criterion, and output two Observables: those that match the criterion and those that do not' children: - label: partition - label: in batches of a particular size children: - label: and emit the group as an array children: - label: bufferCount - label: and emit the group as a nested Observable children: - label: windowCount - label: based on time children: - label: and emit the group as an array children: - label: bufferTime - label: and emit the group as a nested Observable children: - label: windowTime - label: until another Observable emits children: - label: and emit the group as an array children: - label: buffer - label: and emit the group as a nested Observable children: - label: window - label: based on the emissions of an Observable created on-demand children: - label: and emit the group as an array children: - label: bufferWhen - label: and emit the group as a nested Observable children: - label: windowWhen - label: based on another Observable for opening a group, and an Observable for closing a group children: - label: and emit the group as an array children: - label: bufferToggle - label: and emit the group as a nested Observable children: - label: windowToggle - label: based on a key calculated from the emitted values children: - label: groupBy - label: I want to start a new Observable for each value children: - label: and emit the values from all nested Observables in parallel children: - label: where the nested Observable is the same for every value children: - label: mergeMapTo - label: where the nested Observable is calculated for each value children: - label: mergeMap - label: and emit the values from each nested Observable in order children: - label: where the nested Observable is the same for every value children: - label: concatMapTo - label: where the nested Observable is calculated for each value children: - label: concatMap - label: and cancel the previous nested Observable when a new value arrives children: - label: where the nested Observable is the same for every value children: - label: switchMapTo - label: where the nested Observable is calculated for each value children: - label: switchMap - label: and ignore incoming values while the current nested Observable has not yet completed children: - label: exhaustMap - label: and recursively start a new Observable for each new value children: - label: expand - label: I want to perform custom operations children: - label: pipe - label: I want to share a subscription between multiple subscribers children: - label: using a conventional Subject children: - label: and start it as soon as the first subscriber arrives children: - label: share - label: and start it manually or imperatively children: - label: connectable - label: using a specific subject implementation children: - label: share - label: when an error occurs children: - label: I want to start a new Observable children: - label: catchError - label: I want to re-subscribe children: - label: immediately children: - label: retry - label: when another Observable emits children: - label: retryWhen - label: when it completes children: - label: I want to re-subscribe children: - label: immediately children: - label: repeat - label: when another Observable emits children: - label: repeatWhen - label: I want to start a new Observable children: - label: concat - label: when it completes, errors or unsubscribes, I want to execute a function children: - label: finalize - label: I want to change the scheduler children: - label: that routes calls to subscribe children: - label: subscribeOn - label: that routes values to observers children: - label: observeOn - label: I want to combine this Observable with others, and children: - label: I want to receive values only from the Observable that emits a value first children: - label: race - label: I want to output the values from either of them children: - label: merge - label: I want to output a value computed from values of the source Observables children: - label: using the latest value of each source whenever any source emits children: - label: combineLatest - label: using the latest value of each source only when the primary Observable emits children: - label: withLatestFrom - label: using each source value only once children: - label: zip - label: 'I have some Observables to combine together as one Observable, and' children: - label: I want to receive values only from the Observable that emits a value first children: - label: race - label: I want to be notified when all of them have completed children: - label: forkJoin - label: I want to output the values from either of them children: - label: merge - label: I want to output a value computed from values of the source Observables children: - label: using the latest value of each source whenever any source emits children: - label: combineLatest - label: using each source value only once children: - label: zip - label: I want to subscribe to each in order children: - label: concat - label: 'I have no Observables yet, and' children: - label: I want to create a new Observable children: - label: using custom logic children: - label: Observable method: create - label: using a state machine similar to a for loop children: - label: generate - label: that throws an error children: - label: throwError - label: that just completes, without emitting values children: - label: EMPTY - label: that never emits anything children: - label: NEVER - label: from an existing source of events children: - label: coming from the DOM or Node.js or similar children: - label: fromEvent - label: that uses an API to add and remove event handlers children: - label: fromEventPattern - label: from a Promise or an event source children: - label: from - label: that iterates children: - label: over the values in an array children: - label: from - label: over values in a numeric range children: - label: range - label: over prefined values given as arguments children: - label: of - label: that emits values on a timer children: - label: regularly children: - label: interval - label: with an optional initial delay children: - label: timer - label: which is built on demand when subscribed children: - label: defer - label: I want to convert a callback to an Observable children: - label: supporting a conventional callback API children: - label: bindCallback - label: supporting Node.js callback style API children: - label: bindNodeCallback ================================================ FILE: apps/rxjs.dev/database.rules.json ================================================ { "rules": { ".read": "auth != null", ".write": "auth != null" } } ================================================ FILE: apps/rxjs.dev/firebase.json ================================================ { "database": { "rules": "database.rules.json" }, "hosting": { "public": "dist", "target": "stable", "cleanUrls": true, "redirects": [ ////////////////////////////////////////////////////////////////////////////////////////////// // README: // Redirects must also be handled by the ServiceWorker. If you add a redirect rule here, // make sure it is compatible with the configuration in `ngsw-config.json`. ////////////////////////////////////////////////////////////////////////////////////////////// // Strip off the `.html` extension, because Firebase will not do this automatically any more // (unless the new URL points to an existing file, which is not necessarily the case here). { "type": 301, "source": "/:somePath*/:file.html", "destination": "/:somePath*/:file" }, { "type": 301, "source": "/:topLevelFile.html", "destination": "/:topLevelFile" }, { "type": 301, "source": "/guide/v6/migration", "destination": "https://v6.rxjs.dev/guide/v6/migration" }, { "type": 301, "source": "/guide/v6/pipeable-operators", "destination": "https://v6.rxjs.dev/guide/v6/pipeable-operators" } ], "rewrites": [ { "source": "**/!(*.*)", "destination": "/index.html" } ], "headers": [ { "source": "/", "headers": [ { "key": "Link", "value": ";rel=preload;as=fetch,;rel=preload;as=fetch" } ] } ] } } ================================================ FILE: apps/rxjs.dev/ngsw-config.json ================================================ { "index": "/index.html", "assetGroups": [ { "name": "app-shell", "installMode": "prefetch", "updateMode": "prefetch", "resources": { "files": [ "/index.html", "/pwa-manifest.json", "/app/search/search-worker.js", "/assets/images/favicons/favicon.ico", "/assets/js/*.js", "/*.css", "/*.js" ], "urls": [ "https://fonts.googleapis.com/**", "https://fonts.gstatic.com/s/**", "https://maxcdn.bootstrapcdn.com/**" ] } }, { "name": "assets-eager", "installMode": "prefetch", "updateMode": "prefetch", "resources": { "files": [ "/assets/images/**", "/generated/images/marketing/**", "!/assets/images/favicons/**", "!/**/_unused/**", "!/assets/images/marble-diagrams/**" ] } }, { "name": "assets-lazy", "installMode": "lazy", "updateMode": "prefetch", "resources": { "files": [ "/assets/images/favicons/**", "!/**/_unused/**" ] } }, { "name": "docs-index", "installMode": "prefetch", "updateMode": "prefetch", "resources": { "files": [ "/generated/*.json", "/generated/docs/*.json", "/generated/docs/api/api-list.json", "/generated/docs/app/search-data.json" ] } }, { "name": "docs-lazy", "installMode": "lazy", "updateMode": "lazy", "resources": { "files": [ "/generated/docs/**/*.json", "/generated/images/**", "!/**/_unused/**" ] } }, { "name": "marble-diagrams", "installMode": "lazy", "updateMode": "lazy", "resources": { "files": [ "/assets/images/marble-diagrams/**" ] } } ] } ================================================ FILE: apps/rxjs.dev/package.json ================================================ { "name": "rxjs.dev", "version": "1.0.0", "main": "index.js", "repository": "git@github.com:ReactiveX/rxjs.git", "author": "RxJS", "license": "MIT", "scripts": { "ng": "ng", "firebase": "firebase", "start": "ng serve --configuration=fast", "start:docker": "ng serve --configuration=stable --host 0.0.0.0", "setup": "yarn ~~clean-generated && yarn docs", "prebuild": "yarn setup", "build": "yarn ~~build", "lint": "yarn docs-lint && ng lint && yarn tools-lint", "test": "ng test", "pree2e": "yarn update-webdriver", "e2e": "ng e2e --no-webdriver-update", "e2e-prod": "yarn e2e --environment=dev --target=production", "http-server": "http-server", "test-pwa-score-localhost": "concurrently --kill-others --success first \"http-server dist -p 4200 --silent\" \"yarn test-pwa-score http://localhost:4200 90\"", "test-pwa-score": "node scripts/test-pwa-score", "deploy-production": "scripts/deploy-to-firebase.sh", "payload-size": "scripts/payload.sh", "docs": "npx ts-node -P tsconfig.docs.json ../../node_modules/dgeni/lib/gen-docs.js ./tools/transforms/angular.io-package", "docs-watch": "npx ts-node -P tsconfig.docs.json tools/transforms/authors-package/watchr.js", "docs-lint": "eslint --ignore-path=\"tools/transforms/.eslintignore\" tools/transforms", "docs-test": "npx ts-node -P tsconfig.docs.json tools/transforms/test.js", "firebase-utils-test": "jasmine-ts tools/firebase-test-utils/*.spec.ts", "tools-lint": "eslint tools/firebase-test-utils", "tools-test": "./scripts/deploy-to-firebase.test.sh && yarn docs-test && yarn firebase-utils-test", "preserve-and-sync": "yarn docs", "serve-and-sync": "concurrently --kill-others \"yarn docs-watch --watch-only\" \"yarn start\"", "update-webdriver": "webdriver-manager update --standalone false --gecko false $CHROMEDRIVER_VERSION_ARG", "~~build": "ng build --configuration=stable --source-map", "~~clean-generated": "node --eval \"require('shelljs').rm('-rf', 'src/generated')\"", "build:marbles": "ts-node -P ./tools/marbles/tsconfig.marbles.json ./tools/marbles/scripts/index.ts" }, "engines": { "node": ">=10.9" }, "private": true, "dependencies": { "@angular/animations": "^13.1.1", "@angular/cdk": "^13.1.1", "@angular/common": "^13.1.1", "@angular/compiler": "^13.1.1", "@angular/core": "^13.1.1", "@angular/elements": "^13.1.1", "@angular/forms": "^13.1.1", "@angular/material": "^13.1.1", "@angular/platform-browser": "^13.1.1", "@angular/platform-browser-dynamic": "^13.1.1", "@angular/router": "^13.1.1", "@angular/service-worker": "^13.1.1", "@stackblitz/sdk": "^1.5.3", "@webcomponents/custom-elements": "^1.5.0", "core-js": "^3.33.3", "eyes.selenium": "^3.7.0", "rxjs": "^7.5.1", "tslib": "^2.3.1", "zone.js": "~0.11.4" }, "devDependencies": { "@angular-devkit/build-angular": "~13.1.2", "@angular-eslint/builder": "^13.0.0", "@angular-eslint/eslint-plugin": "^13.0.0", "@angular-eslint/eslint-plugin-template": "^13.0.0", "@angular-eslint/template-parser": "^13.0.0", "@angular/cli": "^13.1.2", "@angular/compiler-cli": "^13.1.1", "@jsdevtools/rehype-inline-svg": "^1.1.1", "@swirly/parser": "^0.18.1", "@swirly/renderer-node": "^0.18.2", "@swirly/types": "^0.18.1", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "^2.0.3", "@types/lunr": "^2.3.3", "@types/node": "^20.11.0", "@types/svgo": "^1.3.3", "@types/trusted-types": "^2.0.7", "@types/yamljs": "^0.2.34", "@typescript-eslint/eslint-plugin": "^6.9.0", "@typescript-eslint/parser": "^6.9.1", "archiver": "^3.0.0", "canonical-path": "^1.0.0", "chalk": "^2.1.0", "cjson": "^0.5.0", "concurrently": "^5.3.0", "cross-spawn": "^6.0.5", "css-selector-parser": "^1.3.0", "dgeni": "^0.4.14", "dgeni-packages": "^0.30.0", "eslint": "^8.0.0", "eslint-plugin-import": "^2.23.4", "eslint-plugin-jasmine": "^4.1.2", "eslint-plugin-jsdoc": "^46.8.2", "eslint-plugin-prefer-arrow": "^1.2.3", "firebase-tools": "^9.3.0", "fs-extra": "^8.0.1", "globby": "^9.2.0", "hast-util-is-element": "^1.0.3", "hast-util-to-string": "^1.0.2", "html": "^1.0.0", "http-server": "^0.12.3", "ignore": "^5.1.2", "image-size": "^0.7.4", "jasmine": "^4.1.0", "jasmine-core": "~4.1.0", "jasmine-marbles": "^0.9.2", "jasmine-spec-reporter": "~7.0.0", "jasmine-ts": "^0.4.0", "jsdom": "^15.1.1", "karma": "~6.3.16", "karma-chrome-launcher": "~3.1.0", "karma-cli": "^2.0.0", "karma-coverage-istanbul-reporter": "~3.0.2", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "lighthouse": "^7.0.1", "lodash": "^4.17.20", "lunr": "^2.1.0", "protractor": "~7.0.0", "rehype-slug": "^2.0.3", "remark": "^12.0.1", "remark-html": "^13.0.2", "rimraf": "^2.6.1", "semver": "^6.1.1", "shelljs": "^0.8.3", "stemmer": "^2.0.0", "svgo": "^1.3.2", "svgson": "^4.1.0", "tree-kill": "^1.2.2", "ts-node": "^8.2.0", "typescript": "4.5.4", "unist-util-filter": "^1.0.2", "unist-util-source": "^1.0.5", "unist-util-visit": "^1.4.1", "unist-util-visit-parents": "^2.1.2", "watchr": "^4.1.0", "xregexp": "^4.0.0", "yamljs": "^0.3.0", "yargs": "^13.2.4" }, "nx": { "implicitDependencies": [ "rxjs" ] } } ================================================ FILE: apps/rxjs.dev/scripts/_payload-limits.json ================================================ { "aio": { "master": { "uncompressed": { "inline": 1971, "main": 567849, "polyfills": 40272, "prettify": 14886 } } } } ================================================ FILE: apps/rxjs.dev/scripts/deploy-to-firebase.sh ================================================ #!/usr/bin/env bash # WARNING: FIREBASE_TOKEN should NOT be printed. set +x -eu -o pipefail ## Only deploy if this not a PR. PRs are deployed early in `build.sh`. if [[ $TRAVIS_PULL_REQUEST != "false" ]]; then echo "Skipping deploy because this is a PR build." exit 0 fi # Do not deploy if the current commit is not the latest on its branch. readonly LATEST_COMMIT=$(git ls-remote origin $TRAVIS_BRANCH | cut -c1-40) if [[ $TRAVIS_COMMIT != $LATEST_COMMIT ]]; then echo "Skipping deploy because $TRAVIS_COMMIT is not the latest commit ($LATEST_COMMIT)." exit 0 fi # The deployment mode is computed based on the branch we are building if [[ $TRAVIS_BRANCH == master ]]; then readonly deployEnv=next #elif [[ $TRAVIS_BRANCH == $STABLE_BRANCH ]]; then # readonly deployEnv=stable #else # # Extract the major versions from the branches, e.g. the 4 from 4.3.x # readonly majorVersion=${TRAVIS_BRANCH%%.*} # readonly majorVersionStable=${STABLE_BRANCH%%.*} # # # Do not deploy if the major version is not less than the stable branch major version # if [[ !( "$majorVersion" < "$majorVersionStable" ) ]]; then # echo "Skipping deploy of branch \"${TRAVIS_BRANCH}\" to firebase." # echo "We only deploy archive branches with the major version less than the stable branch: \"${STABLE_BRANCH}\"" # exit 0 # fi # # # Find the branch that has highest minor version for the given `$majorVersion` # readonly mostRecentMinorVersion=$( # # List the branches that start with the major version # git ls-remote origin refs/heads/${majorVersion}.*.x | # # Extract the version number # awk -F'/' '{print $3}' | # # Sort by the minor version # sort -t. -k 2,2n | # # Get the highest version # tail -n1 # ) # # # Do not deploy as it is not the latest branch for the given major version # if [[ $TRAVIS_BRANCH != $mostRecentMinorVersion ]]; then # echo "Skipping deploy of branch \"${TRAVIS_BRANCH}\" to firebase." # echo "There is a more recent branch with the same major version: \"${mostRecentMinorVersion}\"" # exit 0 # fi # # readonly deployEnv=archive fi case $deployEnv in next) readonly projectId=rxjs-dev readonly deployedUrl=https://rxjs.dev readonly firebaseToken=$FIREBASE_TOKEN ;; # stable) # readonly projectId=beta-rxjsdocs # readonly deployedUrl=https://beta-rxjsdocs.firebaseapp.com/ # readonly firebaseToken=$FIREBASE_TOKEN # ;; # archive) # readonly projectId=angular-io-${majorVersion} # readonly deployedUrl=https://v${majorVersion}.angular.io/ # readonly firebaseToken=$FIREBASE_TOKEN # ;; esac echo "Git branch : $TRAVIS_BRANCH" echo "Build/deploy mode : $deployEnv" echo "Firebase project : $projectId" echo "Deployment URL : $deployedUrl" if [[ ${1:-} == "--dry-run" ]]; then exit 0 fi # Deploy ( cd "`dirname $0`/.." # Build the app yarn build --env=$deployEnv # Include any mode-specific files cp -rf src/extra-files/$deployEnv/. dist/ # Check payload size # yarn payload-size # Deploy to Firebase firebase use "$projectId" --token "$firebaseToken" firebase deploy --message "Commit: $TRAVIS_COMMIT" --non-interactive --token "$firebaseToken" # Run PWA-score tests # yarn test-pwa-score "$deployedUrl" "$MIN_PWA_SCORE" ) ================================================ FILE: apps/rxjs.dev/scripts/deploy-to-firebase.test.sh ================================================ #!/usr/bin/env bash set +x -eu -o pipefail function check { if [[ $1 == $2 ]]; then echo Pass exit 0 fi echo Fail echo ---- Expected ---- echo "$2" echo ---- Actual ---- echo "$1" exit 1 } ( echo ===== master - skip deploy - pull request actual=$( export TRAVIS_PULL_REQUEST=true `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy because this is a PR build." check "$actual" "$expected" ) ( echo ===== master - deploy success actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=master export TRAVIS_COMMIT=$(git ls-remote origin master | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Git branch : master Build/deploy mode : next Firebase project : aio-staging Deployment URL : https://next.angular.io/" check "$actual" "$expected" ) ( echo ===== master - skip deploy - commit not HEAD actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=master export TRAVIS_COMMIT=DUMMY_TEST_COMMIT `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin master | cut -c1-40))." check "$actual" "$expected" ) ( echo ===== stable - deploy success actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=4.3.x export STABLE_BRANCH=4.3.x export TRAVIS_COMMIT=$(git ls-remote origin 4.3.x | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Git branch : 4.3.x Build/deploy mode : stable Firebase project : angular-io Deployment URL : https://angular.io/" check "$actual" "$expected" ) ( echo ===== stable - skip deploy - commit not HEAD actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=4.3.x export STABLE_BRANCH=4.3.x export TRAVIS_COMMIT=DUMMY_TEST_COMMIT `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin 4.3.x | cut -c1-40))." check "$actual" "$expected" ) ( echo ===== archive - deploy success actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=2.4.x export STABLE_BRANCH=4.3.x export TRAVIS_COMMIT=$(git ls-remote origin 2.4.x | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Git branch : 2.4.x Build/deploy mode : archive Firebase project : angular-io-2 Deployment URL : https://v2.angular.io/" check "$actual" "$expected" ) ( echo ===== archive - skip deploy - commit not HEAD actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=2.4.x export STABLE_BRANCH=4.3.x export TRAVIS_COMMIT=DUMMY_TEST_COMMIT export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin 2.4.x | cut -c1-40))." check "$actual" "$expected" ) ( echo ===== archive - skip deploy - major version too high, lower minor actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=2.1.x export STABLE_BRANCH=2.2.x export TRAVIS_COMMIT=$(git ls-remote origin 2.1.x | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy of branch \"2.1.x\" to firebase. We only deploy archive branches with the major version less than the stable branch: \"2.2.x\"" check "$actual" "$expected" ) ( echo ===== archive - skip deploy - major version too high, higher minor actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=2.4.x export STABLE_BRANCH=2.2.x export TRAVIS_COMMIT=$(git ls-remote origin 2.4.x | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy of branch \"2.4.x\" to firebase. We only deploy archive branches with the major version less than the stable branch: \"2.2.x\"" check "$actual" "$expected" ) ( echo ===== archive - skip deploy - minor version too low actual=$( export TRAVIS_PULL_REQUEST=false export TRAVIS_BRANCH=2.1.x export STABLE_BRANCH=4.3.x export TRAVIS_COMMIT=$(git ls-remote origin 2.1.x | cut -c-40) export FIREBASE_TOKEN=XXXXX `dirname $0`/deploy-to-firebase.sh --dry-run ) expected="Skipping deploy of branch \"2.1.x\" to firebase. There is a more recent branch with the same major version: \"2.4.x\"" check "$actual" "$expected" ) ================================================ FILE: apps/rxjs.dev/scripts/payload.sh ================================================ #!/usr/bin/env bash set -eu -o pipefail readonly thisDir=$(cd $(dirname $0); pwd) readonly parentDir=$(dirname $thisDir) # Track payload size functions source ../scripts/ci/payload-size.sh trackPayloadSize "aio" "dist/*.js" true true "${thisDir}/_payload-limits.json" ================================================ FILE: apps/rxjs.dev/scripts/publish-docs.sh ================================================ #!/usr/bin/env bash readonly projectId=rxjs-dev readonly deployedUrl=https://rxjs-dev.firebaseapp.com readonly firebaseToken=$FIREBASE_TOKEN # Deploy ( cd "`dirname $0`/.." # Build the app yarn build --env=stable # Include any mode-specific files cp -rf src/extra-files/$deployEnv/. dist/ # Deploy to Firebase yarn firebase -- login yarn firebase -- use "$projectId" yarn firebase -- deploy --message "Deploy docs automatically" --non-interactive yarn firebase -- logout ) ================================================ FILE: apps/rxjs.dev/scripts/test-pwa-score.js ================================================ #!/bin/env node /** * Usage: * node scripts/test-pwa-score [] * * Fails if the score is below ``. * If `` is defined, the full results will be logged there. * * (Skips HTTPS-related audits, when run for HTTP URL.) */ // Imports const lighthouse = require('lighthouse'); const chromeLauncher = require('lighthouse/chrome-launcher'); const printer = require('lighthouse/lighthouse-cli/printer'); const config = require('lighthouse/lighthouse-core/config/default.js'); // Constants const CHROME_LAUNCH_OPTS = {}; const SKIPPED_HTTPS_AUDITS = ['redirects-http']; const VIEWER_URL = 'https://googlechrome.github.io/lighthouse/viewer/'; // Specify the path and flags for Chrome on Travis if (process.env.TRAVIS) { process.env.LIGHTHOUSE_CHROMIUM_PATH = process.env.CHROME_BIN; CHROME_LAUNCH_OPTS.chromeFlags = ['--no-sandbox']; } // Run _main(process.argv.slice(2)); // Functions - Definitions function _main(args) { const {url, minScore, logFile} = parseInput(args); const isOnHttp = /^http:/.test(url); console.log(`Running PWA audit for '${url}'...`); if (isOnHttp) { skipHttpsAudits(config); } launchChromeAndRunLighthouse(url, {}, config). then(results => processResults(results, logFile)). then(score => evaluateScore(minScore, score)). catch(onError); } function evaluateScore(expectedScore, actualScore) { console.log('Lighthouse PWA score:'); console.log(` - Expected: ${expectedScore} / 100 (or higher)`); console.log(` - Actual: ${actualScore} / 100`); if (actualScore < expectedScore) { throw new Error(`PWA score is too low. (${actualScore} < ${expectedScore})`); } } function launchChromeAndRunLighthouse(url, flags, config) { return chromeLauncher.launch(CHROME_LAUNCH_OPTS).then(chrome => { flags.port = chrome.port; return lighthouse(url, flags, config). then(results => chrome.kill().then(() => results)). catch(err => chrome.kill().then(() => { throw err; }, () => { throw err; })); }); } function onError(err) { console.error(err); process.exit(1); } function parseInput(args) { const url = args[0]; const minScore = Number(args[1]); const logFile = args[2]; if (!url) { onError('Invalid arguments: not specified.'); } else if (isNaN(minScore)) { onError('Invalid arguments: not specified or not a number.'); } return {url, minScore, logFile}; } function processResults(results, logFile) { let promise = Promise.resolve(); if (logFile) { console.log(`Saving results in '${logFile}'...`); console.log(`(LightHouse viewer: ${VIEWER_URL})`); // Remove the artifacts, which are not necessary for the report. // (Saves ~1,500,000 lines of formatted JSON output \o/) results.artifacts = undefined; promise = printer.write(results, 'json', logFile); } return promise.then(() => Math.round(results.score)); } function skipHttpsAudits(config) { console.info(`Skipping HTTPS-related audits (${SKIPPED_HTTPS_AUDITS.join(', ')})...`); config.settings.skipAudits = SKIPPED_HTTPS_AUDITS; } ================================================ FILE: apps/rxjs.dev/src/app/app.component.ts ================================================ import { Component, ElementRef, HostBinding, HostListener, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { MatSidenav } from '@angular/material/sidenav'; import { CurrentNodes, NavigationService, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { Deployment } from 'app/shared/deployment.service'; import { LocationService } from 'app/shared/location.service'; import { NotificationComponent } from 'app/layout/notification/notification.component'; import { ScrollService } from 'app/shared/scroll.service'; import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; import { SearchResults } from 'app/search/interfaces'; import { SearchService } from 'app/search/search.service'; import { TocService } from 'app/shared/toc.service'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Inject } from '@angular/core'; import { DOCUMENT } from '@angular/common'; const sideNavView = 'SideNav'; @Component({ selector: 'aio-shell', template: `
#BlackLivesMatter Home Home
View on GitHub
`, }) export class AppComponent implements OnInit { currentDocument: DocumentContents; currentDocVersion: NavigationNode; currentNodes: CurrentNodes = {}; currentPath: string; docVersions: NavigationNode[]; dtOn = false; footerNodes: NavigationNode[]; /** * An HTML friendly identifier for the currently displayed page. * This is computed from the `currentDocument.id` by replacing `/` with `-` */ pageId: string; /** * An HTML friendly identifer for the "folder" of the currently displayed page. * This is computed by taking everything up to the first `/` in the `currentDocument.id` */ folderId: string; /** * These CSS classes are computed from the current state of the application * (e.g. what document is being viewed) to allow for fine grain control over * the styling of individual pages. * You will get three classes: * * * `page-...`: computed from the current document id (e.g. events, guide-security, tutorial-toh-pt2) * * `folder-...`: computed from the top level folder for an id (e.g. guide, tutorial, etc) * * `view-...`: computed from the navigation view (e.g. SideNav, TopBar, etc) */ @HostBinding('class') hostClasses = ''; // Disable all Angular animations for the initial render. @HostBinding('@.disabled') isStarting = true; isTransitioning = true; isFetching = false; isSideBySide = false; private isFetchingTimeout: any; private isSideNavDoc = false; private sideBySideWidth = 992; sideNavNodes: NavigationNode[]; topMenuNodes: NavigationNode[]; topMenuNarrowNodes: NavigationNode[]; hasFloatingToc = false; private showFloatingToc = new BehaviorSubject(false); private showFloatingTocWidth = 800; tocMaxHeight: string; private tocMaxHeightOffset = 0; versionInfo: VersionInfo; get isOpened() { return this.isSideBySide && this.isSideNavDoc; } get mode() { return this.isSideBySide ? 'side' : 'over'; } // Search related properties showSearchResults = false; searchResults: Observable; @ViewChildren('searchBox, searchResultsView', { read: ElementRef }) searchElements: QueryList; @ViewChild(SearchBoxComponent, { static: true }) searchBox: SearchBoxComponent; @ViewChild(MatSidenav, { static: true }) sidenav: MatSidenav; @ViewChild(NotificationComponent, { static: true }) notification: NotificationComponent; notificationAnimating = false; constructor( public deployment: Deployment, private documentService: DocumentService, private hostElement: ElementRef, private locationService: LocationService, private navigationService: NavigationService, private scrollService: ScrollService, private searchService: SearchService, private tocService: TocService, @Inject(DOCUMENT) private dom: Document ) {} ngOnInit() { // Do not initialize the search on browsers that lack web worker support if ('Worker' in window) { // Delay initialization by up to 2 seconds this.searchService.initWorker(2000); } this.onResize(window.innerWidth); /* No need to unsubscribe because this root component never dies */ this.documentService.currentDocument.subscribe((doc) => (this.currentDocument = doc)); this.locationService.currentPath.subscribe((path) => { if (path === this.currentPath) { // scroll only if on same page (most likely a change to the hash) this.scrollService.scroll(); } else { // don't scroll; leave that to `onDocRendered` this.currentPath = path; // Start progress bar if doc not rendered within brief time clearTimeout(this.isFetchingTimeout); this.isFetchingTimeout = setTimeout(() => (this.isFetching = true), 200); } }); this.navigationService.currentNodes.subscribe((currentNodes) => { this.currentNodes = currentNodes; }); // Compute the version picker list from the current version and the versions in the navigation map combineLatest( this.navigationService.versionInfo, this.navigationService.navigationViews.pipe(map((views) => views.docVersions)) ).subscribe(([versionInfo, versions]) => { this.docVersions = [...versions]; // Find the current version - either title matches the current deployment mode // or its title matches the major version of the current version info this.currentDocVersion = this.docVersions.find( (version) => version.title === this.deployment.mode || version.title === `v${versionInfo.major}` )!; this.currentDocVersion.title += ` (v${versionInfo.raw})`; }); this.navigationService.navigationViews.subscribe((views) => { this.footerNodes = views.Footer || []; this.sideNavNodes = views.SideNav || []; this.topMenuNodes = views.TopBar || []; this.topMenuNarrowNodes = views.TopBarNarrow || this.topMenuNodes; }); this.navigationService.versionInfo.subscribe((vi) => (this.versionInfo = vi)); const hasNonEmptyToc = this.tocService.tocList.pipe(map((tocList) => tocList.length > 0)); combineLatest(hasNonEmptyToc, this.showFloatingToc).subscribe( ([hasToc, showFloatingToc]) => (this.hasFloatingToc = hasToc && showFloatingToc) ); // Generally, we want to delay updating the shell (e.g. host classes, sidenav state) for the new // document, until after the leaving document has been removed (to avoid having the styles for // the new document applied prematurely). // For the first document, though, (when we know there is no previous document), we want to // ensure the styles are applied as soon as possible to avoid flicker. combineLatest( this.documentService.currentDocument, // ...needed to determine host classes this.navigationService.currentNodes ) // ...needed to determine `sidenav` state .pipe(first()) .subscribe(() => this.updateShell()); } onDocReady() { // About to transition to new view. this.isTransitioning = true; // Stop fetching timeout (which, when render is fast, means progress bar never shown) clearTimeout(this.isFetchingTimeout); // If progress bar has been shown, keep it for at least 500ms (to avoid flashing). setTimeout(() => (this.isFetching = false), 500); } onDocRemoved() { this.scrollService.removeStoredScrollPosition(); } onDocInserted() { // Update the shell (host classes, sidenav state) to match the new document. // This may be called as a result of actions initiated by view updates. // In order to avoid errors (e.g. `ExpressionChangedAfterItHasBeenChecked`), updating the view // (e.g. sidenav, host classes) needs to happen asynchronously. setTimeout(() => this.updateShell()); // Scroll the good position depending on the context this.scrollService.scrollAfterRender(500); } onDocRendered() { if (this.isStarting) { // In order to ensure that the initial sidenav-content left margin // adjustment happens without animation, we need to ensure that // `isStarting` remains `true` until the margin change is triggered. // (Apparently, this happens with a slight delay.) setTimeout(() => (this.isStarting = false), 100); } const head = this.dom.getElementsByTagName('head')[0]; let element: HTMLLinkElement | null = this.dom.querySelector('link[rel=\'canonical\']') || null; if (element === null) { element = this.dom.createElement('link') as HTMLLinkElement; head.appendChild(element); } element.setAttribute('rel', 'canonical'); element.setAttribute('href', `https://rxjs.dev/${this.currentPath}`); this.isTransitioning = false; } onDocVersionChange(versionIndex: number) { const version = this.docVersions[versionIndex]; if (version.url) { this.locationService.go(version.url); } } @HostListener('window:resize', ['$event.target.innerWidth']) onResize(width: number) { this.isSideBySide = width >= this.sideBySideWidth; this.showFloatingToc.next(width > this.showFloatingTocWidth); if (this.isSideBySide && !this.isSideNavDoc) { // If this is a non-sidenav doc and the screen is wide enough so that we can display menu // items in the top-bar, ensure the sidenav is closed. // (This condition can only be met when the resize event changes the value of `isSideBySide` // from `false` to `true` while on a non-sidenav doc.) this.sidenav.toggle(false); } } @HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey']) onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean { // Hide the search results if we clicked outside both the "search box" and the "search results" if (!this.searchElements.some((element) => element.nativeElement.contains(eventTarget))) { this.hideSearchResults(); } // Show developer source view if the footer is clicked while holding the meta and alt keys if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) { this.dtOn = !this.dtOn; return false; } // Deal with anchor clicks; climb DOM tree until anchor found (or null) let target: HTMLElement | null = eventTarget; while (target && !(target instanceof HTMLAnchorElement)) { target = target.parentElement; } if (target instanceof HTMLAnchorElement) { return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey); } // Allow the click to pass through return true; } setPageId(id: string) { // Special case the home page this.pageId = id === 'index' ? 'home' : id.replace('/', '-'); } setFolderId(id: string) { // Special case the home page this.folderId = id === 'index' ? 'home' : id.split('/', 1)[0]; } notificationDismissed() { this.notificationAnimating = true; // this should be kept in sync with the animation durations in: // - aio/src/styles/2-modules/_notification.scss // - aio/src/app/layout/notification/notification.component.ts setTimeout(() => (this.notificationAnimating = false), 250); this.updateHostClasses(); } updateHostClasses() { const mode = `mode-${this.deployment.mode}`; const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`; const pageClass = `page-${this.pageId}`; const folderClass = `folder-${this.folderId}`; const viewClasses = Object.keys(this.currentNodes) .map((view) => `view-${view}`) .join(' '); const notificationClass = `aio-notification-${this.notification.showNotification}`; const notificationAnimatingClass = this.notificationAnimating ? 'aio-notification-animating' : ''; this.hostClasses = [mode, sideNavOpen, pageClass, folderClass, viewClasses, notificationClass, notificationAnimatingClass].join(' '); } updateShell() { // Update the SideNav state (if necessary). this.updateSideNav(); // Update the host classes. this.setPageId(this.currentDocument.id); this.setFolderId(this.currentDocument.id); this.updateHostClasses(); } updateSideNav() { // Preserve current sidenav open state by default. let openSideNav = this.sidenav.opened; const isSideNavDoc = !!this.currentNodes[sideNavView]; if (this.isSideNavDoc !== isSideNavDoc) { // View type changed. Is it now a sidenav view (e.g, guide or tutorial)? // Open if changed to a sidenav doc; close if changed to a marketing doc. openSideNav = this.isSideNavDoc = isSideNavDoc; } // May be open or closed when wide; always closed when narrow. this.sidenav.toggle(this.isSideBySide && openSideNav); } // Dynamically change height of table of contents container @HostListener('window:scroll') onScroll() { if (!this.tocMaxHeightOffset) { // Must wait until `mat-toolbar` is measurable. const el = this.hostElement.nativeElement as Element; const headerEl = el.querySelector('.app-toolbar'); const footerEl = el.querySelector('footer'); if (headerEl && footerEl) { this.tocMaxHeightOffset = headerEl.clientHeight + footerEl.clientHeight + 24; // fudge margin } } this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2); } // Restrain scrolling inside an element, when the cursor is over it restrainScrolling(evt: WheelEvent) { const elem = evt.currentTarget as Element; const scrollTop = elem.scrollTop; if (evt.deltaY < 0) { // Trying to scroll up: Prevent scrolling if already at the top. if (scrollTop < 1) { evt.preventDefault(); } } else { // Trying to scroll down: Prevent scrolling if already at the bottom. const maxScrollTop = elem.scrollHeight - elem.clientHeight; if (maxScrollTop - scrollTop < 1) { evt.preventDefault(); } } } // Search related methods and handlers hideSearchResults() { this.showSearchResults = false; const oldSearch = this.locationService.search(); if (oldSearch.search !== undefined) { this.locationService.setSearch('', { ...oldSearch, search: undefined }); } } focusSearchBox() { if (this.searchBox) { this.searchBox.focus(); } } doSearch(query: string) { this.searchResults = this.searchService.search(query); this.showSearchResults = !!query; } @HostListener('document:keyup', ['$event.key', '$event.which']) onKeyUp(key: string, keyCode: number) { // forward slash "/" if (key === '/' || keyCode === 191) { this.focusSearchBox(); } if (key === 'Escape' || keyCode === 27) { // escape key if (this.showSearchResults) { this.hideSearchResults(); this.focusSearchBox(); } } } } ================================================ FILE: apps/rxjs.dev/src/app/app.module.ts ================================================ import { BrowserModule } from '@angular/platform-browser'; import { ErrorHandler, NgModule } from '@angular/core'; import { HttpClientModule } from '@angular/common/http'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ServiceWorkerModule } from '@angular/service-worker'; import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; import { AppComponent } from 'app/app.component'; import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry'; import { Deployment } from 'app/shared/deployment.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { DtComponent } from 'app/layout/doc-viewer/dt.component'; import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component'; import { GaService } from 'app/shared/ga.service'; import { Logger } from 'app/shared/logger.service'; import { LocationService } from 'app/shared/location.service'; import { NavigationService } from 'app/navigation/navigation.service'; import { DocumentService } from 'app/documents/document.service'; import { SearchService } from 'app/search/search.service'; import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component'; import { FooterComponent } from 'app/layout/footer/footer.component'; import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component'; import { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; import { ReportingErrorHandler } from 'app/shared/reporting-error-handler'; import { ScrollService } from 'app/shared/scroll.service'; import { ScrollSpyService } from 'app/shared/scroll-spy.service'; import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; import { NotificationComponent } from 'app/layout/notification/notification.component'; import { TocService } from 'app/shared/toc.service'; import { CurrentDateToken, currentDateProvider } from 'app/shared/current-date'; import { WindowToken, windowProvider } from 'app/shared/window'; import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; import { SharedModule } from 'app/shared/shared.module'; import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module'; import {environment} from '../environments/environment'; // These are the hardcoded inline svg sources to be used by the `` component. export const svgIconProviders = [ { provide: SVG_ICONS, useValue: { name: 'close', svgSource: '' + '' + '' + '', }, multi: true, }, { provide: SVG_ICONS, useValue: { name: 'error_outline', svgSource: '' + '' + // eslint-disable-next-line max-len '' + '', }, multi: true, }, { provide: SVG_ICONS, useValue: { name: 'insert_comment', svgSource: '' + '' + '' + '', }, multi: true, }, { provide: SVG_ICONS, useValue: { name: 'keyboard_arrow_right', svgSource: '' + '' + '', }, multi: true, }, { provide: SVG_ICONS, useValue: { name: 'menu', svgSource: '' + '' + '', }, multi: true, }, ]; @NgModule({ imports: [ BrowserModule, BrowserAnimationsModule, CustomElementsModule, HttpClientModule, MatButtonModule, MatIconModule, MatProgressBarModule, MatSidenavModule, MatToolbarModule, SwUpdatesModule, SharedModule, ServiceWorkerModule.register('/ngsw-worker.js', {enabled: environment.production}), ], declarations: [ AppComponent, DocViewerComponent, DtComponent, FooterComponent, ModeBannerComponent, NavMenuComponent, NavItemComponent, SearchBoxComponent, NotificationComponent, TopMenuComponent, ], providers: [ Deployment, DocumentService, { provide: ErrorHandler, useClass: ReportingErrorHandler }, GaService, Logger, Location, { provide: LocationStrategy, useClass: PathLocationStrategy }, LocationService, { provide: MatIconRegistry, useClass: CustomIconRegistry }, NavigationService, ScrollService, ScrollSpyService, SearchService, svgIconProviders, TocService, { provide: CurrentDateToken, useFactory: currentDateProvider }, { provide: WindowToken, useFactory: windowProvider }, ], bootstrap: [ AppComponent ] }) export class AppModule { } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/announcement-bar/announcement-bar.component.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { AnnouncementBarComponent } from './announcement-bar.component'; const today = new Date(); const lastWeek = changeDays(today, -7); const yesterday = changeDays(today, -1); const tomorrow = changeDays(today, 1); const nextWeek = changeDays(today, 7); describe('AnnouncementBarComponent', () => { let element: HTMLElement; let fixture: ComponentFixture; let component: AnnouncementBarComponent; let httpMock: HttpTestingController; let mockLogger: MockLogger; beforeEach(() => { const injector = TestBed.configureTestingModule({ imports: [HttpClientTestingModule], declarations: [AnnouncementBarComponent], providers: [{ provide: Logger, useClass: MockLogger }] }); httpMock = injector.get(HttpTestingController); mockLogger = injector.get(Logger); fixture = TestBed.createComponent(AnnouncementBarComponent); component = fixture.componentInstance; element = fixture.nativeElement; }); it('should have no announcement when first created', () => { expect(component.announcement).toBeUndefined(); }); describe('ngOnInit', () => { it('should make a single request to the server', () => { component.ngOnInit(); httpMock.expectOne('generated/announcements.json'); }); it('should set the announcement to the first "live" one in the list loaded from `announcements.json`', () => { component.ngOnInit(); const request = httpMock.expectOne('generated/announcements.json'); request.flush([ { startDate: lastWeek, endDate: yesterday, message: 'Test Announcement 0' }, { startDate: tomorrow, endDate: nextWeek, message: 'Test Announcement 1' }, { startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 2' }, { startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 3' } ]); expect(component.announcement.message).toEqual('Test Announcement 2'); }); it('should set the announcement to `undefined` if there are no announcements in `announcements.json`', () => { component.ngOnInit(); const request = httpMock.expectOne('generated/announcements.json'); request.flush([]); expect(component.announcement).toBeUndefined(); }); it('should handle invalid data in `announcements.json`', () => { component.ngOnInit(); const request = httpMock.expectOne('generated/announcements.json'); request.flush('some random response'); expect(component.announcement).toBeUndefined(); expect(mockLogger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json contains invalid data:/); }); it('should handle a failed request for `announcements.json`', () => { component.ngOnInit(); const request = httpMock.expectOne('generated/announcements.json'); request.error(new ErrorEvent('404')); expect(component.announcement).toBeUndefined(); expect(mockLogger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json request failed:/); }); }); describe('rendering', () => { beforeEach(() => { component.announcement = { imageUrl: 'link/to/image', linkUrl: 'link/to/website', message: 'this is an important message', endDate: '2018-03-01', startDate: '2018-02-01' }; fixture.detectChanges(); }); it('should display the message as HTML', () => { expect(element.innerHTML).toContain('this is an important message'); }); it('should display an image', () => { expect(element.querySelector('img')!.src).toContain('link/to/image'); }); it('should display a link', () => { expect(element.querySelector('a')!.href).toContain('link/to/website'); }); }); }); function changeDays(initial: Date, days: number) { return (new Date(initial.valueOf()).setDate(initial.getDate() + days)); } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/announcement-bar/announcement-bar.component.ts ================================================ import { Component, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { catchError, map } from 'rxjs/operators'; import { Logger } from 'app/shared/logger.service'; import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; const announcementsPath = CONTENT_URL_PREFIX + 'announcements.json'; export interface Announcement { imageUrl: string; message: string; linkUrl: string; startDate: string; endDate: string; } /** * Display the latest live announcement. This is used on the homepage. * * The data for the announcements is kept in `aio/content/marketing/announcements.json`. * * The format for that data file looks like: * * ``` * [ * { * "startDate": "2018-02-01", * "endDate": "2018-03-01", * "message": "This is an important announcement", * "imageUrl": "url/to/image", * "linkUrl": "url/to/website" * }, * ... * ] * ``` * * Only one announcement will be shown at any time. This is determined as the first "live" * announcement in the file, where "live" means that its start date is before today, and its * end date is after today. * * **Security Note:** * The `message` field can contain unsanitized HTML but this field should only updated by * verified members of the Angular team. */ @Component({ selector: 'aio-announcement-bar', template: `

Learn More
` }) export class AnnouncementBarComponent implements OnInit { announcement: Announcement; constructor(private http: HttpClient, private logger: Logger) {} ngOnInit() { this.http.get(announcementsPath) .pipe( catchError(error => { this.logger.error(new Error(`${announcementsPath} request failed: ${error.message}`)); return []; }), map(announcements => this.findCurrentAnnouncement(announcements)), catchError(error => { this.logger.error(new Error(`${announcementsPath} contains invalid data: ${error.message}`)); return []; }), ) .subscribe(announcement => this.announcement = announcement); } /** * Get the first date in the list that is "live" now */ private findCurrentAnnouncement(announcements: Announcement[]) { return announcements .filter(announcement => new Date(announcement.startDate).valueOf() < Date.now()) .filter(announcement => new Date(announcement.endDate).valueOf() > Date.now()) [0]; } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/announcement-bar/announcement-bar.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { SharedModule } from '../../shared/shared.module'; import { AnnouncementBarComponent } from './announcement-bar.component'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule, SharedModule, HttpClientModule ], declarations: [ AnnouncementBarComponent ] }) export class AnnouncementBarModule implements WithCustomElementComponent { customElementComponent: Type = AnnouncementBarComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/api/api-list.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BehaviorSubject } from 'rxjs'; import { ApiListComponent } from './api-list.component'; import { ApiItem, ApiSection, ApiService } from './api.service'; import { LocationService } from 'app/shared/location.service'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { ApiListModule } from './api-list.module'; describe('ApiListComponent', () => { let component: ApiListComponent; let fixture: ComponentFixture; let sections: ApiSection[]; beforeEach(() => { TestBed.configureTestingModule({ imports: [ ApiListModule ], providers: [ { provide: ApiService, useClass: TestApiService }, { provide: Logger, useClass: MockLogger }, { provide: LocationService, useClass: TestLocationService } ] }); fixture = TestBed.createComponent(ApiListComponent); component = fixture.componentInstance; sections = getApiSections(); }); /** * Expectation Utility: Assert that filteredSections has the expected result for this test * * @param itemTest - return true if the item passes the match test * * Subscribes to `filteredSections` and performs expectation within subscription callback. */ function expectFilteredResult(label: string, itemTest: (item: ApiItem) => boolean) { component.filteredSections.subscribe(filtered => { filtered = filtered.filter(section => section.items); expect(filtered.length).toBeGreaterThan(0, 'expected something'); expect(filtered.every(section => section.items!.every(itemTest))).toBe(true, label); }); } describe('#filteredSections', () => { beforeEach(() => { fixture.detectChanges(); }); it('should return all complete sections when no criteria', () => { let filtered: ApiSection[]|undefined; component.filteredSections.subscribe(f => filtered = f); expect(filtered).toEqual(sections); }); it('item.show should be true for all queried items', () => { component.setQuery('class'); expectFilteredResult('query: class', item => /class/.test(item.name)); }); it('items should be an array for every item in section when query matches section name', () => { component.setQuery('core'); component.filteredSections.subscribe(filtered => { filtered = filtered.filter(section => Array.isArray(section.items)); expect(filtered.length).toBe(1, 'only one section'); expect(filtered[0].name).toBe('core'); expect(filtered[0].items).toEqual(sections.find(section => section.name === 'core')!.items); }); }); describe('section.items', () => { it('should null if there are no matching items and the section itself does not match', () => { component.setQuery('core'); component.filteredSections.subscribe(filtered => { const commonSection = filtered.find(section => section.name === 'common')!; expect(commonSection.items).toBe(null); }); }); it('should be visible if they have the selected stability status', () => { component.setStatus({value: 'stable', title: 'Stable'}); expectFilteredResult('status: stable', item => item.stability === 'stable'); }); it('should be visible if they have the selected security status', () => { component.setStatus({value: 'security-risk', title: 'Security Risk'}); expectFilteredResult('status: security-risk', item => item.securityRisk); }); it('should be visible if they match the selected API type', () => { component.setType({value: 'class', title: 'Class'}); expectFilteredResult('type: class', item => item.docType === 'class'); }); }); it('should have no sections and no items visible when there is no match', () => { component.setQuery('fizzbuzz'); component.filteredSections.subscribe(filtered => { expect(filtered.some(section => !!section.items)).toBeFalsy(); }); }); }); describe('initial criteria from location', () => { let locationService: TestLocationService; beforeEach(() => { locationService = fixture.componentRef.injector.get(LocationService); }); function expectOneItem(name: string, section: string, type: string, stability: string) { fixture.detectChanges(); component.filteredSections.subscribe(filtered => { filtered = filtered.filter(s => s.items); expect(filtered.length).toBe(1, 'sections'); expect(filtered[0].name).toBe(section, 'section name'); const items = filtered[0].items!; expect(items.length).toBe(1, 'items'); const item = items[0]; const badItem = 'Wrong item: ' + JSON.stringify(item, null, 2); expect(item.docType).toBe(type, badItem); expect(item.stability).toBe(stability, badItem); expect(item.name).toBe(name, badItem); }); } it('should filter as expected for ?query', () => { locationService.query = {query: '_3'}; expectOneItem('class_3', 'core', 'class', 'experimental'); }); it('should filter as expected for ?status', () => { locationService.query = {status: 'deprecated'}; expectOneItem('function_1', 'core', 'function', 'deprecated'); }); it('should filter as expected when status is security-risk', () => { locationService.query = {status: 'security-risk'}; fixture.detectChanges(); expectFilteredResult('security-risk', item => item.securityRisk); }); it('should filter as expected for ?query&status&type', () => { locationService.query = { query: 's_1', status: 'experimental', type: 'class' }; fixture.detectChanges(); expectOneItem('class_1', 'common', 'class', 'experimental'); }); it('should ignore case for ?query&status&type', () => { locationService.query = { query: 'S_1', status: 'ExperiMental', type: 'CLASS' }; fixture.detectChanges(); expectOneItem('class_1', 'common', 'class', 'experimental'); }); }); describe('location path after criteria change', () => { let locationService: TestLocationService; beforeEach(() => { locationService = fixture.componentRef.injector.get(LocationService); }); it('should have query', () => { component.setQuery('foo'); // `setSearch` 2nd param is a query/search params object const search = locationService.setSearch.calls.mostRecent().args[1]; expect(search.query).toBe('foo'); }); it('should keep last of multiple query settings (in lowercase)', () => { component.setQuery('foo'); component.setQuery('fooBar'); const search = locationService.setSearch.calls.mostRecent().args[1]; expect(search.query).toBe('foobar'); }); it('should have query, status, and type', () => { component.setQuery('foo'); component.setStatus({value: 'stable', title: 'Stable'}); component.setType({value: 'class', title: 'Class'}); const search = locationService.setSearch.calls.mostRecent().args[1]; expect(search.query).toBe('foo'); expect(search.status).toBe('stable'); expect(search.type).toBe('class'); }); }); }); ////// Helpers //////// class TestLocationService { query: {[index: string]: string } = {}; setSearch = jasmine.createSpy('setSearch'); search() { return this.query; } } class TestApiService { sectionsSubject = new BehaviorSubject(getApiSections()); sections = this.sectionsSubject.asObservable(); } const apiSections: ApiSection[] = [ { name: 'common', title: 'common', path: 'api/common', deprecated: false, items: [ { name: 'class_1', title: 'Class 1', path: 'api/common/class_1', docType: 'class', stability: 'experimental', securityRisk: false, }, { name: 'class_2', title: 'Class 2', path: 'api/common/class_2', docType: 'class', stability: 'stable', securityRisk: false, }, { name: 'directive_1', title: 'Directive 1', path: 'api/common/directive_1', docType: 'directive', stability: 'stable', securityRisk: true, } ] }, { name: 'core', title: 'core', path: 'api/core', deprecated: false, items: [ { name: 'class_3', title: 'Class 3', path: 'api/core/class_3', docType: 'class', stability: 'experimental', securityRisk: false, }, { name: 'function_1', title: 'Function 1', path: 'api/core/function 1', docType: 'function', stability: 'deprecated', securityRisk: true, }, { name: 'const_1', title: 'Const 1', path: 'api/core/const_1', docType: 'const', stability: 'stable', securityRisk: false, } ] } ]; function getApiSections() { return apiSections; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/api/api-list.component.ts ================================================ /* * API List & Filter Component * * A page that displays a formatted list of the public Angular API entities. * Clicking on a list item triggers navigation to the corresponding API entity document. * Can add/remove API entity links based on filter settings. */ import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { combineLatest, Observable, ReplaySubject } from 'rxjs'; import { LocationService } from 'app/shared/location.service'; import { ApiSection, ApiService } from './api.service'; import { Option } from 'app/shared/select/select.component'; import { map } from 'rxjs/operators'; class SearchCriteria { query? = ''; status? = 'all'; type? = 'all'; } @Component({ selector: 'aio-api-list', template: `
search

{{ section.title }}

  • {{ item.title }} {{ !item.stability || item.stability === 'stable' ? '' : '(' + item.stability + ')' }}
`, }) export class ApiListComponent implements OnInit { filteredSections: Observable; showStatusMenu = false; showTypeMenu = false; private criteriaSubject = new ReplaySubject(1); private searchCriteria = new SearchCriteria(); status: Option; type: Option; // API types types: Option[] = [ { value: 'all', title: 'All' }, { value: 'class', title: 'Class' }, { value: 'const', title: 'Const' }, { value: 'enum', title: 'Enum' }, { value: 'function', title: 'Function' }, { value: 'interface', title: 'Interface' }, { value: 'type-alias', title: 'Type alias' }, ]; statuses: Option[] = [ { value: 'all', title: 'All' }, { value: 'deprecated', title: 'Deprecated' }, { value: 'security-risk', title: 'Security Risk' }, ]; @ViewChild('filter', { static: true }) queryEl: ElementRef; constructor(private apiService: ApiService, private locationService: LocationService) {} ngOnInit() { this.filteredSections = combineLatest(this.apiService.sections, this.criteriaSubject).pipe( map((results) => ({ sections: results[0], criteria: results[1] })), map((results) => results.sections.map((section) => ({ ...section, items: this.filterSection(section, results.criteria) }))) ); this.initializeSearchCriteria(); } // TODO: may need to debounce as the original did // although there shouldn't be any perf consequences if we don't setQuery(query: string) { this.setSearchCriteria({ query: (query || '').toLowerCase().trim() }); } setStatus(status: Option) { this.toggleStatusMenu(); this.status = status; this.setSearchCriteria({ status: status.value }); } setType(type: Option) { this.toggleTypeMenu(); this.type = type; this.setSearchCriteria({ type: type.value }); } toggleStatusMenu() { this.showStatusMenu = !this.showStatusMenu; } toggleTypeMenu() { this.showTypeMenu = !this.showTypeMenu; } //////// Private ////////// private filterSection(section: ApiSection, { query, status, type }: SearchCriteria) { const items = section.items!.filter((item) => { return matchesType() && matchesStatus() && matchesQuery(); function matchesQuery() { return !query || section.name.indexOf(query) !== -1 || item.name.indexOf(query) !== -1; } function matchesStatus() { return status === 'all' || status === item.stability || (status === 'security-risk' && item.securityRisk); } function matchesType() { return type === 'all' || type === item.docType; } }); // If there are no items we still return an empty array if the section name matches and the type is 'package' return items.length ? items : type === 'package' && (!query || section.name.indexOf(query) !== -1) ? [] : null; } // Get initial search criteria from URL search params private initializeSearchCriteria() { const { query, status, type } = this.locationService.search(); const q = (query || '').toLowerCase(); // Hack: can't bind to query because input cursor always forced to end-of-line. this.queryEl.nativeElement.value = q; this.status = this.statuses.find((x) => x.value === status) || this.statuses[0]; this.type = this.types.find((x) => x.value === type) || this.types[0]; this.searchCriteria = { query: q, status: this.status.value, type: this.type.value, }; this.criteriaSubject.next(this.searchCriteria); } private setLocationSearch() { const { query, status, type } = this.searchCriteria; const params = { query: query ? query : undefined, status: status !== 'all' ? status : undefined, type: type !== 'all' ? type : undefined, }; this.locationService.setSearch('API Search', params); } private setSearchCriteria(criteria: SearchCriteria) { this.criteriaSubject.next(Object.assign(this.searchCriteria, criteria)); this.setLocationSearch(); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/api/api-list.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { SharedModule } from '../../shared/shared.module'; import { ApiListComponent } from './api-list.component'; import { ApiService } from './api.service'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule, SharedModule, HttpClientModule ], declarations: [ ApiListComponent ], providers: [ ApiService ] }) export class ApiListModule implements WithCustomElementComponent { customElementComponent: Type = ApiListComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/api/api.service.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Logger } from 'app/shared/logger.service'; import { ApiService } from './api.service'; describe('ApiService', () => { let injector: Injector; let service: ApiService; let httpMock: HttpTestingController; beforeEach(() => { injector = TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ ApiService, { provide: Logger, useClass: TestLogger } ] }); service = injector.get(ApiService); httpMock = injector.get(HttpTestingController); }); afterEach(() => httpMock.verify()); it('should not immediately connect to the server', () => { httpMock.expectNone({}); }); it('subscribers should be completed/unsubscribed when service destroyed', () => { let completed = false; service.sections.subscribe( undefined, undefined, () => completed = true ); service.ngOnDestroy(); expect(completed).toBe(true); // Stop `httpMock.verify()` from complaining. httpMock.expectOne({}); }); describe('#sections', () => { it('first subscriber should fetch sections', done => { const data = [ {name: 'a', title: 'A', path: '', items: [], deprecated: false}, {name: 'b', title: 'B', path: '', items: [], deprecated: false}, ]; service.sections.subscribe(sections => { expect(sections).toEqual(data); done(); }); httpMock.expectOne({}).flush(data); }); it('second subscriber should get previous sections and NOT trigger refetch', done => { const data = [ {name: 'a', title: 'A', path: '', items: [], deprecated: false}, {name: 'b', title: 'B', path: '', items: [], deprecated: false}, ]; let subscriptions = 0; service.sections.subscribe(sections => { subscriptions++; expect(sections).toEqual(data); }); service.sections.subscribe(sections => { subscriptions++; expect(sections).toEqual(data); expect(subscriptions).toBe(2); done(); }); httpMock.expectOne({}).flush(data); }); }); describe('#fetchSections', () => { it('should connect to the server w/ expected URL', () => { service.fetchSections(); httpMock.expectOne('generated/docs/api/api-list.json'); }); it('should refresh the #sections observable w/ new content on second call', () => { let call = 0; let data = [ {name: 'a', title: 'A', path: '', items: [], deprecated: false}, {name: 'b', title: 'B', path: '', items: [], deprecated: false}, ]; service.sections.subscribe(sections => { // called twice during this test // (1) during subscribe // (2) after refresh expect(sections).toEqual(data, 'call ' + call++); }); httpMock.expectOne({}).flush(data); // refresh/refetch data = [{name: 'c', title: 'C', path: '', items: [], deprecated: false}]; service.fetchSections(); httpMock.expectOne({}).flush(data); expect(call).toBe(2, 'should be called twice'); }); }); }); class TestLogger { log = jasmine.createSpy('log'); error = jasmine.createSpy('error'); } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/api/api.service.ts ================================================ import { Injectable, OnDestroy } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { ReplaySubject, Subject } from 'rxjs'; import { takeUntil, tap } from 'rxjs/operators'; import { Logger } from 'app/shared/logger.service'; import { DOC_CONTENT_URL_PREFIX } from 'app/documents/document.service'; export interface ApiItem { name: string; title: string; path: string; docType: string; stability: string; securityRisk: boolean; } export interface ApiSection { path: string; name: string; title: string; deprecated: boolean; items: ApiItem[]|null; } @Injectable() export class ApiService implements OnDestroy { private apiBase = DOC_CONTENT_URL_PREFIX + 'api/'; private apiListJsonDefault = 'api-list.json'; private firstTime = true; private onDestroy = new Subject(); private sectionsSubject = new ReplaySubject(1); private _sections = this.sectionsSubject.pipe(takeUntil(this.onDestroy)); /** * Return a cached observable of API sections from a JSON file. * API sections is an array of Angular top modules and metadata about their API documents (items). */ get sections() { if (this.firstTime) { this.firstTime = false; this.fetchSections(); // TODO: get URL for fetchSections by configuration? // makes sectionsSubject hot; subscribe ensures stays alive (always refCount > 0); this._sections.subscribe(() => this.logger.log('ApiService got API sections') ); } return this._sections.pipe(tap(sections => { sections.forEach(section => { section.deprecated = !!section.items && section.items.every(item => item.stability === 'deprecated'); }); })); } constructor(private http: HttpClient, private logger: Logger) { } ngOnDestroy() { this.onDestroy.next(null); } /** * Fetch API sections from a JSON file. * API sections is an array of Angular top modules and metadata about their API documents (items). * Updates `sections` observable * * @param src Name of the api list JSON file */ fetchSections(src?: string) { // TODO: get URL by configuration? const url = this.apiBase + (src || this.apiListJsonDefault); this.http.get(url) .pipe( takeUntil(this.onDestroy), tap(() => this.logger.log(`Got API sections from ${url}`)), ) .subscribe( sections => this.sectionsSubject.next(sections), (err: HttpErrorResponse) => { // TODO: handle error this.logger.error(err); throw err; // rethrow for now. } ); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-example.component.spec.ts ================================================ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CodeExampleComponent } from './code-example.component'; import { CodeExampleModule } from './code-example.module'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; describe('CodeExampleComponent', () => { let hostComponent: HostComponent; let codeExampleComponent: CodeExampleComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ imports: [ CodeExampleModule ], declarations: [ HostComponent, ], providers: [ { provide: Logger, useClass: MockLogger }, ] }); fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); hostComponent = fixture.componentInstance; codeExampleComponent = hostComponent.codeExampleComponent; }); it('should be able to capture the code snippet provided in content', () => { expect(codeExampleComponent.aioCode.code.trim()).toBe('const foo = "bar";'); }); it('should change aio-code classes based on header presence', () => { expect(codeExampleComponent.header).toBe('Great Example'); expect(fixture.nativeElement.querySelector('header')).toBeTruthy(); expect(codeExampleComponent.classes).toEqual({ 'headed-code': true, 'simple-code': false }); codeExampleComponent.header = ''; fixture.detectChanges(); expect(codeExampleComponent.header).toBe(''); expect(fixture.nativeElement.querySelector('header')).toBeFalsy(); expect(codeExampleComponent.classes).toEqual({ 'headed-code': false, 'simple-code': true }); }); it('should set avoidFile class if path has .avoid.', () => { const codeExampleComponentElement: HTMLElement = fixture.nativeElement.querySelector('code-example'); expect(codeExampleComponent.path).toBe('code-path'); expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(true); codeExampleComponent.path = 'code-path.avoid.'; fixture.detectChanges(); expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(false); }); it('should coerce hidecopy', () => { expect(codeExampleComponent.hidecopy).toBe(false); hostComponent.hidecopy = true; fixture.detectChanges(); expect(codeExampleComponent.hidecopy).toBe(true); hostComponent.hidecopy = 'false'; fixture.detectChanges(); expect(codeExampleComponent.hidecopy).toBe(false); hostComponent.hidecopy = 'true'; fixture.detectChanges(); expect(codeExampleComponent.hidecopy).toBe(true); }); }); @Component({ selector: 'aio-host-comp', template: ` {{code}} ` }) class HostComponent { code = 'const foo = "bar";'; header = 'Great Example'; path = 'code-path'; hidecopy: boolean | string = false; @ViewChild(CodeExampleComponent, {static: true}) codeExampleComponent: CodeExampleComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-example.component.ts ================================================ /* tslint:disable component-selector */ import { Component, HostBinding, ElementRef, ViewChild, Input, AfterViewInit } from '@angular/core'; import { CodeComponent } from './code.component'; /** * An embeddable code block that displays nicely formatted code. * Example usage: * * ``` * * // a code block * console.log('do stuff'); * * ``` */ @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: 'code-example', template: `
{{header}}
`, }) export class CodeExampleComponent implements AfterViewInit { classes: Record; @Input() language: string; @Input() linenums: string; @Input() region: string; @Input() set header(header: string) { this._header = header; this.classes = { 'headed-code': !!this.header, 'simple-code': !this.header, }; } get header(): string { return this._header; } private _header: string; @Input() set path(path: string) { this._path = path; this.isAvoid = this.path.indexOf('.avoid.') !== -1; } get path(): string { return this._path; } private _path = ''; @Input() set hidecopy(hidecopy: boolean) { // Coerce the boolean value. this._hidecopy = hidecopy != null && `${hidecopy}` !== 'false'; } get hidecopy(): boolean { return this._hidecopy; } private _hidecopy: boolean; // eslint-disable-next-line @angular-eslint/no-input-rename @Input('hide-copy') set hyphenatedHideCopy(hidecopy: boolean) { this.hidecopy = hidecopy; } // eslint-disable-next-line @angular-eslint/no-input-rename @Input('hideCopy') set capitalizedHideCopy(hidecopy: boolean) { this.hidecopy = hidecopy; } @HostBinding('class.avoidFile') isAvoid = false; @ViewChild('content', { static: true }) content: ElementRef; @ViewChild(CodeComponent, { static: true }) aioCode: CodeComponent; ngAfterViewInit() { this.aioCode.code = this.content.nativeElement.innerHTML; } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-example.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CodeExampleComponent } from './code-example.component'; import { CodeModule } from './code.module'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule, CodeModule ], declarations: [ CodeExampleComponent ], exports: [ CodeExampleComponent ] }) export class CodeExampleModule implements WithCustomElementComponent { customElementComponent: Type = CodeExampleComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-tabs.component.spec.ts ================================================ import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { CodeTabsComponent } from './code-tabs.component'; import { CodeTabsModule } from './code-tabs.module'; @Component({ selector: 'aio-host-comp', template: ` Code example 1 Code example 2 `, }) class HostComponent { @ViewChild(CodeTabsComponent, { static: true }) codeTabsComponent: CodeTabsComponent; } describe('CodeTabsComponent', () => { let fixture: ComponentFixture; let hostComponent: HostComponent; let codeTabsComponent: CodeTabsComponent; beforeEach(() => { TestBed.configureTestingModule({ declarations: [HostComponent], imports: [CodeTabsModule, NoopAnimationsModule], schemas: [NO_ERRORS_SCHEMA], providers: [{ provide: Logger, useClass: MockLogger }], }); fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); hostComponent = fixture.componentInstance; codeTabsComponent = hostComponent.codeTabsComponent; }); it('should get correct tab info', () => { const tabs = codeTabsComponent.tabs; expect(tabs.length).toBe(2); // First code pane expectations expect(tabs[0].class).toBe('class-A'); expect(tabs[0].language).toBe('language-A'); expect(tabs[0].linenums).toBe('linenums-A'); expect(tabs[0].path).toBe('path-A'); expect(tabs[0].region).toBe('region-A'); expect(tabs[0].header).toBe('header-A'); expect(tabs[0].code.trim()).toBe('Code example 1'); // Second code pane expectations expect(tabs[1].class).toBe('class-B'); expect(tabs[1].language).toBe('language-B'); expect(tabs[1].linenums).toBe('default-linenums', 'Default linenums should have been used'); expect(tabs[1].path).toBe('path-B'); expect(tabs[1].region).toBe('region-B'); expect(tabs[1].header).toBe('header-B'); expect(tabs[1].code.trim()).toBe('Code example 2'); }); it('should create the right number of tabs with the right labels and classes', () => { const matTabs = fixture.nativeElement.querySelectorAll('.mat-tab-label'); expect(matTabs.length).toBe(2); expect(matTabs[0].textContent.trim()).toBe('header-A'); expect(matTabs[0].querySelector('.class-A')).toBeTruthy(); expect(matTabs[1].textContent.trim()).toBe('header-B'); expect(matTabs[1].querySelector('.class-B')).toBeTruthy(); }); it('should show the first tab with the right code', () => { const codeContent = fixture.nativeElement.querySelector('aio-code').textContent; expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy(); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-tabs.component.ts ================================================ /* tslint:disable component-selector */ import { AfterViewInit, Component, ElementRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { CodeComponent } from './code.component'; export interface TabInfo { class: string | null; code: string; language: string | null; linenums: any; path: string; region: string; header: string | null; } /** * Renders a set of tab group of code snippets. * * The innerHTML of the `` component should contain `` elements. * Each `` has the same interface as the embedded `` component. * The optional `linenums` attribute is the default `linenums` for each code pane. */ @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: 'code-tabs', template: `
{{ tab.header }} `, }) export class CodeTabsComponent implements OnInit, AfterViewInit { tabs: TabInfo[]; @Input() linenums: string; @ViewChild('content', { static: true }) content: ElementRef; @ViewChildren(CodeComponent) codeComponents: QueryList; ngOnInit() { this.tabs = []; const codeExamples = Array.from(this.content.nativeElement.querySelectorAll('code-pane')); for (const tabContent of codeExamples) { this.tabs.push(this.getTabInfo(tabContent)); } } ngAfterViewInit() { this.codeComponents.toArray().forEach((codeComponent, i) => { codeComponent.code = this.tabs[i].code; }); } /** Gets the extracted TabInfo data from the provided code-pane element. */ private getTabInfo(tabContent: Element): TabInfo { return { class: tabContent.getAttribute('class'), code: tabContent.innerHTML, language: tabContent.getAttribute('language'), linenums: tabContent.getAttribute('linenums') || this.linenums, path: tabContent.getAttribute('path') || '', region: tabContent.getAttribute('region') || '', header: tabContent.getAttribute('header'), }; } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code-tabs.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CodeTabsComponent } from './code-tabs.component'; import { MatCardModule } from '@angular/material/card'; import { MatTabsModule } from '@angular/material/tabs'; import { CodeModule } from './code.module'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule, MatCardModule, MatTabsModule, CodeModule ], declarations: [ CodeTabsComponent ], exports: [ CodeTabsComponent ] }) export class CodeTabsModule implements WithCustomElementComponent { customElementComponent: Type = CodeTabsComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code.component.spec.ts ================================================ import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { CodeTabsComponent } from './code-tabs.component'; import { CodeTabsModule } from './code-tabs.module'; describe('CodeTabsComponent', () => { let fixture: ComponentFixture; let hostComponent: HostComponent; let codeTabsComponent: CodeTabsComponent; beforeEach(() => { TestBed.configureTestingModule({ declarations: [ HostComponent ], imports: [ CodeTabsModule, NoopAnimationsModule ], schemas: [ NO_ERRORS_SCHEMA ], providers: [ { provide: Logger, useClass: MockLogger }, ] }); fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); hostComponent = fixture.componentInstance; codeTabsComponent = hostComponent.codeTabsComponent; }); it('should get correct tab info', () => { const tabs = codeTabsComponent.tabs; expect(tabs.length).toBe(2); // First code pane expectations expect(tabs[0].class).toBe('class-A'); expect(tabs[0].language).toBe('language-A'); expect(tabs[0].linenums).toBe('linenums-A'); expect(tabs[0].path).toBe('path-A'); expect(tabs[0].region).toBe('region-A'); expect(tabs[0].header).toBe('header-A'); expect(tabs[0].code.trim()).toBe('Code example 1'); // Second code pane expectations expect(tabs[1].class).toBe('class-B'); expect(tabs[1].language).toBe('language-B'); expect(tabs[1].linenums).toBe('default-linenums', 'Default linenums should have been used'); expect(tabs[1].path).toBe('path-B'); expect(tabs[1].region).toBe('region-B'); expect(tabs[1].header).toBe('header-B'); expect(tabs[1].code.trim()).toBe('Code example 2'); }); it('should create the right number of tabs with the right labels and classes', () => { const matTabs = fixture.nativeElement.querySelectorAll('.mat-tab-label'); expect(matTabs.length).toBe(2); expect(matTabs[0].textContent.trim()).toBe('header-A'); expect(matTabs[0].querySelector('.class-A')).toBeTruthy(); expect(matTabs[1].textContent.trim()).toBe('header-B'); expect(matTabs[1].querySelector('.class-B')).toBeTruthy(); }); it('should show the first tab with the right code', () => { const codeContent = fixture.nativeElement.querySelector('aio-code').textContent; expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy(); }); }); @Component({ selector: 'aio-host-comp', template: ` Code example 1 Code example 2 ` }) class HostComponent { @ViewChild(CodeTabsComponent, {static: true}) codeTabsComponent: CodeTabsComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code.component.ts ================================================ import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; import { Logger } from 'app/shared/logger.service'; import { PrettyPrinter } from './pretty-printer.service'; import { CopierService } from 'app/shared/copier.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { tap } from 'rxjs/operators'; import { StackblitzService } from 'app/shared/stackblitz.service'; // @ts-expect-error import version from '../../../../tools/stackblitz/rxjs.version'; /** * If linenums is not set, this is the default maximum number of lines that * an example can display without line numbers. */ const DEFAULT_LINE_NUMS_COUNT = 10; /** * Formatted Code Block * * Pretty renders a code block, used in the docs and API reference by the code-example and * code-tabs embedded components. * It includes a "copy" button that will send the content to the clipboard when clicked * * Example usage: * * ``` * * * ``` * * * Renders code provided through the `updateCode` method. */ @Component({ selector: 'aio-code', template: `
      
      
      
    
`, }) export class CodeComponent implements OnChanges { ariaLabelCopy = ''; ariaLabelEdit = ''; /** The code to be copied when clicking the copy button, this should not be HTML encoded */ private codeText: string; /** Code that should be formatted with current inputs and displayed in the view. */ set code(code: string) { this._code = code; if (!this._code || !this._code.trim()) { this.showMissingCodeMessage(); } else { this.formatDisplayedCode(); } } get code(): string { return this._code; } _code: string; /** Whether the copy button should be shown. */ @Input() hideCopy: boolean; /** Language to render the code (e.g. javascript, dart, typescript). */ @Input() language: string | null; /** * Whether to display line numbers: * - If false: hide * - If true: show * - If number: show but start at that number */ @Input() linenums: boolean | number | string; /** Path to the source of the code. */ @Input() path: string; /** Region of the source of the code being displayed. */ @Input() region: string; /** Optional header to be displayed above the code. */ @Input() set header(header: string | null) { this._header = header; this.ariaLabelCopy = this.header ? `Copy code snippet from ${this.header}` : ''; this.ariaLabelEdit = this.header ? `Edit code snippet from ${this.header} in StackBlitz` : ''; } get header(): string | null { return this._header; } private _header: string | null; @Output() codeFormatted = new EventEmitter(); /** The element in the template that will display the formatted code. */ @ViewChild('codeContainer', { static: true }) codeContainer: ElementRef; constructor( private snackbar: MatSnackBar, private pretty: PrettyPrinter, private copier: CopierService, private logger: Logger, private stackblitz: StackblitzService ) {} ngOnChanges() { // If some inputs have changed and there is code displayed, update the view with the latest // formatted code. if (this.code) { this.formatDisplayedCode(); } } private formatDisplayedCode() { const leftAlignedCode = leftAlign(this.code); this.setCodeHtml(leftAlignedCode); // start with unformatted code this.codeText = this.getCodeText(); // store the unformatted code as text (for copying) this.pretty .formatCode(leftAlignedCode, this.language ?? '', this.getLinenums(leftAlignedCode)) .pipe(tap(() => this.codeFormatted.emit())) .subscribe( (c) => this.setCodeHtml(c), () => { /* ignore failure to format */ } ); } /** Sets the message showing that the code could not be found. */ private showMissingCodeMessage() { const src = this.path ? this.path + (this.region ? '#' + this.region : '') : ''; const srcMsg = src ? ` for\n${src}` : '.'; this.setCodeHtml(`

The code sample is missing${srcMsg}

`); } /** Sets the innerHTML of the code container to the provided code string. */ private setCodeHtml(formattedCode: string) { // **Security:** Code example content is provided by docs authors and as such its considered to // be safe for innerHTML purposes. this.codeContainer.nativeElement.innerHTML = formattedCode; } /** Gets the textContent of the displayed code element. */ private getCodeText() { // `prettify` may remove newlines, e.g. when `linenums` are on. Retrieve the content of the // container as text, before prettifying it. // We take the textContent because we don't want it to be HTML encoded. return this.codeContainer.nativeElement.textContent; } /** Extracts html placed in the `// html: ` comment in the code. */ private getHtmlFromCode(code: string): string { const pattern = new RegExp('// html: (.*)'); const matches = code.match(pattern); return matches ? matches[1] : ''; } /** Copies the code snippet to the user's clipboard. */ doCopy() { const code = this.codeText; const successfullyCopied = this.copier.copyText(code); if (successfullyCopied) { this.logger.log('Copied code to clipboard:', code); this.snackbar.open('Code Copied', '', { duration: 800 }); } else { this.logger.error(new Error(`ERROR copying code to clipboard: "${code}"`)); this.snackbar.open('Copy failed. Please try again!', '', { duration: 800 }); } } editInStackBlitz() { this.stackblitz.openProject({ code: this.codeText, language: this.language ?? '', dependencies: { rxjs: version, }, html: this.getHtmlFromCode(this.codeText), }); } /** Gets the calculated value of linenums (boolean/number). */ getLinenums(code: string) { const linenums = typeof this.linenums === 'boolean' ? this.linenums : this.linenums === 'true' ? true : this.linenums === 'false' ? false : typeof this.linenums === 'string' ? parseInt(this.linenums, 10) : this.linenums; // if no linenums, enable line numbers if more than one line return linenums == null || isNaN(linenums as number) ? (code.match(/\n/g) || []).length > DEFAULT_LINE_NUMS_COUNT : linenums; } } function leftAlign(text: string): string { let indent = Number.MAX_VALUE; const lines = text.split('\n'); lines.forEach((line) => { const lineIndent = line.search(/\S/); if (lineIndent !== -1) { indent = Math.min(lineIndent, indent); } }); return lines .map((line) => line.substr(indent)) .join('\n') .trim(); } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/code.module.ts ================================================ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CodeComponent } from './code.component'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { PrettyPrinter } from './pretty-printer.service'; import { CopierService } from 'app/shared/copier.service'; @NgModule({ imports: [ CommonModule, MatSnackBarModule ], declarations: [ CodeComponent ], exports: [ CodeComponent ], providers: [ PrettyPrinter, CopierService ] }) export class CodeModule { } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/code/pretty-printer.service.ts ================================================ import { Injectable } from '@angular/core'; import { from as fromPromise, Observable } from 'rxjs'; import { first, map, share } from 'rxjs/operators'; import { Logger } from 'app/shared/logger.service'; type PrettyPrintOne = (code: string, language?: string, linenums?: number | boolean) => string; /** * Wrapper around the prettify.js library */ @Injectable() export class PrettyPrinter { private prettyPrintOne: Observable; constructor(private logger: Logger) { this.prettyPrintOne = fromPromise(this.getPrettyPrintOne()).pipe(share()); } private getPrettyPrintOne(): Promise { const ppo = (window as any).prettyPrintOne; return ppo ? Promise.resolve(ppo) : // `prettyPrintOne` is not on `window`, which means `prettify.js` has not been loaded yet. // Import it; as a side-effect it will add `prettyPrintOne` on `window`. import('assets/js/prettify.js' as any) .then( () => (window as any).prettyPrintOne, err => { const msg = `Cannot get prettify.js from server: ${err.message}`; this.logger.error(new Error(msg)); // return a pretty print fn that always fails. return () => { throw new Error(msg); }; }); } /** * Format code snippet as HTML * * @param code the code snippet to format; should already be HTML encoded * @param language The language of the code to render (could be javascript, html, typescript, etc) * @param linenums Whether to display line numbers: * - false: don't display * - true: do display * - number: do display but start at the given number * @returns Observable of formatted code */ formatCode(code: string, language?: string, linenums?: number | boolean) { return this.prettyPrintOne.pipe( map(ppo => { try { return ppo(code, language, linenums); } catch (err) { const msg = `Could not format code that begins '${code.substr(0, 50)}...'.`; console.error(msg, err); throw new Error(msg); } }), first(), // complete immediately ); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor-list.component.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { of } from 'rxjs'; import { ContributorGroup } from './contributors.model'; import { ContributorListComponent } from './contributor-list.component'; import { ContributorService } from './contributor.service'; import { LocationService } from 'app/shared/location.service'; // Testing the component class behaviors, independent of its template // Let e2e tests verify how it displays. describe('ContributorListComponent', () => { let component: ContributorListComponent; let injector: ReflectiveInjector; let contributorService: TestContributorService; let locationService: TestLocationService; let contributorGroups: ContributorGroup[]; beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ ContributorListComponent, {provide: ContributorService, useClass: TestContributorService }, {provide: LocationService, useClass: TestLocationService } ]); locationService = injector.get(LocationService); contributorService = injector.get(ContributorService); contributorGroups = contributorService.testContributors; }); it('should select the first group when no query string', () => { component = getComponent(); expect(component.selectedGroup).toBe(contributorGroups[0]); }); it('should select the first group when query string w/o "group" property', () => { locationService.searchResult = { foo: 'GDE' }; component = getComponent(); expect(component.selectedGroup).toBe(contributorGroups[0]); }); it('should select the first group when query group not found', () => { locationService.searchResult = { group: 'foo' }; component = getComponent(); expect(component.selectedGroup).toBe(contributorGroups[0]); }); it('should select the GDE group when query group is "GDE"', () => { locationService.searchResult = { group: 'GDE' }; component = getComponent(); expect(component.selectedGroup).toBe(contributorGroups[1]); }); it('should select the GDE group when query group is "gde" (case insensitive)', () => { locationService.searchResult = { group: 'gde' }; component = getComponent(); expect(component.selectedGroup).toBe(contributorGroups[1]); }); it('should set the query to the "GDE" group when user selects "GDE"', () => { component = getComponent(); component.selectGroup('GDE'); expect(locationService.searchResult.group).toBe('GDE'); }); it('should set the query to the first group when user selects unknown name', () => { component = getComponent(); component.selectGroup('GDE'); // a legit group that isn't the first component.selectGroup('foo'); // not a legit group name expect(locationService.searchResult.group).toBe('Angular'); }); //// Test Helpers //// function getComponent(): ContributorListComponent { const comp = injector.get(ContributorListComponent); comp.ngOnInit(); return comp; } interface SearchResult { [index: string]: string }; class TestLocationService { searchResult: SearchResult = {}; search = jasmine.createSpy('search').and.callFake(() => this.searchResult); setSearch = jasmine.createSpy('setSearch') .and.callFake((label: string, result: SearchResult) => { this.searchResult = result; }); } class TestContributorService { testContributors = getTestData(); contributors = of(this.testContributors); } function getTestData(): ContributorGroup[] { return [ // Not interested in the contributors data in these tests { name: 'Angular', order: 0, contributors: [] }, { name: 'GDE', order: 1, contributors: [] }, ]; } }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor-list.component.ts ================================================ import { Component, OnInit } from '@angular/core'; import { ContributorGroup } from './contributors.model'; import { ContributorService } from './contributor.service'; import { LocationService } from 'app/shared/location.service'; @Component({ selector: 'aio-contributor-list', template: `
{{name}}
` }) export class ContributorListComponent implements OnInit { private groups: ContributorGroup[]; groupNames: string[]; selectedGroup: ContributorGroup; constructor( private contributorService: ContributorService, private locationService: LocationService) { } ngOnInit() { const groupName = this.locationService.search().group || ''; // no need to unsubscribe because `contributors` completes this.contributorService.contributors .subscribe(grps => { this.groups = grps; this.groupNames = grps.map(g => g.name); this.selectGroup(groupName); }); } selectGroup(name: string) { name = name.toLowerCase(); this.selectedGroup = this.groups.find(g => g.name.toLowerCase() === name) || this.groups[0]; this.locationService.setSearch('', {group: this.selectedGroup.name}); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor-list.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ContributorListComponent } from './contributor-list.component'; import { ContributorService } from './contributor.service'; import { ContributorComponent } from './contributor.component'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [CommonModule], declarations: [ContributorListComponent, ContributorComponent], providers: [ContributorService], }) export class ContributorListModule implements WithCustomElementComponent { customElementComponent: Type = ContributorListComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor.component.ts ================================================ import { Component, Input } from '@angular/core'; import { Contributor } from './contributors.model'; import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; @Component({ selector: 'aio-contributor', template: `

{{person.name}}

View Bio Twitter {{person.name}} GitHub {{person.name}} Personal website {{person.name}}

{{person.name}}

{{person.bio}}

`, }) export class ContributorComponent { @Input() person: Contributor; noPicture = '_no-one.png'; pictureBase = CONTENT_URL_PREFIX + 'images/bios/'; flipCard(person: Contributor) { person.isFlipped = !person.isFlipped; } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor.service.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ContributorService } from './contributor.service'; import { ContributorGroup } from './contributors.model'; describe('ContributorService', () => { let injector: Injector; let contribService: ContributorService; let httpMock: HttpTestingController; beforeEach(() => { injector = TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ ContributorService ] }); contribService = injector.get(ContributorService); httpMock = injector.get(HttpTestingController); }); afterEach(() => httpMock.verify()); it('should make a single connection to the server', () => { const req = httpMock.expectOne({}); expect(req.request.url).toBe('generated/contributors.json'); }); describe('#contributors', () => { let contribs: ContributorGroup[]; let testData: any; beforeEach(() => { testData = getTestContribs(); httpMock.expectOne({}).flush(testData); contribService.contributors.subscribe(results => contribs = results); }); it('contributors observable should complete', () => { let completed = false; contribService.contributors.subscribe(undefined, undefined, () => completed = true); expect(completed).toBe(true, 'observable completed'); }); it('should reshape the contributor json to expected result', () => { const groupNames = contribs.map(g => g.name).join(','); expect(groupNames).toEqual('Angular,GDE'); }); it('should have expected "GDE" contribs in order', () => { const gde = contribs[1]; const actualAngularNames = gde.contributors.map(l => l.name).join(','); const expectedAngularNames = [testData.jeffcross, testData.kapunahelewong].map(l => l.name).join(','); expect(actualAngularNames).toEqual(expectedAngularNames); }); }); it('should do WHAT(?) if the request fails'); }); function getTestContribs() { return { kapunahelewong: { name: 'Kapunahele Wong', picture: 'kapunahelewong.jpg', website: 'https://github.com/kapunahelewong', twitter: 'kapunahele', bio: 'Kapunahele is a front-end developer and contributor to angular.io', group: 'GDE' }, misko: { name: 'Miško Hevery', picture: 'misko.jpg', twitter: 'mhevery', website: 'http://misko.hevery.com', bio: 'Miško Hevery is the creator of AngularJS framework.', group: 'Angular' }, igor: { name: 'Igor Minar', picture: 'igor-minar.jpg', twitter: 'IgorMinar', website: 'https://google.com/+IgorMinar', bio: 'Igor is a software engineer at Angular.', group: 'Angular' }, kara: { name: 'Kara Erickson', picture: 'kara-erickson.jpg', twitter: 'karaforthewin', website: 'https://github.com/kara', bio: 'Kara is a software engineer on the Angular team at Angular and a co-organizer of the Angular-SF Meetup. ', group: 'Angular' }, jeffcross: { name: 'Jeff Cross', picture: 'jeff-cross.jpg', twitter: 'jeffbcross', website: 'https://twitter.com/jeffbcross', bio: 'Jeff was one of the earliest core team members on AngularJS.', group: 'GDE' }, naomi: { name: 'Naomi Black', picture: 'naomi.jpg', twitter: 'naomitraveller', website: 'http://google.com/+NaomiBlack', bio: 'Naomi is Angular\'s TPM generalist and jack-of-all-trades.', group: 'Angular' } }; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributor.service.ts ================================================ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { ConnectableObservable, Observable } from 'rxjs'; import { map, publishLast } from 'rxjs/operators'; import { Contributor, ContributorGroup } from './contributors.model'; // TODO(andrewjs): Look into changing this so that we don't import the service just to get the const import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; const contributorsPath = CONTENT_URL_PREFIX + 'contributors.json'; const knownGroups = ['Core Team', 'Learning Team', 'Alumn', 'Contributors']; @Injectable() export class ContributorService { contributors: Observable; constructor(private http: HttpClient) { this.contributors = this.getContributors(); } private getContributors() { const contributors = this.http.get<{[key: string]: Contributor}>(contributorsPath).pipe( // Create group map map(contribs => { const contribMap: { [name: string]: Contributor[]} = {}; Object.keys(contribs).forEach(key => { const contributor = contribs[key]; const group = contributor.group; const contribGroup = contribMap[group]; if (contribGroup) { contribGroup.push(contributor); } else { contribMap[group] = [contributor]; } }); return contribMap; }), // Flatten group map into sorted group array of sorted contributors map(cmap => Object.keys(cmap).map(key => { const order = knownGroups.indexOf(key); return { name: key, order: order === -1 ? knownGroups.length : order, contributors: cmap[key].sort(compareContributors) } as ContributorGroup; }) .sort(compareGroups)), publishLast(), ); (contributors as ConnectableObservable).connect(); return contributors; } } function compareContributors(l: Contributor, r: Contributor) { return l.name.toUpperCase() > r.name.toUpperCase() ? 1 : -1; } function compareGroups(l: ContributorGroup, r: ContributorGroup) { return l.order === r.order ? (l.name > r.name ? 1 : -1) : l.order > r.order ? 1 : -1; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/contributor/contributors.model.ts ================================================ export class ContributorGroup { name: string; order: number; contributors: Contributor[]; } export class Contributor { group: string; name: string; picture?: string; website?: string; twitter?: string; github?: string; bio?: string; isFlipped ? = false; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/current-location/current-location.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; import { CurrentLocationComponent } from './current-location.component'; describe('CurrentLocationComponent', () => { let element: HTMLElement; let fixture: ComponentFixture; let locationService: MockLocationService; beforeEach(() => { locationService = new MockLocationService('initial/url'); TestBed.configureTestingModule({ declarations: [ CurrentLocationComponent ], providers: [ { provide: LocationService, useValue: locationService } ] }); fixture = TestBed.createComponent(CurrentLocationComponent); element = fixture.nativeElement; }); it('should render the current location', () => { fixture.detectChanges(); expect(element.textContent).toEqual('initial/url'); locationService.urlSubject.next('next/url'); fixture.detectChanges(); expect(element.textContent).toEqual('next/url'); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/current-location/current-location.component.ts ================================================ /* tslint:disable component-selector */ import { Component } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; /** Renders the current location path. */ @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: 'current-location', template: '{{ location.currentPath | async }}' }) export class CurrentLocationComponent { constructor(public location: LocationService) { } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/current-location/current-location.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CurrentLocationComponent } from './current-location.component'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule ], declarations: [ CurrentLocationComponent ] }) export class CurrentLocationModule implements WithCustomElementComponent { customElementComponent: Type = CurrentLocationComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/custom-elements.module.ts ================================================ import { NgModule } from '@angular/core'; import { ROUTES} from '@angular/router'; import { ElementsLoader } from './elements-loader'; import { ELEMENT_MODULE_LOAD_CALLBACKS, ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES, ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN } from './element-registry'; import { LazyCustomElementComponent } from './lazy-custom-element.component'; @NgModule({ declarations: [ LazyCustomElementComponent ], exports: [ LazyCustomElementComponent ], providers: [ ElementsLoader, { provide: ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, useValue: ELEMENT_MODULE_LOAD_CALLBACKS }, // Providing these routes as a signal to the build system that these modules should be // registered as lazy-loadable. // TODO(andrewjs): Provide first-class support for providing this. { provide: ROUTES, useValue: ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES, multi: true }, ], }) export class CustomElementsModule { } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/element-registry.ts ================================================ import { InjectionToken, Type } from '@angular/core'; import { LoadChildrenCallback } from '@angular/router'; // Modules containing custom elements must be set up as lazy-loaded routes (loadChildren) // TODO(andrewjs): This is a hack, Angular should have first-class support for preparing a module // that contains custom elements. export const ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES = [ { selector: 'aio-announcement-bar', loadChildren: () => import('./announcement-bar/announcement-bar.module').then(m => m.AnnouncementBarModule) }, { selector: 'aio-api-list', loadChildren: () => import('./api/api-list.module').then(m => m.ApiListModule) }, { selector: 'aio-contributor-list', loadChildren: () => import('./contributor/contributor-list.module').then(m => m.ContributorListModule) }, { selector: 'aio-file-not-found-search', loadChildren: () => import('./search/file-not-found-search.module').then(m => m.FileNotFoundSearchModule) }, { selector: 'aio-resource-list', loadChildren: () => import('./resource/resource-list.module').then(m => m.ResourceListModule) }, { selector: 'aio-toc', loadChildren: () => import('./toc/toc.module').then(m => m.TocModule) }, { selector: 'code-example', loadChildren: () => import('./code/code-example.module').then(m => m.CodeExampleModule) }, { selector: 'code-tabs', loadChildren: () => import('./code/code-tabs.module').then(m => m.CodeTabsModule) }, { selector: 'current-location', loadChildren: () => import('./current-location/current-location.module').then(m => m.CurrentLocationModule) }, { selector: 'expandable-section', loadChildren: () => import('./expandable-section/expandable-section.module').then(m => m.ExpandableSectionModule) }, { selector: 'live-example', loadChildren: () => import('./live-example/live-example.module').then(m => m.LiveExampleModule) }, { selector: 'aio-operator-decision-tree', loadChildren: () => import('./operator-decision-tree/operator-decision-tree.module').then(m => m.OperatorDecisionTreeModule) } ]; /** * Interface expected to be implemented by all modules that declare a component that can be used as * a custom element. */ export interface WithCustomElementComponent { customElementComponent: Type; } /** Injection token to provide the element path modules. */ // export const ELEMENT_MODULE_PATHS_TOKEN = new InjectionToken('aio/elements-map'); /** Map of possible custom element selectors to their lazy-loadable module paths. */ // export const ELEMENT_MODULE_PATHS = new Map Promise>(); // ELEMENT_MODULE_PATHS_AS_ROUTES.forEach(route => { // ELEMENT_MODULE_PATHS.set(route.selector, route.loadChildren); // }); /** Injection token to provide the element path modules. */ export const ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN = new InjectionToken>('aio/elements-map'); /** Map of possible custom element selectors to their lazy-loadable module paths. */ export const ELEMENT_MODULE_LOAD_CALLBACKS = new Map(); ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES.forEach(route => { ELEMENT_MODULE_LOAD_CALLBACKS.set(route.selector, route.loadChildren); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/elements-loader.spec.ts ================================================ import { Compiler, ComponentFactory, ComponentFactoryResolver, ComponentRef, Injector, NgModuleFactory, NgModuleRef, Type, } from '@angular/core'; import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { ElementsLoader } from './elements-loader'; import { ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, WithCustomElementComponent } from './element-registry'; interface Deferred { resolve(): void; reject(err: any): void; } describe('ElementsLoader', () => { let elementsLoader: ElementsLoader; let compiler: Compiler; beforeEach(() => { const injector = TestBed.configureTestingModule({ providers: [ ElementsLoader, { provide: ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, useValue: new Map< string, () => Promise | Type> >([ ['element-a-selector', () => Promise.resolve(new FakeModuleFactory('element-a-module'))], ['element-b-selector', () => Promise.resolve(new FakeModuleFactory('element-b-module'))], ['element-c-selector', () => Promise.resolve(FakeCustomElementModule)] ])}, ] }); elementsLoader = injector.get(ElementsLoader); compiler = injector.get(Compiler); }); describe('loadContainedCustomElements()', () => { let loadCustomElementSpy: jasmine.Spy; beforeEach(() => loadCustomElementSpy = spyOn(elementsLoader, 'loadCustomElement')); it('should attempt to load and register all contained elements', fakeAsync(() => { expect(loadCustomElementSpy).not.toHaveBeenCalled(); const hostEl = document.createElement('div'); hostEl.innerHTML = ` `; elementsLoader.loadContainedCustomElements(hostEl); flushMicrotasks(); expect(loadCustomElementSpy).toHaveBeenCalledTimes(2); expect(loadCustomElementSpy).toHaveBeenCalledWith('element-a-selector'); expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector'); })); it('should attempt to load and register only contained elements', fakeAsync(() => { expect(loadCustomElementSpy).not.toHaveBeenCalled(); const hostEl = document.createElement('div'); hostEl.innerHTML = ` `; elementsLoader.loadContainedCustomElements(hostEl); flushMicrotasks(); expect(loadCustomElementSpy).toHaveBeenCalledTimes(1); expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector'); })); it('should wait for all contained elements to load and register', fakeAsync(() => { const deferreds = returnPromisesFromSpy(loadCustomElementSpy); const hostEl = document.createElement('div'); hostEl.innerHTML = ` `; const log: any[] = []; elementsLoader.loadContainedCustomElements(hostEl).subscribe( v => log.push(`emitted: ${v}`), e => log.push(`errored: ${e}`), () => log.push('completed'), ); flushMicrotasks(); expect(log).toEqual([]); deferreds[0].resolve(); flushMicrotasks(); expect(log).toEqual([]); deferreds[1].resolve(); flushMicrotasks(); expect(log).toEqual(['emitted: undefined', 'completed']); })); it('should fail if any of the contained elements fails to load and register', fakeAsync(() => { const deferreds = returnPromisesFromSpy(loadCustomElementSpy); const hostEl = document.createElement('div'); hostEl.innerHTML = ` `; const log: any[] = []; elementsLoader.loadContainedCustomElements(hostEl).subscribe( v => log.push(`emitted: ${v}`), e => log.push(`errored: ${e}`), () => log.push('completed'), ); flushMicrotasks(); expect(log).toEqual([]); deferreds[0].resolve(); flushMicrotasks(); expect(log).toEqual([]); deferreds[1].reject('foo'); flushMicrotasks(); expect(log).toEqual(['errored: foo']); })); }); describe('loadCustomElement()', () => { let definedSpy: jasmine.Spy; let whenDefinedSpy: jasmine.Spy; let whenDefinedDeferreds: Deferred[]; beforeEach(() => { // `loadCustomElement()` uses the `window.customElements` API. Provide mocks for these tests. definedSpy = spyOn(window.customElements, 'define'); whenDefinedSpy = spyOn(window.customElements, 'whenDefined'); whenDefinedDeferreds = returnPromisesFromSpy(whenDefinedSpy); }); it('should be able to load and register an element', fakeAsync(() => { elementsLoader.loadCustomElement('element-a-selector'); flushMicrotasks(); expect(definedSpy).toHaveBeenCalledTimes(1); expect(definedSpy).toHaveBeenCalledWith('element-a-selector', jasmine.any(Function)); // Verify the right component was loaded/registered. const Ctor = definedSpy.calls.argsFor(0)[1]; expect(Ctor.observedAttributes).toEqual(['element-a-module']); })); it('should wait until the element is defined', fakeAsync(() => { let state = 'pending'; elementsLoader.loadCustomElement('element-b-selector').then(() => state = 'resolved'); flushMicrotasks(); expect(state).toBe('pending'); expect(whenDefinedSpy).toHaveBeenCalledTimes(1); expect(whenDefinedSpy).toHaveBeenCalledWith('element-b-selector'); whenDefinedDeferreds[0].resolve(); flushMicrotasks(); expect(state).toBe('resolved'); })); it('should not load and register the same element more than once', fakeAsync(() => { elementsLoader.loadCustomElement('element-a-selector'); flushMicrotasks(); expect(definedSpy).toHaveBeenCalledTimes(1); definedSpy.calls.reset(); // While loading/registering is still in progress: elementsLoader.loadCustomElement('element-a-selector'); flushMicrotasks(); expect(definedSpy).not.toHaveBeenCalled(); definedSpy.calls.reset(); whenDefinedDeferreds[0].resolve(); // Once loading/registering is already completed: let state = 'pending'; elementsLoader.loadCustomElement('element-a-selector').then(() => state = 'resolved'); flushMicrotasks(); expect(state).toBe('resolved'); expect(definedSpy).not.toHaveBeenCalled(); })); it('should fail if defining the custom element fails', fakeAsync(() => { let state = 'pending'; elementsLoader.loadCustomElement('element-b-selector').catch(e => state = `rejected: ${e}`); flushMicrotasks(); expect(state).toBe('pending'); whenDefinedDeferreds[0].reject('foo'); flushMicrotasks(); expect(state).toBe('rejected: foo'); })); it('should be able to load and register an element again if previous attempt failed', fakeAsync(() => { elementsLoader.loadCustomElement('element-a-selector'); flushMicrotasks(); expect(definedSpy).toHaveBeenCalledTimes(1); definedSpy.calls.reset(); // While loading/registering is still in progress: elementsLoader.loadCustomElement('element-a-selector').catch(() => undefined); flushMicrotasks(); expect(definedSpy).not.toHaveBeenCalled(); whenDefinedDeferreds[0].reject('foo'); flushMicrotasks(); expect(definedSpy).not.toHaveBeenCalled(); // Once loading/registering has already failed: elementsLoader.loadCustomElement('element-a-selector'); flushMicrotasks(); expect(definedSpy).toHaveBeenCalledTimes(1); }) ); it('should be able to load and register an element after compiling its NgModule', fakeAsync(() => { const compilerSpy = spyOn(compiler, 'compileModuleAsync') .and.returnValue(Promise.resolve(new FakeModuleFactory('element-c-module'))); elementsLoader.loadCustomElement('element-c-selector'); flushMicrotasks(); expect(definedSpy).toHaveBeenCalledTimes(1); expect(definedSpy).toHaveBeenCalledWith('element-c-selector', jasmine.any(Function)); expect(compilerSpy).toHaveBeenCalledTimes(1); expect(compilerSpy).toHaveBeenCalledWith(FakeCustomElementModule); })); }); }); // TEST CLASSES/HELPERS class FakeCustomElementModule implements WithCustomElementComponent { customElementComponent: Type; } class FakeComponentFactory extends ComponentFactory { selector: string; componentType: Type; ngContentSelectors: string[]; inputs = [{propName: this.identifyingInput, templateName: this.identifyingInput}]; outputs = []; constructor(private identifyingInput: string) { super(); } create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, ngModule?: NgModuleRef): ComponentRef { return jasmine.createSpy('ComponentRef') as any; } } class FakeComponentFactoryResolver extends ComponentFactoryResolver { constructor(private modulePath: string) { super(); } resolveComponentFactory(component: Type): ComponentFactory { return new FakeComponentFactory(this.modulePath); } } class FakeModuleRef extends NgModuleRef { injector = jasmine.createSpyObj('injector', ['get']); componentFactoryResolver = new FakeComponentFactoryResolver(this.modulePath); instance: WithCustomElementComponent = new FakeCustomElementModule(); constructor(private modulePath: string) { super(); this.injector.get.and.returnValue(this.componentFactoryResolver); } destroy() {} onDestroy(callback: () => void) {} } class FakeModuleFactory extends NgModuleFactory { moduleType: Type; moduleRefToCreate = new FakeModuleRef(this.modulePath); constructor(private modulePath: string) { super(); } create(parentInjector: Injector | null): NgModuleRef { return this.moduleRefToCreate; } } function returnPromisesFromSpy(spy: jasmine.Spy): Deferred[] { const deferreds: Deferred[] = []; spy.and.callFake(() => new Promise((resolve: any, reject) => deferreds.push({resolve, reject}))); return deferreds; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/elements-loader.ts ================================================ import { Compiler, Inject, Injectable, NgModuleFactory, NgModuleRef, Type, } from '@angular/core'; import { ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, WithCustomElementComponent } from './element-registry'; import { from, Observable, of } from 'rxjs'; import { createCustomElement } from '@angular/elements'; import { LoadChildrenCallback } from '@angular/router'; @Injectable() export class ElementsLoader { /** Map of unregistered custom elements and their respective module paths to load. */ private elementsToLoad: Map; /** Map of custom elements that are in the process of being loaded and registered. */ private elementsLoading = new Map>(); constructor(private moduleRef: NgModuleRef, @Inject(ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN) elementModulePaths: Map, private compiler: Compiler) { this.elementsToLoad = new Map(elementModulePaths); } /** * Queries the provided element for any custom elements that have not yet been registered with * the browser. Custom elements that are registered will be removed from the list of unregistered * elements so that they will not be queried in subsequent calls. */ loadContainedCustomElements(element: HTMLElement): Observable { const unregisteredSelectors = Array.from(this.elementsToLoad.keys()) .filter(s => element.querySelector(s)); if (!unregisteredSelectors.length) { return of(undefined); } // Returns observable that completes when all discovered elements have been registered. const allRegistered = Promise.all(unregisteredSelectors.map(s => this.loadCustomElement(s))); return from(allRegistered.then(() => undefined)); } /** Loads and registers the custom element defined on the `WithCustomElement` module factory. */ loadCustomElement(selector: string): Promise { if (this.elementsLoading.has(selector)) { // The custom element is in the process of being loaded and registered. return this.elementsLoading.get(selector)!; } if (this.elementsToLoad.has(selector)) { // Load and register the custom element (for the first time). const modulePathLoader = this.elementsToLoad.get(selector)!; const loadedAndRegistered = (modulePathLoader() as Promise | Type>) .then(elementModuleOrFactory => { /** * With View Engine, the NgModule factory is created and provided when loaded. * With Ivy, only the NgModule class is provided loaded and must be compiled. * This uses the same mechanism as the deprecated `SystemJsNgModuleLoader` in * in `packages/core/src/linker/system_js_ng_module_factory_loader.ts` * to pass on the NgModuleFactory, or compile the NgModule and return its NgModuleFactory. */ if (elementModuleOrFactory instanceof NgModuleFactory) { return elementModuleOrFactory; } else { return this.compiler.compileModuleAsync(elementModuleOrFactory); } }) .then(elementModuleFactory => { const elementModuleRef = elementModuleFactory.create(this.moduleRef.injector); const injector = elementModuleRef.injector; const CustomElementComponent = elementModuleRef.instance.customElementComponent; const CustomElement = createCustomElement(CustomElementComponent, {injector}); customElements!.define(selector, CustomElement); return customElements.whenDefined(selector); }) .then(() => { // The custom element has been successfully loaded and registered. // Remove from `elementsLoading` and `elementsToLoad`. this.elementsLoading.delete(selector); this.elementsToLoad.delete(selector); }) .catch(err => { // The custom element has failed to load and register. // Remove from `elementsLoading`. // (Do not remove from `elementsToLoad` in case it was a temporary error.) this.elementsLoading.delete(selector); return Promise.reject(err); }); this.elementsLoading.set(selector, loadedAndRegistered); return loadedAndRegistered; } // The custom element has already been loaded and registered. return Promise.resolve(); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/expandable-section/expandable-section.component.ts ================================================ /* tslint:disable component-selector */ import { Component, Input } from '@angular/core'; /** Custom element wrapper for the material expansion panel with a title input. */ @Component({ selector: 'aio-expandable-section', template: ` {{ title }} `, }) export class ExpandableSectionComponent { @Input() title: string; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/expandable-section/expandable-section.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { ExpandableSectionComponent } from './expandable-section.component'; import { WithCustomElementComponent } from '../element-registry'; import { MatExpansionModule } from '@angular/material/expansion'; @NgModule({ imports: [ MatExpansionModule ], declarations: [ ExpandableSectionComponent, ] }) export class ExpandableSectionModule implements WithCustomElementComponent { customElementComponent: Type = ExpandableSectionComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/lazy-custom-element.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { LazyCustomElementComponent } from './lazy-custom-element.component'; import { ElementsLoader } from './elements-loader'; describe('LazyCustomElementComponent', () => { let mockElementsLoader: jasmine.SpyObj; let mockLogger: MockLogger; let fixture: ComponentFixture; beforeEach(() => { mockElementsLoader = jasmine.createSpyObj('ElementsLoader', [ 'loadContainedCustomElements', 'loadCustomElement', ]); const injector = TestBed.configureTestingModule({ declarations: [ LazyCustomElementComponent ], providers: [ { provide: ElementsLoader, useValue: mockElementsLoader }, { provide: Logger, useClass: MockLogger }, ], }); mockLogger = injector.get(Logger); fixture = TestBed.createComponent(LazyCustomElementComponent); }); it('should set the HTML content based on the selector', () => { const elem = fixture.nativeElement; expect(elem.innerHTML).toBe(''); fixture.componentInstance.selector = 'foo-bar'; fixture.detectChanges(); expect(elem.innerHTML).toBe(''); }); it('should load the specified custom element', () => { expect(mockElementsLoader.loadCustomElement).not.toHaveBeenCalled(); fixture.componentInstance.selector = 'foo-bar'; fixture.detectChanges(); expect(mockElementsLoader.loadCustomElement).toHaveBeenCalledWith('foo-bar'); }); it('should log an error (and abort) if the selector is empty', () => { fixture.detectChanges(); expect(mockElementsLoader.loadCustomElement).not.toHaveBeenCalled(); expect(mockLogger.output.error).toEqual([[jasmine.any(Error)]]); expect(mockLogger.output.error[0][0].message).toBe('Invalid selector for \'aio-lazy-ce\': '); }); it('should log an error (and abort) if the selector is invalid', () => { fixture.componentInstance.selector = 'foo-bar>`; this.elementsLoader.loadCustomElement(this.selector); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/live-example/live-example.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Component, DebugElement } from '@angular/core'; import { Location } from '@angular/common'; import { LiveExampleComponent, EmbeddedStackblitzComponent } from './live-example.component'; const defaultTestPath = '/test'; describe('LiveExampleComponent', () => { let liveExampleDe: DebugElement; let liveExampleComponent: LiveExampleComponent; let fixture: ComponentFixture; let testPath: string; //////// test helpers //////// @Component({ selector: 'aio-host-comp', template: '' }) class HostComponent { } class TestLocation { path() { return testPath; } } function getAnchors() { return liveExampleDe.queryAll(By.css('a')).map(de => de.nativeElement as HTMLAnchorElement); } function getHrefs() { return getAnchors().map(a => a.href); } function setHostTemplate(template: string) { TestBed.overrideComponent(HostComponent, {set: {template}}); } function testComponent(testFn: () => void) { fixture = TestBed.createComponent(HostComponent); liveExampleDe = fixture.debugElement.children[0]; liveExampleComponent = liveExampleDe.componentInstance; // Trigger `ngAfterContentInit()`. fixture.detectChanges(); testFn(); } //////// tests //////// beforeEach(() => { TestBed.configureTestingModule({ declarations: [ HostComponent, LiveExampleComponent, EmbeddedStackblitzComponent ], providers: [ { provide: Location, useClass: TestLocation } ] }) // Disable the ', styles: ['iframe { min-height: 400px; }'], }) export class EmbeddedStackblitzComponent implements AfterViewInit { @Input() src: string; @ViewChild('iframe', { static: true }) iframe: ElementRef; ngAfterViewInit() { // DEVELOPMENT TESTING ONLY // this.src = 'https://angular.io/resources/live-examples/quickstart/ts/stackblitz.json'; if (this.iframe) { // security: the `src` is always authored by the documentation team // and is considered to be safe this.iframe.nativeElement.src = this.src; } } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/live-example/live-example.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { EmbeddedStackblitzComponent, LiveExampleComponent } from './live-example.component'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule ], declarations: [ LiveExampleComponent, EmbeddedStackblitzComponent ] }) export class LiveExampleModule implements WithCustomElementComponent { customElementComponent: Type = LiveExampleComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/README.md ================================================ The `operator-decision-tree` module requires `decision-tree-data.json`, which is hosted at `/generated/app`. The JSON is generated via `apps/rxjs.dev/content/operator-decision-tree.yml`. # TODO - Consider placing the widget on the home page - or a link on the home page, “Decision Tree” - Manual focus calls when navigating the tree (example: after making a selection, focus on the current sentence) - Drop jasmine-marbles for just the TestScheduler ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/fixtures.ts ================================================ import { OperatorDecisionTree, OperatorTreeNode, OperatorTreeNodeWithOptions } from './interfaces'; export const treeNodeStubWithOptionsA: OperatorTreeNodeWithOptions = { id: 'treeNodeStubWithOptionsA', label: 'someLabelA', options: ['treeNodeStubWithOptionsB'] }; export const treeNodeStubWithOptionsB: OperatorTreeNodeWithOptions = { id: 'treeNodeStubWithOptionsB', label: 'someLabelB', options: ['treeNodeStubNoOptions'] }; export const treeNodeStubNoOptions: OperatorTreeNode = { id: 'treeNodeStubNoOptions', label: 'somelabelNoOptions', path: 'some/path/NoOptions', docType: 'someDocTypeNoOptions' }; export const treeNodeInitialStub = { initial: { id: 'initial', options: ['treeNodeStubWithOptionsA'] } }; export const treeStub: OperatorDecisionTree = { [treeNodeStubWithOptionsA.id]: treeNodeStubWithOptionsA, [treeNodeStubWithOptionsB.id]: treeNodeStubWithOptionsB, [treeNodeStubNoOptions.id]: treeNodeStubNoOptions, ...treeNodeInitialStub }; ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/interfaces.ts ================================================ export interface OperatorTreeNode { id: string; label?: string; options?: string[]; path?: string; docType?: string; method?: string; } export interface OperatorTreeNodeWithOptions extends OperatorTreeNode { options: string[]; } export interface OperatorDecisionTree { [key: string]: OperatorTreeNode; initial: Required>; error?: any; } export interface State { previousBranchIds: string[]; currentBranchId: string; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree-data.service.spec.ts ================================================ import { TestBed } from '@angular/core/testing'; import { OperatorDecisionTreeDataService } from './operator-decision-tree-data.service'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { treeStub } from './fixtures'; describe('OperatorDecisionTreeDataService', () => { let service: OperatorDecisionTreeDataService; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [OperatorDecisionTreeDataService] }); httpTestingController = TestBed.inject(HttpTestingController); service = TestBed.inject(OperatorDecisionTreeDataService); }); describe('getDecisionTree$', () => { it('should get the decision-tree-data.json', () => { service.getDecisionTree$().subscribe( data => expect(data).toBe(treeStub) ); const req = httpTestingController.expectOne('/generated/docs/app/decision-tree-data.json'); expect(req.request.method).toEqual('GET'); req.flush(treeStub); httpTestingController.verify(); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree-data.service.ts ================================================ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { OperatorDecisionTree } from './interfaces'; @Injectable() export class OperatorDecisionTreeDataService { constructor(private http: HttpClient) {} getDecisionTree$(): Observable { return this.http.get( '/generated/docs/app/decision-tree-data.json' ); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.component.scss ================================================ @use '@angular/material' as mat; @import '../../../styles/constants'; h2 { max-width: 700px; } button.option { @include mat.elevation-transition; border-radius: 34px; border: 0; cursor: pointer; display: block; margin-bottom: 12px; padding: 0; text-align: left; &:active, &:hover, &:focus { @include mat.elevation(8); mat-card { background-color: $pink; color: $white; } } } mat-card { border-radius: 34px; padding: 12px 24px; transition: all 250ms; } section { margin-bottom: 16px; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.component.spec.ts ================================================ import { CommonModule, Location } from '@angular/common'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ScrollService } from 'app/shared/scroll.service'; import { BehaviorSubject } from 'rxjs'; import { treeNodeStubNoOptions, treeNodeStubWithOptionsA } from './fixtures'; import { OperatorDecisionTreeComponent } from './operator-decision-tree.component'; import { OperatorDecisionTreeService } from './operator-decision-tree.service'; const operatorDecisionTreeServiceStub = { currentSentence$: new BehaviorSubject('Conditioner is better'), options$: new BehaviorSubject([treeNodeStubWithOptionsA]), isBeyondInitialQuestion$: new BehaviorSubject(false), hasError$: new BehaviorSubject(false), selectOption: jasmine.createSpy(), back: jasmine.createSpy(), startOver: jasmine.createSpy() }; describe('OperatorDecisionTreeComponent', () => { let component: OperatorDecisionTreeComponent; let fixture: ComponentFixture; let operatorDecisionTreeService: OperatorDecisionTreeService; let scrollService: ScrollService; let locationService: jasmine.SpyObj; beforeEach(waitForAsync(() => { locationService = jasmine.createSpyObj(['subscribe']); TestBed.configureTestingModule({ imports: [ CommonModule, MatButtonModule, MatCardModule, MatRippleModule, NoopAnimationsModule ], declarations: [OperatorDecisionTreeComponent], providers: [ { provide: OperatorDecisionTreeService, useValue: operatorDecisionTreeServiceStub }, ScrollService, {provide: Location, useValue: locationService } ] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(OperatorDecisionTreeComponent); component = fixture.componentInstance; operatorDecisionTreeService = TestBed.inject(OperatorDecisionTreeService); scrollService = TestBed.inject(ScrollService); fixture.detectChanges(); }); afterEach(() => { operatorDecisionTreeServiceStub.currentSentence$.next( 'Conditioner is better' ); operatorDecisionTreeServiceStub.options$.next([treeNodeStubWithOptionsA]); operatorDecisionTreeServiceStub.isBeyondInitialQuestion$.next(false); operatorDecisionTreeServiceStub.hasError$.next(false); }); it('should create', () => { expect(component).toBeTruthy(); }); describe('in the template', () => { describe('when the OperatorDecisionTreeService.currentSentence$ emits a signal', () => { it('should update what is being displayed as the current sentence', () => { expect( fixture.debugElement.query(By.css('h2')).nativeElement.textContent ).toContain('Conditioner is better'); operatorDecisionTreeServiceStub.currentSentence$.next( 'Shampoo is better' ); fixture.detectChanges(); expect( fixture.debugElement.query(By.css('h2')).nativeElement.textContent ).toContain('Shampoo is better'); }); }); describe('when there are options to choose', () => { it('should have option buttons', () => { expect( fixture.debugElement.queryAll(By.css('button.option')).length ).toBeTruthy(); }); }); describe('when there are no more options to choose', () => { it('should have no option buttons', () => { operatorDecisionTreeServiceStub.options$.next([ treeNodeStubNoOptions as any ]); fixture.detectChanges(); expect( fixture.debugElement.queryAll(By.css('button.option')).length ).toBeFalsy(); }); describe('when there is a method associated with the operator', () => { it('should display a method, docType, label, and a link to the operator path', () => { const node = { ...treeNodeStubNoOptions, method: 'someMethod' }; operatorDecisionTreeServiceStub.options$.next([node as any]); fixture.detectChanges(); const sentence: HTMLParagraphElement = fixture.debugElement.query( By.css('p') ).nativeElement; const link: HTMLAnchorElement = fixture.debugElement .query(By.css('a')) .nativeElement.getAttribute('href'); expect(sentence.textContent).toContain( `You want the ${node.method} of the ${node.docType} ${node.label}.` ); expect(link).toContain(`${node.path}#${node.method}`); }); }); describe('when there is no method associated with the operator', () => { it('should display a docType, label, and a link to the operator path', () => { operatorDecisionTreeServiceStub.options$.next([ treeNodeStubNoOptions as any ]); fixture.detectChanges(); const sentence: HTMLParagraphElement = fixture.debugElement.query( By.css('p') ).nativeElement; const link: HTMLAnchorElement = fixture.debugElement .query(By.css('a')) .nativeElement.getAttribute('href'); expect(sentence.textContent).toContain( `You want the ${treeNodeStubNoOptions.docType} ${ treeNodeStubNoOptions.label }.` ); expect(link).toContain(treeNodeStubNoOptions.path); }); }); }); describe('when there are no errors', () => { it('should not display the error template', () => { expect(fixture.debugElement.query(By.css('div.error'))).toBeNull(); }); }); describe('when there is an error', () => { it('should display the error template', () => { operatorDecisionTreeServiceStub.hasError$.next(true); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('div.error'))).toBeTruthy(); }); }); }); describe('selectOption', () => { describe('when an option is clicked', () => { it('should call the selectOption method', () => { spyOn(component, 'selectOption'); fixture.debugElement .query(By.css('button.option')) .triggerEventHandler('click', null); expect(component.selectOption).toHaveBeenCalled(); }); }); describe('when fired', () => { it('should call the selectOption method on the operatorDecisionTreeService', () => { component.selectOption(treeNodeStubWithOptionsA.id); expect(operatorDecisionTreeService.selectOption).toHaveBeenCalledWith( treeNodeStubWithOptionsA.id ); }); it('should call the scrollToTop method of the scrollService', () => { spyOn(scrollService, 'scrollToTop'); component.selectOption(treeNodeStubWithOptionsA.id); expect(scrollService.scrollToTop).toHaveBeenCalled(); }); }); }); describe('back', () => { describe('when the back button is pressed', () => { it('should should call the back method', () => { spyOn(component, 'back'); operatorDecisionTreeServiceStub.isBeyondInitialQuestion$.next(true); fixture.detectChanges(); fixture.debugElement .query(By.css('button.back')) .triggerEventHandler('click', null); expect(component.back).toHaveBeenCalled(); }); }); describe('when fired', () => { it('should call the back method on the operatorDecisionTreeService', () => { component.back(); expect(operatorDecisionTreeService.back).toHaveBeenCalled(); }); }); }); describe('startOver', () => { describe('when the start-over button is pressed', () => { it('should should call the startOver method', () => { spyOn(component, 'startOver'); operatorDecisionTreeServiceStub.isBeyondInitialQuestion$.next(true); fixture.detectChanges(); fixture.debugElement .query(By.css('button.start-over')) .triggerEventHandler('click', null); expect(component.startOver).toHaveBeenCalled(); }); }); describe('when fired', () => { it('should call the startOver method on the operatorDecisionTreeService', () => { component.startOver(); expect(operatorDecisionTreeService.startOver).toHaveBeenCalled(); }); }); }); describe('ngOnDestroy', () => { it('should call the startOver method', () => { spyOn(component, 'startOver'); component.ngOnDestroy(); expect(component.startOver).toHaveBeenCalled(); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.component.ts ================================================ import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, OnDestroy } from '@angular/core'; import { ScrollService } from 'app/shared/scroll.service'; import { Observable } from 'rxjs'; import { OperatorTreeNode } from './interfaces'; import { OperatorDecisionTreeService } from './operator-decision-tree.service'; @Component({ selector: 'aio-operator-decision-tree', template: `

Operator Decision Tree

{{ currentSentence$ | async }}

You want the {{ option.method }} of the {{ option.docType }} {{ option.label }}.

You want the {{ option.docType }} {{ option.label }}.

Oops! There was an issue loading the decision tree.. we're real sorry about that. Please try reloading the page.

You can also try submitting an issue on GitHub.

`, styleUrls: ['./operator-decision-tree.component.scss'], animations: [ trigger('flyIn', [ state('in', style({ transform: 'translateX(0)' })), transition(':enter', [style({ transform: 'translateX(-100%)' }), animate(250)]), ]), ], }) export class OperatorDecisionTreeComponent implements OnDestroy { currentSentence$: Observable = this.operatorDecisionTreeService.currentSentence$; options$: Observable = this.operatorDecisionTreeService.options$; isBeyondInitialQuestion$: Observable = this.operatorDecisionTreeService.isBeyondInitialQuestion$; hasError$: Observable = this.operatorDecisionTreeService.hasError$; constructor(private operatorDecisionTreeService: OperatorDecisionTreeService, private scrollService: ScrollService) {} selectOption(optionId: string): void { this.operatorDecisionTreeService.selectOption(optionId); this.scrollService.scrollToTop(); } back(): void { this.operatorDecisionTreeService.back(); } startOver(): void { this.operatorDecisionTreeService.startOver(); } ngOnDestroy(): void { this.startOver(); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.module.spec.ts ================================================ import { OperatorDecisionTreeModule } from './operator-decision-tree.module'; describe('OperatorDecisionTreeModule', () => { let chooseYourOwnOperatorModule: OperatorDecisionTreeModule; beforeEach(() => { chooseYourOwnOperatorModule = new OperatorDecisionTreeModule(); }); it('should create an instance', () => { expect(chooseYourOwnOperatorModule).toBeTruthy(); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.module.ts ================================================ import { CommonModule } from '@angular/common'; import { NgModule, Type } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { ScrollService } from 'app/shared/scroll.service'; import { WithCustomElementComponent } from '../element-registry'; import { OperatorDecisionTreeDataService } from './operator-decision-tree-data.service'; import { OperatorDecisionTreeComponent } from './operator-decision-tree.component'; import { OperatorDecisionTreeService } from './operator-decision-tree.service'; @NgModule({ imports: [CommonModule, MatButtonModule, MatCardModule, MatRippleModule], declarations: [OperatorDecisionTreeComponent], providers: [ OperatorDecisionTreeDataService, OperatorDecisionTreeService, ScrollService ] }) export class OperatorDecisionTreeModule implements WithCustomElementComponent { customElementComponent: Type< OperatorDecisionTreeComponent > = OperatorDecisionTreeComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.service.spec.ts ================================================ import { TestBed } from '@angular/core/testing'; import { cold, initTestScheduler, addMatchers } from 'jasmine-marbles'; import { treeNodeInitialStub, treeNodeStubNoOptions, treeNodeStubWithOptionsA, treeNodeStubWithOptionsB, treeStub } from './fixtures'; import { OperatorDecisionTreeDataService } from './operator-decision-tree-data.service'; import { OperatorDecisionTreeService } from './operator-decision-tree.service'; describe('OperatorDecisionTreeService', () => { let service: OperatorDecisionTreeService; const dataServiceStub = { getDecisionTree$: jasmine.createSpy() }; beforeEach(() => { addMatchers(); initTestScheduler(); TestBed.configureTestingModule({ providers: [ OperatorDecisionTreeService, { provide: OperatorDecisionTreeDataService, useValue: dataServiceStub } ] }); }); describe('currentSentence$', () => { const initialSentence = 'Start by choosing an option from the list below.'; beforeEach(() => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: treeStub }) ); service = TestBed.inject(OperatorDecisionTreeService); }); describe('when it is the initial sequence', () => { it('should emit an initial sentence', () => { spyOn(service, 'selectOption'); expect(service.currentSentence$).toBeObservable( cold('x', { x: initialSentence }) ); expect(service.selectOption).not.toHaveBeenCalled(); }); }); describe('when an option is selected', () => { it('should emit a sentence based on previous chosen labels', () => { service.selectOption(treeNodeStubWithOptionsA.id); expect(service.currentSentence$).toBeObservable( cold('x', { x: `${treeNodeStubWithOptionsA.label}...` }) ); service.selectOption(treeNodeStubWithOptionsB.id); expect(service.currentSentence$).toBeObservable( cold('x', { x: `${treeNodeStubWithOptionsA.label} ${ treeNodeStubWithOptionsB.label }...` }) ); }); describe('and the back method is called', () => { it('should emit the previous sentence', () => { service.selectOption(treeNodeStubWithOptionsA.id); service.selectOption(treeNodeStubWithOptionsB.id); service.back(); expect(service.currentSentence$).toBeObservable( cold('x', { x: `${treeNodeStubWithOptionsA.label}...` }) ); }); }); describe('and the startOver method is called', () => { it('should emit the initial sentence', () => { service.selectOption(treeNodeStubWithOptionsA.id); service.startOver(); expect(service.currentSentence$).toBeObservable( cold('x', { x: initialSentence }) ); }); }); }); }); describe('options$', () => { describe('signals do not get past the filter,', () => { describe('when the tree has an error', () => { it('should never emit', () => { dataServiceStub.getDecisionTree$.and.returnValue(cold('#')); service = TestBed.inject(OperatorDecisionTreeService); expect(service.options$).toBeObservable(cold('-')); }); }); describe('when the current branch has no options', () => { it('should never emit', () => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: { [treeNodeStubNoOptions.id]: treeNodeStubNoOptions } }) ); service = TestBed.inject(OperatorDecisionTreeService); expect(service.options$).toBeObservable(cold('-')); }); }); describe('when the currentBranchId does not exist in the tree', () => { it('should never emit', () => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: { foo: treeNodeStubNoOptions } }) ); service = TestBed.inject(OperatorDecisionTreeService); expect(service.options$).toBeObservable(cold('-')); }); }); }); describe('when signals get past the filter', () => { beforeEach(() => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: treeStub }) ); service = TestBed.inject(OperatorDecisionTreeService); }); describe('when it is the initial sequence', () => { it('should be an array of the tree nodes from the initial options', () => { expect(service.options$).toBeObservable( cold('a', { a: [treeStub[treeNodeInitialStub.initial.options[0]]] }) ); }); }); describe('when an option is selected', () => { describe('and there are additional options', () => { it('should be an array of the new option nodes', () => { service.selectOption(treeNodeStubWithOptionsA.id); expect(service.options$).toBeObservable( cold('a', { a: [treeNodeStubWithOptionsB] }) ); }); }); describe('and there are no additional options', () => { it('should not emit', () => { service.selectOption(treeNodeStubNoOptions.id); expect(service.options$).toBeObservable(cold('-')); }); }); }); }); }); describe('isBeyondInitialQuestion$', () => { beforeEach(() => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: treeStub }) ); service = TestBed.inject(OperatorDecisionTreeService); }); describe('when not beyond the initial question', () => { it('should be false', () => { spyOn(service, 'selectOption'); expect(service.isBeyondInitialQuestion$).toBeObservable( cold('a', { a: false }) ); expect(service.selectOption).not.toHaveBeenCalled(); }); }); describe('when beyond the initial question', () => { it('should be true', () => { service.selectOption(treeNodeStubWithOptionsA.id); expect(service.isBeyondInitialQuestion$).toBeObservable( cold('a', { a: true }) ); }); }); }); describe('hasError$', () => { describe('when the tree has no error', () => { it('should not emit', () => { dataServiceStub.getDecisionTree$.and.returnValue( cold('x', { x: treeStub }) ); service = TestBed.inject(OperatorDecisionTreeService); expect(service.hasError$).toBeObservable(cold('-')); }); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/operator-decision-tree.service.ts ================================================ import { Injectable } from '@angular/core'; import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; import { filter, map, mapTo, shareReplay, catchError } from 'rxjs/operators'; import { OperatorDecisionTree, OperatorTreeNode, State } from './interfaces'; import { OperatorDecisionTreeDataService } from './operator-decision-tree-data.service'; import { isInitialDecision, nodeHasOptions, treeIsErrorFree } from './utils'; @Injectable() export class OperatorDecisionTreeService { private initialState: State = { previousBranchIds: ['initial'], currentBranchId: 'initial' }; private state$ = new BehaviorSubject(this.initialState); private tree$: Observable< OperatorDecisionTree > = this.dataService.getDecisionTree$().pipe( catchError(error => of(error)), // This helps if the JSON for some reason fails to get fetched shareReplay() ); currentSentence$: Observable = combineLatest( this.tree$, this.state$ ).pipe( filter(([tree]) => treeIsErrorFree(tree)), map(([tree, { previousBranchIds }]) => isInitialDecision(previousBranchIds) ? 'Start by choosing an option from the list below.' : `${previousBranchIds .map(entityId => tree[entityId].label) .join(' ')}...`.trim() ) ); options$: Observable<(OperatorTreeNode)[]> = combineLatest( this.tree$, this.state$ ).pipe( filter(([tree, state]) => ( treeIsErrorFree(tree) && !!tree[state.currentBranchId] && !!tree[state.currentBranchId].options )), map(([tree, state]) => { // Project is currently using TypeScript 2.9.2 // With TS 3.1+ this can be done better if we map to [tree, node] and typeguard with a tuple in a filter // filter((a): a is [OperatorDecisionTree, OperatorTreeNodeWithOptions] => !a[0].error && !!a[1].options) const node = tree[state.currentBranchId]; return nodeHasOptions(node) ? node.options.map(option => tree[option]) : tree.initial.options.map(option => tree[option]); }) ); isBeyondInitialQuestion$: Observable = this.state$.pipe( map(({ currentBranchId }) => currentBranchId !== 'initial') ); // This helps if the JSON for some reason fails to get fetched hasError$ = this.tree$.pipe( filter(tree => !!tree.error), mapTo(true) ); constructor(private dataService: OperatorDecisionTreeDataService) {} private get snapShot(): State { return this.state$.getValue(); } selectOption(optionId: string): void { this.state$.next({ previousBranchIds: [...this.snapShot.previousBranchIds, optionId], currentBranchId: optionId }); } back(): void { const previousOptionId = this.snapShot.previousBranchIds[ this.snapShot.previousBranchIds.length - 2 ]; if (previousOptionId) { this.state$.next({ previousBranchIds: [ ...this.snapShot.previousBranchIds.slice( 0, this.snapShot.previousBranchIds.length - 1 ) ], currentBranchId: previousOptionId }); } } startOver(): void { this.state$.next(this.initialState); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/utils.spec.ts ================================================ import { isInitialDecision, treeIsErrorFree, nodeHasOptions } from './utils'; describe('isInitialDecision', () => { describe('when it is an initial decision', () => { it('should be true', () => { expect(isInitialDecision(['initial'])).toBe(true); }); }); describe('when it is not an initial decision', () => { it('should be false', () => { expect(isInitialDecision(['initial', 'foo'])).toBe(false); }); }); }); describe('treeIsErrorFree', () => { describe('when the tree is error free', () => { it('should return true', () => { expect(treeIsErrorFree({} as any)).toBe(true); }); }); describe('when the tree has an error', () => { it('should return false', () => { expect(treeIsErrorFree({error: true} as any)).toBe(false); }); }); }); describe('nodeHasOptions', () => { describe('when node has options', () => { it('should return true', () => { expect(nodeHasOptions({options: ['123']} as any)).toBe(true); }); }); describe('when node has no options', () => { it('should return false', () => { expect(nodeHasOptions({} as any)).toBe(false); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/operator-decision-tree/utils.ts ================================================ import { OperatorDecisionTree, OperatorTreeNode, OperatorTreeNodeWithOptions } from './interfaces'; export function isInitialDecision(previousBranchIds: string[]): boolean { return ( previousBranchIds.includes('initial') && previousBranchIds.length === 1 ); } export function treeIsErrorFree(tree: OperatorDecisionTree): boolean { return !tree.error; } export function nodeHasOptions(node: OperatorTreeNode): node is OperatorTreeNodeWithOptions { return !!node.options; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource-list.component.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { PlatformLocation } from '@angular/common'; import { of } from 'rxjs'; import { ResourceListComponent } from './resource-list.component'; import { ResourceService } from './resource.service'; import { Category } from './resource.model'; // Testing the component class behaviors, independent of its template // Let e2e tests verify how it displays. describe('ResourceListComponent', () => { let injector: ReflectiveInjector; let location: TestPlatformLocation; beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ ResourceListComponent, {provide: PlatformLocation, useClass: TestPlatformLocation }, {provide: ResourceService, useClass: TestResourceService } ]); location = injector.get(PlatformLocation); }); it('should set the location w/o leading slashes', () => { location.pathname = '////resources'; const component = getComponent(); expect(component.location).toBe('resources'); }); it('href(id) should return the expected href', () => { location.pathname = '////resources'; const component = getComponent(); expect(component.href({id: 'foo'})).toBe('resources#foo'); }); it('should set scroll position to zero when no target element', () => { const component = getComponent(); component.onScroll(undefined); expect(component.scrollPos).toBe(0); }); it('should set scroll position to element.scrollTop when that is defined', () => { const component = getComponent(); component.onScroll({scrollTop: 42}); expect(component.scrollPos).toBe(42); }); it('should set scroll position to element.body.scrollTop when that is defined', () => { const component = getComponent(); component.onScroll({body: {scrollTop: 42}}); expect(component.scrollPos).toBe(42); }); it('should set scroll position to 0 when no target.body.scrollTop defined', () => { const component = getComponent(); component.onScroll({body: {}}); expect(component.scrollPos).toBe(0); }); //// Test Helpers //// function getComponent(): ResourceListComponent { return injector.get(ResourceListComponent); } class TestPlatformLocation { pathname = 'resources'; } class TestResourceService { categories = of(getTestData); } function getTestData(): Category[] { return []; // Not interested in the data in these tests } }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource-list.component.ts ================================================ import { Component, HostListener, OnInit } from '@angular/core'; import { PlatformLocation } from '@angular/common'; import { Category } from './resource.model'; import { ResourceService } from './resource.service'; @Component({ selector: 'aio-resource-list', template: `

{{ category.title }}

{{ subCategory.title }}

{{ resource.title }}

{{ resource.desc || 'No Description' }}

`, }) export class ResourceListComponent implements OnInit { categories: Category[]; location: string; scrollPos = 0; constructor(location: PlatformLocation, private resourceService: ResourceService) { this.location = location.pathname.replace(/^\/+/, ''); } href(cat: { id: string }) { return this.location + '#' + cat.id; } ngOnInit() { // Not using async pipe because cats appear twice in template // No need to unsubscribe because categories observable completes. this.resourceService.categories.subscribe((cats) => (this.categories = cats)); } @HostListener('window:scroll', ['$event.target']) onScroll(target: any) { this.scrollPos = target ? target.scrollTop || target.body.scrollTop || 0 : 0; } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource-list.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ResourceListComponent } from './resource-list.component'; import { ResourceService } from './resource.service'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule ], declarations: [ ResourceListComponent ], providers: [ ResourceService ] }) export class ResourceListModule implements WithCustomElementComponent { customElementComponent: Type = ResourceListComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource.model.ts ================================================ export class Category { id: string; // "education" title: string; // "Education" order: number; // 2 subCategories: SubCategory[]; } export class SubCategory { id: string; // "books" title: string; // "Books" order: number; // 1 resources: Resource[]; } export class Resource { category: string; // "Education" subCategory: string; // "Books" id: string; // "-KLI8vJ0ZkvWhqPembZ7" desc: string; // "This books shows all the steps necessary for the development of SPA" rev: boolean; // true (always true in the original) title: string; // "Practical Angular 2", url: string; // "https://leanpub.com/practical-angular-2" } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource.service.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ResourceService } from './resource.service'; import { Category } from './resource.model'; describe('ResourceService', () => { let injector: Injector; let resourceService: ResourceService; let httpMock: HttpTestingController; beforeEach(() => { injector = TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ ResourceService ] }); resourceService = injector.get(ResourceService); httpMock = injector.get(HttpTestingController); }); afterEach(() => httpMock.verify()); it('should make a single connection to the server', () => { const req = httpMock.expectOne({}); expect(req.request.url).toBe('generated/resources.json'); }); describe('#categories', () => { let categories: Category[]; let testData: any; beforeEach(() => { testData = getTestResources(); httpMock.expectOne({}).flush(testData); resourceService.categories.subscribe(results => categories = results); }); it('categories observable should complete', () => { let completed = false; resourceService.categories.subscribe(undefined, undefined, () => completed = true); expect(completed).toBe(true, 'observable completed'); }); it('should reshape contributors.json to sorted category array', () => { const actualIds = categories.map(c => c.id).join(','); expect(actualIds).toBe('cat-1,cat-3'); }); it('should convert ids to canonical form', () => { // canonical form is lowercase with dashes for spaces const cat = categories[1]; const sub = cat.subCategories[0]; const res = sub.resources[0]; expect(cat.id).toBe('cat-3', 'category id'); expect(sub.id).toBe('cat3-subcat2', 'subcat id'); expect(res.id).toBe('cat3-subcat2-res1', 'resources id'); }); it('resource knows its category and sub-category titles', () => { const cat = categories[1]; const sub = cat.subCategories[0]; const res = sub.resources[0]; expect(res.category).toBe(cat.title, 'category title'); expect(res.subCategory).toBe(sub.title, 'subcategory title'); }); it('should have expected SubCategories of "Cat 3"', () => { const actualIds = categories[1].subCategories.map(s => s.id).join(','); expect(actualIds).toBe('cat3-subcat2,cat3-subcat1'); }); it('should have expected sorted resources of "Cat 1:SubCat1"', () => { const actualIds = categories[0].subCategories[0].resources.map(r => r.id).join(','); expect(actualIds).toBe('a-a-a,s-s-s,z-z-z'); }); }); it('should do WHAT(?) if the request fails'); }); function getTestResources() { return { 'Cat 3': { order: 3, subCategories: { 'Cat3 SubCat1': { order: 2, resources: { 'Cat3 SubCat1 Res1': { desc: 'Meetup in Barcelona, Spain. ', rev: true, title: 'Angular Beers', url: 'http://www.meetup.com/AngularJS-Beers/' }, 'Cat3 SubCat1 Res2': { desc: 'Angular Camps in Barcelona, Spain.', rev: true, title: 'Angular Camp', url: 'http://angularcamp.org/' } } }, 'Cat3 SubCat2': { order: 1, resources: { 'Cat3 SubCat2 Res1': { desc: 'A community index of components and libraries', rev: true, title: 'Catalog of Angular Components & Libraries', url: 'https://a/b/c' } } }, } }, 'Cat 1': { order: 1, subCategories: { 'Cat1 SubCat1': { order: 1, resources: { 'S S S': { desc: 'SSS', rev: true, title: 'Sssss', url: 'http://s/s/s' }, 'A A A': { desc: 'AAA', rev: true, title: 'Aaaa', url: 'http://a/a/a' }, 'Z Z Z': { desc: 'ZZZ', rev: true, title: 'Zzzzz', url: 'http://z/z/z' } } }, }, } }; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/resource/resource.service.ts ================================================ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { ConnectableObservable, Observable } from 'rxjs'; import { map, publishLast } from 'rxjs/operators'; import { Category, Resource, SubCategory } from './resource.model'; import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; const resourcesPath = CONTENT_URL_PREFIX + 'resources.json'; @Injectable() export class ResourceService { categories: Observable; constructor(private http: HttpClient) { this.categories = this.getCategories(); } private getCategories(): Observable { const categories = this.http.get(resourcesPath).pipe( map(data => mkCategories(data)), publishLast(), ); (categories as ConnectableObservable).connect(); return categories; }; } // Extract sorted Category[] from resource JSON data function mkCategories(categoryJson: any): Category[] { return Object.keys(categoryJson).map(catKey => { const cat = categoryJson[catKey]; return { id: makeId(catKey), title: catKey, order: cat.order, subCategories: mkSubCategories(cat.subCategories, catKey) } as Category; }) .sort(compareCats); } // Extract sorted SubCategory[] from JSON category data function mkSubCategories(subCategoryJson: any, catKey: string): SubCategory[] { return Object.keys(subCategoryJson).map(subKey => { const sub = subCategoryJson[subKey]; return { id: makeId(subKey), title: subKey, order: sub.order, resources: mkResources(sub.resources, subKey, catKey) } as SubCategory; }) .sort(compareCats); } // Extract sorted Resource[] from JSON subcategory data function mkResources(resourceJson: any, subKey: string, catKey: string): Resource[] { return Object.keys(resourceJson).map(resKey => { const res = resourceJson[resKey]; res.category = catKey; res.subCategory = subKey; res.id = makeId(resKey); return res as Resource; }) .sort(compareTitles); } function compareCats(l: Category | SubCategory, r: Category | SubCategory) { return l.order === r.order ? compareTitles(l, r) : l.order > r.order ? 1 : -1; } function compareTitles(l: {title: string}, r: {title: string}) { return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1; } function makeId(title: string) { return title.toLowerCase().replace(/\s+/g, '-'); } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/search/file-not-found-search.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Subject } from 'rxjs'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; import { SearchResults } from 'app/search/interfaces'; import { SearchResultsComponent } from 'app/shared/search-results/search-results.component'; import { SearchService } from 'app/search/search.service'; import { FileNotFoundSearchComponent } from './file-not-found-search.component'; describe('FileNotFoundSearchComponent', () => { let fixture: ComponentFixture; let searchService: SearchService; let searchResultSubject: Subject; beforeEach(() => { TestBed.configureTestingModule({ declarations: [ FileNotFoundSearchComponent, SearchResultsComponent ], providers: [ { provide: LocationService, useValue: new MockLocationService('base/initial-url?some-query') }, SearchService ] }); fixture = TestBed.createComponent(FileNotFoundSearchComponent); searchService = TestBed.inject(SearchService); searchResultSubject = new Subject(); spyOn(searchService, 'search').and.callFake(() => searchResultSubject.asObservable()); fixture.detectChanges(); }); it('should run a search with a query built from the current url', () => { expect(searchService.search).toHaveBeenCalledWith('base initial url'); }); it('should pass through any results to the `aio-search-results` component', () => { const searchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance; expect(searchResultsComponent.searchResults).toBe(null); const results = { query: 'base initial url', results: []}; searchResultSubject.next(results); fixture.detectChanges(); expect(searchResultsComponent.searchResults).toEqual(results); }); }); ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/search/file-not-found-search.component.ts ================================================ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LocationService } from 'app/shared/location.service'; import { SearchResults } from 'app/search/interfaces'; import { SearchService } from 'app/search/search.service'; @Component({ selector: 'aio-file-not-found-search', template: `

Let's see if any of these search results help...

` }) export class FileNotFoundSearchComponent implements OnInit { searchResults: Observable; constructor(private location: LocationService, private search: SearchService) {} ngOnInit() { this.searchResults = this.location.currentPath.pipe(switchMap(path => { const query = path.split(/\W+/).join(' '); return this.search.search(query); })); } } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/search/file-not-found-search.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '../../shared/shared.module'; import { FileNotFoundSearchComponent } from './file-not-found-search.component'; import { WithCustomElementComponent } from '../element-registry'; @NgModule({ imports: [ CommonModule, SharedModule ], declarations: [ FileNotFoundSearchComponent ] }) export class FileNotFoundSearchModule implements WithCustomElementComponent { customElementComponent: Type = FileNotFoundSearchComponent; } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/toc/toc.component.ts ================================================ import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; import { asapScheduler as asap, combineLatest, Subject } from 'rxjs'; import { startWith, subscribeOn, takeUntil } from 'rxjs/operators'; import { ScrollService } from 'app/shared/scroll.service'; import { TocItem, TocService } from 'app/shared/toc.service'; type TocType = 'None' | 'Floating' | 'EmbeddedSimple' | 'EmbeddedExpandable'; @Component({ selector: 'aio-toc', template: `
Contents
`, styles: [], }) export class TocComponent implements OnInit, AfterViewInit, OnDestroy { activeIndex: number | null = null; type: TocType = 'None'; isCollapsed = true; isEmbedded = false; @ViewChildren('tocItem') private items: QueryList; private onDestroy = new Subject(); primaryMax = 4; tocList: TocItem[]; constructor(private scrollService: ScrollService, elementRef: ElementRef, private tocService: TocService) { this.isEmbedded = elementRef.nativeElement.className.indexOf('embedded') !== -1; } ngOnInit() { this.tocService.tocList.pipe(takeUntil(this.onDestroy)).subscribe((tocList) => { this.tocList = tocList; const itemCount = count(this.tocList, (item) => item.level !== 'h1'); this.type = itemCount > 0 ? (this.isEmbedded ? (itemCount > this.primaryMax ? 'EmbeddedExpandable' : 'EmbeddedSimple') : 'Floating') : 'None'; }); } ngAfterViewInit() { if (!this.isEmbedded) { // We use the `asap` scheduler because updates to `activeItemIndex` are triggered by DOM changes, // which, in turn, are caused by the rendering that happened due to a ChangeDetection. // Without asap, we would be updating the model while still in a ChangeDetection handler, which is disallowed by Angular. combineLatest(this.tocService.activeItemIndex.pipe(subscribeOn(asap)), this.items.changes.pipe(startWith(this.items))) .pipe(takeUntil(this.onDestroy)) .subscribe(([index, items]) => { this.activeIndex = index; if (index === null || index >= items.length) { return; } const e = items.toArray()[index].nativeElement; const p = e.offsetParent; const eRect = e.getBoundingClientRect(); const pRect = p.getBoundingClientRect(); const isInViewport = eRect.top >= pRect.top && eRect.bottom <= pRect.bottom; if (!isInViewport) { p.scrollTop += eRect.top - pRect.top - p.clientHeight / 2; } }); } } ngOnDestroy() { this.onDestroy.next(null); } toggle(canScroll = true) { this.isCollapsed = !this.isCollapsed; if (canScroll && this.isCollapsed) { this.toTop(); } } toTop() { this.scrollService.scrollToTop(); } } function count(array: T[], fn: (item: T) => boolean) { return array.reduce((result, item) => (fn(item) ? result + 1 : result), 0); } ================================================ FILE: apps/rxjs.dev/src/app/custom-elements/toc/toc.module.ts ================================================ import { NgModule, Type } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { WithCustomElementComponent } from '../element-registry'; import { TocComponent } from './toc.component'; @NgModule({ imports: [ CommonModule, MatIconModule ], declarations: [ TocComponent ] }) export class TocModule implements WithCustomElementComponent { customElementComponent: Type = TocComponent; } ================================================ FILE: apps/rxjs.dev/src/app/documents/document-contents.ts ================================================ export interface DocumentContents { /** The unique identifier for this document */ id: string; /** The HTML to display in the doc viewer */ contents: string|null; } ================================================ FILE: apps/rxjs.dev/src/app/documents/document.service.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { Subscription } from 'rxjs'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; import { Logger } from 'app/shared/logger.service'; import { MockLogger } from 'testing/logger.service'; import { DocumentService, DocumentContents, FETCHING_ERROR_ID, FILE_NOT_FOUND_ID } from './document.service'; const CONTENT_URL_PREFIX = 'generated/docs/'; describe('DocumentService', () => { let httpMock: HttpTestingController; function createInjector(initialUrl: string) { return TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ DocumentService, { provide: LocationService, useFactory: () => new MockLocationService(initialUrl) }, { provide: Logger, useClass: MockLogger }, ], }); } function getServices(initialUrl: string = '') { const injector = createInjector(initialUrl); httpMock = injector.get(HttpTestingController) as HttpTestingController; return { locationService: injector.get(LocationService) as MockLocationService, docService: injector.get(DocumentService) as DocumentService, logger: injector.get(Logger) as MockLogger, }; } afterEach(() => httpMock.verify()); describe('currentDocument', () => { it('should fetch a document for the initial location', () => { const { docService } = getServices('initial/doc'); docService.currentDocument.subscribe(); httpMock.expectOne(CONTENT_URL_PREFIX + 'initial/doc.json'); }); it('should emit a document each time the location changes', () => { let latestDocument: DocumentContents | undefined; const doc0 = { contents: 'doc 0', id: 'initial/doc' }; const doc1 = { contents: 'doc 1', id: 'new/doc' }; const { docService, locationService } = getServices('initial/doc'); docService.currentDocument.subscribe((doc) => (latestDocument = doc)); expect(latestDocument).toBeUndefined(); httpMock.expectOne({}).flush(doc0); expect(latestDocument).toEqual(doc0); locationService.go('new/doc'); httpMock.expectOne({}).flush(doc1); expect(latestDocument).toEqual(doc1); }); it('should emit the not-found document if the document is not found on the server', () => { let currentDocument: DocumentContents | undefined; const notFoundDoc = { id: FILE_NOT_FOUND_ID, contents: '

Page Not Found

' }; const { docService, logger } = getServices('missing/doc'); docService.currentDocument.subscribe((doc) => (currentDocument = doc)); // Initial request return 404. httpMock.expectOne({}).flush(null, { status: 404, statusText: 'NOT FOUND' }); expect(logger.output.error).toEqual([[jasmine.any(Error)]]); expect(logger.output.error[0][0].message).toEqual("Document file not found at 'missing/doc'"); // Subsequent request for not-found document. logger.output.error = []; httpMock.expectOne(CONTENT_URL_PREFIX + 'file-not-found.json').flush(notFoundDoc); expect(logger.output.error).toEqual([]); // does not report repeated errors expect(currentDocument).toEqual(notFoundDoc); }); it('should emit a hard-coded not-found document if the not-found document is not found on the server', () => { let currentDocument: DocumentContents | undefined; const hardCodedNotFoundDoc = { contents: 'Document not found', id: FILE_NOT_FOUND_ID }; const nextDoc = { contents: 'Next Doc', id: 'new/doc' }; const { docService, locationService } = getServices(FILE_NOT_FOUND_ID); docService.currentDocument.subscribe((doc) => (currentDocument = doc)); httpMock.expectOne({}).flush(null, { status: 404, statusText: 'NOT FOUND' }); expect(currentDocument).toEqual(hardCodedNotFoundDoc); // now check that we haven't killed the currentDocument observable sequence locationService.go('new/doc'); httpMock.expectOne({}).flush(nextDoc); expect(currentDocument).toEqual(nextDoc); }); it('should use a hard-coded error doc if the request fails (but not cache it)', () => { let latestDocument!: DocumentContents; const doc1 = { contents: 'doc 1' } as DocumentContents; const doc2 = { contents: 'doc 2' } as DocumentContents; const { docService, locationService, logger } = getServices('initial/doc'); docService.currentDocument.subscribe((doc) => (latestDocument = doc)); httpMock.expectOne({}).flush(null, { status: 500, statusText: 'Server Error' }); expect(latestDocument.id).toEqual(FETCHING_ERROR_ID); expect(logger.output.error).toEqual([[jasmine.any(Error)]]); expect(logger.output.error[0][0].message).toEqual( "Error fetching document 'initial/doc': (Http failure response for generated/docs/initial/doc.json: 500 Server Error)" ); locationService.go('new/doc'); httpMock.expectOne({}).flush(doc1); expect(latestDocument).toEqual(jasmine.objectContaining(doc1)); locationService.go('initial/doc'); httpMock.expectOne({}).flush(doc2); expect(latestDocument).toEqual(jasmine.objectContaining(doc2)); }); it('should not crash the app if the response is invalid JSON', () => { let latestDocument!: DocumentContents; const doc1 = { contents: 'doc 1' } as DocumentContents; const { docService, locationService } = getServices('initial/doc'); docService.currentDocument.subscribe((doc) => (latestDocument = doc)); httpMock.expectOne({}).flush('this is invalid JSON'); expect(latestDocument.id).toEqual(FETCHING_ERROR_ID); locationService.go('new/doc'); httpMock.expectOne({}).flush(doc1); expect(latestDocument).toEqual(jasmine.objectContaining(doc1)); }); it('should not make a request to the server if the doc is in the cache already', () => { let latestDocument!: DocumentContents; let subscription: Subscription; const doc0 = { contents: 'doc 0' } as DocumentContents; const doc1 = { contents: 'doc 1' } as DocumentContents; const { docService, locationService } = getServices('url/0'); subscription = docService.currentDocument.subscribe((doc) => (latestDocument = doc)); httpMock.expectOne({}).flush(doc0); expect(latestDocument).toEqual(jasmine.objectContaining(doc0)); subscription.unsubscribe(); subscription = docService.currentDocument.subscribe((doc) => (latestDocument = doc)); locationService.go('url/1'); httpMock.expectOne({}).flush(doc1); expect(latestDocument).toEqual(jasmine.objectContaining(doc1)); subscription.unsubscribe(); // This should not trigger a new request. subscription = docService.currentDocument.subscribe((doc) => (latestDocument = doc)); locationService.go('url/0'); httpMock.expectNone({}); expect(latestDocument).toEqual(jasmine.objectContaining(doc0)); subscription.unsubscribe(); // This should not trigger a new request. subscription = docService.currentDocument.subscribe((doc) => (latestDocument = doc)); locationService.go('url/1'); httpMock.expectNone({}); expect(latestDocument).toEqual(jasmine.objectContaining(doc1)); subscription.unsubscribe(); }); }); describe('computeMap', () => { it('should map the "empty" location to the correct document request', () => { const { docService } = getServices(); docService.currentDocument.subscribe(); httpMock.expectOne(CONTENT_URL_PREFIX + 'index.json'); }); it('should map the "folder" locations to the correct document request', () => { const { docService } = getServices('guide'); docService.currentDocument.subscribe(); httpMock.expectOne(CONTENT_URL_PREFIX + 'guide.json'); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/documents/document.service.ts ================================================ import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { AsyncSubject, Observable, of } from 'rxjs'; import { catchError, switchMap, tap } from 'rxjs/operators'; import { DocumentContents } from './document-contents'; export { DocumentContents } from './document-contents'; import { LocationService } from 'app/shared/location.service'; import { Logger } from 'app/shared/logger.service'; export const FILE_NOT_FOUND_ID = 'file-not-found'; export const FETCHING_ERROR_ID = 'fetching-error'; export const CONTENT_URL_PREFIX = 'generated/'; export const DOC_CONTENT_URL_PREFIX = CONTENT_URL_PREFIX + 'docs/'; const FETCHING_ERROR_CONTENTS = (path: string) => `
error_outline

Request for document failed.

We are unable to retrieve the "${path}" page at this time. Please check your connection and try again later.

`; @Injectable() export class DocumentService { private cache = new Map>(); currentDocument: Observable; constructor( private logger: Logger, private http: HttpClient, location: LocationService) { // Whenever the URL changes we try to get the appropriate doc this.currentDocument = location.currentPath.pipe(switchMap(path => this.getDocument(path))); } private getDocument(url: string) { const id = url || 'index'; this.logger.log('getting document', id); if (!this.cache.has(id)) { this.cache.set(id, this.fetchDocument(id)); } return this.cache.get(id)!; } private fetchDocument(id: string): Observable { const requestPath = `${DOC_CONTENT_URL_PREFIX}${id}.json`; const subject = new AsyncSubject(); this.logger.log('fetching document from', requestPath); this.http .get(requestPath, {responseType: 'json'}) .pipe( tap(data => { if (!data || typeof data !== 'object') { this.logger.log('received invalid data:', data); throw Error('Invalid data'); } }), catchError((error: HttpErrorResponse) => error.status === 404 ? this.getFileNotFoundDoc(id) : this.getErrorDoc(id, error)), ) .subscribe(subject); return subject.asObservable(); } private getFileNotFoundDoc(id: string): Observable { if (id !== FILE_NOT_FOUND_ID) { this.logger.error(new Error(`Document file not found at '${id}'`)); // using `getDocument` means that we can fetch the 404 doc contents from the server and cache it return this.getDocument(FILE_NOT_FOUND_ID); } else { return of({ id: FILE_NOT_FOUND_ID, contents: 'Document not found' }); } } private getErrorDoc(id: string, error: HttpErrorResponse): Observable { this.logger.error(new Error(`Error fetching document '${id}': (${error.message})`)); this.cache.delete(id); return of({ id: FETCHING_ERROR_ID, contents: FETCHING_ERROR_CONTENTS(id), }); } } ================================================ FILE: apps/rxjs.dev/src/app/layout/doc-viewer/doc-viewer.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Meta, Title } from '@angular/platform-browser'; import { Observable, asapScheduler, of } from 'rxjs'; import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; import { Logger } from 'app/shared/logger.service'; import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; import { TocService } from 'app/shared/toc.service'; import { ElementsLoader } from 'app/custom-elements/elements-loader'; import { MockTitle, MockTocService, ObservableWithSubscriptionSpies, TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader } from 'testing/doc-viewer-utils'; import { MockLogger } from 'testing/logger.service'; import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component'; describe('DocViewerComponent', () => { let parentFixture: ComponentFixture; let parentComponent: TestParentComponent; let docViewerEl: HTMLElement; let docViewer: TestDocViewerComponent; let metaServiceMock: jasmine.SpyObj; const safeFlushAsapScheduler = () => asapScheduler.actions.length && asapScheduler.flush(); beforeEach(() => { metaServiceMock = jasmine.createSpyObj(['updateTag', 'addTag', 'removeTag']); TestBed.configureTestingModule({ imports: [CustomElementsModule, TestModule], providers: [{provide: Meta, useValue: metaServiceMock}] }); parentFixture = TestBed.createComponent(TestParentComponent); parentComponent = parentFixture.componentInstance; parentFixture.detectChanges(); docViewerEl = parentFixture.debugElement.children[0].nativeElement; docViewer = parentComponent.docViewer as any; }); it('should create a `DocViewer`', () => { expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); }); describe('#doc', () => { let renderSpy: jasmine.Spy; const setCurrentDoc = (newDoc: TestParentComponent['currentDoc']) => { parentComponent.currentDoc = newDoc; // set default with id if parameter is not defined parentFixture.detectChanges(); // Run change detection to propagate the new doc to `DocViewer`. safeFlushAsapScheduler(); // Flush `asapScheduler` to trigger `DocViewer#render()`. }; beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.callFake(() => of(undefined))); it('should render the new document', () => { setCurrentDoc({contents: 'foo', id: 'bar'}); expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]); setCurrentDoc({contents: null, id: 'baz'}); expect(renderSpy).toHaveBeenCalledTimes(2); expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); }); it('should unsubscribe from the previous "render" observable upon new document', () => { const obs = new ObservableWithSubscriptionSpies(); renderSpy.and.returnValue(obs); setCurrentDoc({contents: 'foo', id: 'bar'}); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); setCurrentDoc({contents: 'baz', id: 'qux'}); expect(obs.subscribeSpy).toHaveBeenCalledTimes(2); expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); }); it('should ignore falsy document values', () => { setCurrentDoc(null); expect(renderSpy).not.toHaveBeenCalled(); setCurrentDoc(undefined); expect(renderSpy).not.toHaveBeenCalled(); }); }); describe('#ngOnDestroy()', () => { it('should stop responding to document changes', () => { const renderSpy = spyOn(docViewer, 'render').and.callFake(() => of(undefined)); expect(renderSpy).not.toHaveBeenCalled(); docViewer.doc = {contents: 'Some content', id: 'some-id'}; safeFlushAsapScheduler(); expect(renderSpy).toHaveBeenCalledTimes(1); docViewer.ngOnDestroy(); docViewer.doc = {contents: 'Other content', id: 'other-id'}; safeFlushAsapScheduler(); expect(renderSpy).toHaveBeenCalledTimes(1); docViewer.doc = {contents: 'More content', id: 'more-id'}; safeFlushAsapScheduler(); expect(renderSpy).toHaveBeenCalledTimes(1); }); }); describe('#prepareTitleAndToc()', () => { const EMPTY_DOC = ''; const DOC_WITHOUT_H1 = 'Some content'; const DOC_WITH_H1 = '

Features

Some content'; const DOC_WITH_NO_TOC_H1 = '

Features

Some content'; const DOC_WITH_EMBEDDED_TOC = '

Features

Some content'; const DOC_WITH_EMBEDDED_TOC_WITHOUT_H1 = 'Some content'; const DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1 = 'Some content'; const DOC_WITH_HIDDEN_H1_CONTENT = '

linkFeatures

Some content'; let titleService: MockTitle; let tocService: MockTocService; let targetEl: HTMLElement; const getTocEl = () => targetEl.querySelector('aio-toc'); const doPrepareTitleAndToc = (contents: string, docId = '') => { targetEl.innerHTML = contents; return docViewer.prepareTitleAndToc(targetEl, docId); }; const doAddTitleAndToc = (contents: string, docId = '') => { const addTitleAndToc = doPrepareTitleAndToc(contents, docId); return addTitleAndToc(); }; beforeEach(() => { titleService = TestBed.inject(Title) as unknown as MockTitle; tocService = TestBed.inject(TocService) as unknown as MockTocService; targetEl = document.createElement('div'); document.body.appendChild(targetEl); // Required for `innerText` to work as expected. }); afterEach(() => document.body.removeChild(targetEl)); it('should return a function for doing the actual work', () => { const addTitleAndToc = doPrepareTitleAndToc(DOC_WITH_H1); expect(getTocEl()).toBeTruthy(); expect(titleService.setTitle).not.toHaveBeenCalled(); expect(tocService.reset).not.toHaveBeenCalled(); expect(tocService.genToc).not.toHaveBeenCalled(); addTitleAndToc(); expect(titleService.setTitle).toHaveBeenCalledTimes(1); expect(tocService.reset).toHaveBeenCalledTimes(1); expect(tocService.genToc).toHaveBeenCalledTimes(1); }); describe('(title)', () => { it('should set the title if there is an `

` heading', () => { doAddTitleAndToc(DOC_WITH_H1); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS - Features'); }); it('should set the title if there is a `.no-toc` `

` heading', () => { doAddTitleAndToc(DOC_WITH_NO_TOC_H1); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS - Features'); }); it('should set the default title if there is no `

` heading', () => { doAddTitleAndToc(DOC_WITHOUT_H1); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS'); doAddTitleAndToc(EMPTY_DOC); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS'); }); it('should not include hidden content of the `

` heading in the title', () => { doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS - Features'); }); it('should fall back to `textContent` if `innerText` is not available', () => { const querySelector = targetEl.querySelector; spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { const elem = querySelector.call(targetEl, selector); return elem && Object.defineProperties(elem, { innerText: {value: undefined}, textContent: {value: 'Text Content'}, }); }); doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS - Text Content'); }); it('should still use `innerText` if available but empty', () => { const querySelector = targetEl.querySelector; spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { const elem = querySelector.call(targetEl, selector); return elem && Object.defineProperties(elem, { innerText: { value: '' }, textContent: { value: 'Text Content' } }); }); doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); expect(titleService.setTitle).toHaveBeenCalledWith('RxJS'); }); }); describe('(ToC)', () => { describe('needed', () => { it('should add an embedded ToC element if there is an `

` heading', () => { doPrepareTitleAndToc(DOC_WITH_H1); const tocEl = getTocEl()!; expect(tocEl).toBeTruthy(); expect(tocEl.classList.contains('embedded')).toBe(true); }); it('should not add a second ToC element if there a hard coded one in place', () => { doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC); expect(targetEl.querySelectorAll('aio-toc').length).toEqual(1); }); }); describe('not needed', () => { it('should not add a ToC element if there is a `.no-toc` `

` heading', () => { doPrepareTitleAndToc(DOC_WITH_NO_TOC_H1); expect(getTocEl()).toBeFalsy(); }); it('should not add a ToC element if there is no `

` heading', () => { doPrepareTitleAndToc(DOC_WITHOUT_H1); expect(getTocEl()).toBeFalsy(); doPrepareTitleAndToc(EMPTY_DOC); expect(getTocEl()).toBeFalsy(); }); it('should remove ToC a hard coded one', () => { doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITHOUT_H1); expect(getTocEl()).toBeFalsy(); doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1); expect(getTocEl()).toBeFalsy(); }); }); it('should generate ToC entries if there is an `

` heading', () => { doAddTitleAndToc(DOC_WITH_H1, 'foo'); expect(tocService.genToc).toHaveBeenCalledTimes(1); expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); }); it('should not generate ToC entries if there is a `.no-toc` `

` heading', () => { doAddTitleAndToc(DOC_WITH_NO_TOC_H1); expect(tocService.genToc).not.toHaveBeenCalled(); }); it('should not generate ToC entries if there is no `

` heading', () => { doAddTitleAndToc(DOC_WITHOUT_H1); doAddTitleAndToc(EMPTY_DOC); expect(tocService.genToc).not.toHaveBeenCalled(); }); it('should always reset the ToC (before generating the new one)', () => { doAddTitleAndToc(DOC_WITH_H1, 'foo'); expect(tocService.reset).toHaveBeenCalledTimes(1); expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); tocService.genToc.calls.reset(); doAddTitleAndToc(DOC_WITH_NO_TOC_H1, 'bar'); expect(tocService.reset).toHaveBeenCalledTimes(2); expect(tocService.genToc).not.toHaveBeenCalled(); doAddTitleAndToc(DOC_WITHOUT_H1, 'baz'); expect(tocService.reset).toHaveBeenCalledTimes(3); expect(tocService.genToc).not.toHaveBeenCalled(); doAddTitleAndToc(EMPTY_DOC, 'qux'); expect(tocService.reset).toHaveBeenCalledTimes(4); expect(tocService.genToc).not.toHaveBeenCalled(); }); }); }); describe('#render()', () => { let prepareTitleAndTocSpy: jasmine.Spy; let swapViewsSpy: jasmine.Spy; let loadElementsSpy: jasmine.Spy; const doRender = (contents: string | null, id = 'foo') => docViewer.render({contents, id}).toPromise(); beforeEach(() => { const elementsLoader = TestBed.inject(ElementsLoader) as Partial as MockElementsLoader; loadElementsSpy = elementsLoader.loadContainedCustomElements.and.callFake(() => of(undefined)); prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); swapViewsSpy = spyOn(docViewer, 'swapViews').and.callFake(() => of(undefined)); }); it('should return an `Observable`', () => { expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable)); }); describe('(contents, title, ToC)', () => { beforeEach(() => swapViewsSpy.and.callThrough()); it('should display the document contents', async () => { const contents = '

Hello,

world!
'; await doRender(contents); expect(docViewerEl.innerHTML).toContain(contents); expect(docViewerEl.textContent).toBe('Hello, world!'); }); it('should display nothing if the document has no contents', async () => { await doRender('Test'); expect(docViewerEl.textContent).toBe('Test'); await doRender(''); expect(docViewerEl.textContent).toBe(''); docViewer.currViewContainer.innerHTML = 'Test'; expect(docViewerEl.textContent).toBe('Test'); await doRender(null); expect(docViewerEl.textContent).toBe(''); }); it('should prepare the title and ToC (before embedding components)', async () => { prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { expect(targetEl.innerHTML).toBe('Some content'); expect(docId).toBe('foo'); }); await doRender('Some content', 'foo'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy); }); it('should set the title and ToC (after the content has been set)', async () => { const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content')); await doRender('Foo content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content')); await doRender('Bar content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('')); await doRender(''); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content')); await doRender('Qux content'); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); }); it('should remove the "noindex" meta tag if the document is valid', async () => { await doRender('foo', 'bar'); expect(TestBed.inject(Meta).removeTag).toHaveBeenCalledWith('name="robots"'); }); it('should add the "noindex" meta tag if the document is 404', async () => { await doRender('missing', FILE_NOT_FOUND_ID); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); it('should add a "noindex" meta tag if the document fetching fails', async () => { await doRender('error', FETCHING_ERROR_ID); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); }); describe('(embedding components)', () => { it('should embed components', async () => { await doRender('Some content'); expect(loadElementsSpy).toHaveBeenCalledTimes(1); expect(loadElementsSpy).toHaveBeenCalledWith(docViewer.nextViewContainer); }); it('should attempt to embed components even if the document is empty', async () => { await doRender(''); await doRender(null); expect(loadElementsSpy).toHaveBeenCalledTimes(2); expect(loadElementsSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]); expect(loadElementsSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]); }); it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => { const obs = new ObservableWithSubscriptionSpies(); loadElementsSpy.and.returnValue(obs); const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'}); const subscription = renderObservable.subscribe(); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); subscription.unsubscribe(); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); }); }); describe('(swapping views)', () => { it('should still swap the views if the document is empty', async () => { await doRender(''); expect(swapViewsSpy).toHaveBeenCalledTimes(1); await doRender(null); expect(swapViewsSpy).toHaveBeenCalledTimes(2); }); it('should pass the `addTitleAndToc` callback', async () => { const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); await doRender('
'); expect(swapViewsSpy).toHaveBeenCalledWith(addTitleAndTocSpy); }); it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => { const obs = new ObservableWithSubscriptionSpies(); swapViewsSpy.and.returnValue(obs); const renderObservable = docViewer.render({contents: 'Hello, world!', id: 'foo'}); const subscription = renderObservable.subscribe(); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); subscription.unsubscribe(); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); }); }); describe('(on error) should clean up, log the error and recover', () => { let logger: MockLogger; beforeEach(() => { logger = TestBed.inject(Logger) as unknown as MockLogger; }); it('when `prepareTitleAndTocSpy()` fails', async () => { const error = Error('Typical `prepareTitleAndToc()` error'); prepareTitleAndTocSpy.and.callFake(() => { expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); throw error; }); await doRender('Some content', 'foo'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).not.toHaveBeenCalled(); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'foo': ${error.stack}`); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); it('when `EmbedComponentsService.embedInto()` fails', async () => { const error = Error('Typical `embedInto()` error'); loadElementsSpy.and.callFake(() => { expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); throw error; }); await doRender('Some content', 'bar'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(loadElementsSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).not.toHaveBeenCalled(); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); it('when `swapViews()` fails', async () => { const error = Error('Typical `swapViews()` error'); swapViewsSpy.and.callFake(() => { expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); throw error; }); await doRender('Some content', 'qux'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error.stack}`); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); it('when something fails with non-Error', async () => { const error = 'Typical string error'; swapViewsSpy.and.callFake(() => { expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); throw error; }); await doRender('Some content', 'qux'); expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ [jasmine.any(Error)] ]); expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error}`); expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); }); describe('(events)', () => { it('should emit `docReady` after loading elements', async () => { const onDocReadySpy = jasmine.createSpy('onDocReady'); docViewer.docReady.subscribe(onDocReadySpy); await doRender('Some content'); expect(onDocReadySpy).toHaveBeenCalledTimes(1); expect(loadElementsSpy).toHaveBeenCalledBefore(onDocReadySpy); }); it('should emit `docReady` before swapping views', async () => { const onDocReadySpy = jasmine.createSpy('onDocReady'); docViewer.docReady.subscribe(onDocReadySpy); await doRender('Some content'); expect(onDocReadySpy).toHaveBeenCalledTimes(1); expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy); }); it('should emit `docRendered` after swapping views', async () => { const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); docViewer.docRendered.subscribe(onDocRenderedSpy); await doRender('Some content'); expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledBefore(onDocRenderedSpy); }); }); }); describe('#swapViews()', () => { let oldCurrViewContainer: HTMLElement; let oldNextViewContainer: HTMLElement; const doSwapViews = (cb?: () => void) => docViewer.swapViews(cb).toPromise(); beforeEach(() => { oldCurrViewContainer = docViewer.currViewContainer; oldNextViewContainer = docViewer.nextViewContainer; oldCurrViewContainer.innerHTML = 'Current view'; oldNextViewContainer.innerHTML = 'Next view'; docViewerEl.appendChild(oldCurrViewContainer); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); }); [true, false].forEach(animationsEnabled => { describe(`(animationsEnabled: ${animationsEnabled})`, () => { beforeEach(() => DocViewerComponent.animationsEnabled = animationsEnabled); afterEach(() => DocViewerComponent.animationsEnabled = true); [true, false].forEach(noAnimations => { describe(`(.${NO_ANIMATIONS}: ${noAnimations})`, () => { beforeEach(() => docViewerEl.classList[noAnimations ? 'add' : 'remove'](NO_ANIMATIONS)); it('should return an observable', done => { docViewer.swapViews().subscribe(done, done.fail); }); it('should swap the views', async () => { await doSwapViews(); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); expect(docViewer.currViewContainer).toBe(oldNextViewContainer); expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); await doSwapViews(); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); expect(docViewer.currViewContainer).toBe(oldCurrViewContainer); expect(docViewer.nextViewContainer).toBe(oldNextViewContainer); }); it('should emit `docRemoved` after removing the leaving view', async () => { const onDocRemovedSpy = jasmine.createSpy('onDocRemoved').and.callFake(() => { expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); }); docViewer.docRemoved.subscribe(onDocRemovedSpy); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); await doSwapViews(); expect(onDocRemovedSpy).toHaveBeenCalledTimes(1); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); }); it('should not emit `docRemoved` if the leaving view is already removed', async () => { const onDocRemovedSpy = jasmine.createSpy('onDocRemoved'); docViewer.docRemoved.subscribe(onDocRemovedSpy); docViewerEl.removeChild(oldCurrViewContainer); await doSwapViews(); expect(onDocRemovedSpy).not.toHaveBeenCalled(); }); it('should emit `docInserted` after inserting the entering view', async () => { const onDocInsertedSpy = jasmine.createSpy('onDocInserted').and.callFake(() => { expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); }); docViewer.docInserted.subscribe(onDocInsertedSpy); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); await doSwapViews(); expect(onDocInsertedSpy).toHaveBeenCalledTimes(1); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); }); it('should call the callback after inserting the entering view', async () => { const onInsertedCb = jasmine.createSpy('onInsertedCb').and.callFake(() => { expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); }); const onDocInsertedSpy = jasmine.createSpy('onDocInserted'); docViewer.docInserted.subscribe(onDocInsertedSpy); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); await doSwapViews(onInsertedCb); expect(onInsertedCb).toHaveBeenCalledTimes(1); expect(onInsertedCb).toHaveBeenCalledBefore(onDocInsertedSpy); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); }); it('should empty the previous view', async () => { await doSwapViews(); expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); expect(docViewer.nextViewContainer.innerHTML).toBe(''); docViewer.nextViewContainer.innerHTML = 'Next view 2'; await doSwapViews(); expect(docViewer.currViewContainer.innerHTML).toBe('Next view 2'); expect(docViewer.nextViewContainer.innerHTML).toBe(''); }); if (animationsEnabled && !noAnimations) { // Only test this when there are animations. Without animations, the views are swapped // synchronously, so there is no need (or way) to abort. it('should abort swapping if the returned observable is unsubscribed from', async () => { docViewer.swapViews().subscribe().unsubscribe(); await doSwapViews(); // Since the first call was cancelled, only one swapping should have taken place. expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); expect(docViewer.currViewContainer).toBe(oldNextViewContainer); expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); expect(docViewer.nextViewContainer.innerHTML).toBe(''); }); } else { it('should swap views synchronously when animations are disabled', () => { const cbSpy = jasmine.createSpy('cb'); docViewer.swapViews(cbSpy).subscribe(); expect(cbSpy).toHaveBeenCalledTimes(1); expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); expect(docViewer.currViewContainer).toBe(oldNextViewContainer); expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); expect(docViewer.nextViewContainer.innerHTML).toBe(''); }); } }); }); }); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/layout/doc-viewer/doc-viewer.component.ts ================================================ import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; import { Title, Meta } from '@angular/platform-browser'; import { asapScheduler, Observable, of, timer } from 'rxjs'; import { catchError, observeOn, switchMap, takeUntil, tap } from 'rxjs/operators'; import { DocumentContents, FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; import { Logger } from 'app/shared/logger.service'; import { TocService } from 'app/shared/toc.service'; import { ElementsLoader } from 'app/custom-elements/elements-loader'; // Constants export const NO_ANIMATIONS = 'no-animations'; // Initialization prevents flicker once pre-rendering is on const initialDocViewerElement = document.querySelector('aio-doc-viewer'); const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElement.innerHTML : ''; @Component({ selector: 'aio-doc-viewer', template: '', // TODO(robwormald): shadow DOM and emulated don't work here (?!) // encapsulation: ViewEncapsulation.Native }) export class DocViewerComponent implements OnDestroy { // Enable/Disable view transition animations. static animationsEnabled = true; private hostElement: HTMLElement; private void$ = of(undefined); private onDestroy$ = new EventEmitter(); private docContents$ = new EventEmitter(); protected currViewContainer: HTMLElement = document.createElement('div'); protected nextViewContainer: HTMLElement = document.createElement('div'); @Input() set doc(newDoc: DocumentContents) { // Ignore `undefined` values that could happen if the host component // does not initially specify a value for the `doc` input. if (newDoc) { this.docContents$.emit(newDoc); } } // The new document is ready to be inserted into the viewer. // (Embedded components have been loaded and instantiated, if necessary.) @Output() docReady = new EventEmitter(); // The previous document has been removed from the viewer. // (The leaving animation (if any) has been completed and the node has been removed from the DOM.) @Output() docRemoved = new EventEmitter(); // The new document has been inserted into the viewer. // (The node has been inserted into the DOM, but the entering animation may still be in progress.) @Output() docInserted = new EventEmitter(); // The new document has been fully rendered into the viewer. // (The entering animation has been completed.) @Output() docRendered = new EventEmitter(); constructor( elementRef: ElementRef, private logger: Logger, private titleService: Title, private metaService: Meta, private tocService: TocService, private elementsLoader: ElementsLoader ) { this.hostElement = elementRef.nativeElement; // Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure this.hostElement.innerHTML = initialDocViewerContent; if (this.hostElement.firstElementChild) { this.currViewContainer = this.hostElement.firstElementChild as HTMLElement; } this.docContents$ .pipe( observeOn(asapScheduler), switchMap((newDoc) => this.render(newDoc)), takeUntil(this.onDestroy$) ) .subscribe(); } ngOnDestroy() { this.onDestroy$.emit(); } /** * Prepare for setting the window title and ToC. * Return a function to actually set them. */ protected prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { const descriptionEl = targetElem.querySelector('.api-body > p:nth-child(2)'); const titleEl = targetElem.querySelector('h1'); const needsToc = !!titleEl && !/no-?toc/i.test(titleEl.className); const embeddedToc = targetElem.querySelector('aio-toc.embedded'); if (needsToc && !embeddedToc) { // Add an embedded ToC if it's needed and there isn't one in the content already. titleEl!.insertAdjacentHTML('afterend', ''); } else if (!needsToc && embeddedToc) { // Remove the embedded Toc if it's there and not needed. embeddedToc.remove(); } return () => { this.tocService.reset(); let title: string | null = ''; let description: string | null = ''; // Only create ToC for docs with an `

` heading. // If you don't want a ToC, add "no-toc" class to `

`. if (titleEl) { title = typeof titleEl.innerText === 'string' ? titleEl.innerText : titleEl.textContent; if (needsToc) { this.tocService.genToc(targetElem, docId); } } if (descriptionEl) { description = descriptionEl.innerHTML; } const formattedTitle = title ? `RxJS - ${title}` : 'RxJS'; this.addDocumentMetaTags(formattedTitle, description); this.titleService.setTitle(formattedTitle); }; } /** * Add doc content to host element and build it out with embedded components. */ protected render(doc: DocumentContents): Observable { let addTitleAndToc: () => void; this.setNoIndex(doc.id === FILE_NOT_FOUND_ID || doc.id === FETCHING_ERROR_ID); return this.void$.pipe( // Security: `doc.contents` is always authored by the documentation team // and is considered to be safe. tap(() => (this.nextViewContainer.innerHTML = doc.contents || '')), tap(() => (addTitleAndToc = this.prepareTitleAndToc(this.nextViewContainer, doc.id))), switchMap(() => this.elementsLoader.loadContainedCustomElements(this.nextViewContainer)), tap(() => this.docReady.emit()), switchMap(() => this.swapViews(addTitleAndToc)), tap(() => this.docRendered.emit()), catchError((err) => { const errorMessage = err instanceof Error ? err.stack : err; this.logger.error(new Error(`[DocViewer] Error preparing document '${doc.id}': ${errorMessage}`)); this.nextViewContainer.innerHTML = ''; this.setNoIndex(true); return this.void$; }) ); } /** * Tell search engine crawlers whether to index this page */ private setNoIndex(val: boolean) { if (val) { this.metaService.addTag({ name: 'robots', content: 'noindex' }); } else { this.metaService.removeTag('name="robots"'); } } /** * Swap the views, removing `currViewContainer` and inserting `nextViewContainer`. * (At this point all content should be ready, including having loaded and instantiated embedded * components.) * * Optionally, run a callback as soon as `nextViewContainer` has been inserted, but before the * entering animation has been completed. This is useful for work that needs to be done as soon as * the element has been attached to the DOM. */ protected swapViews(onInsertedCb = () => {}): Observable { const raf$ = new Observable((subscriber) => { const rafId = requestAnimationFrame(() => { subscriber.next(); subscriber.complete(); }); return () => cancelAnimationFrame(rafId); }); // Get the actual transition duration (taking global styles into account). // According to the [CSSOM spec](https://drafts.csswg.org/cssom/#serializing-css-values), // `time` values should be returned in seconds. const getActualDuration = (elem: HTMLElement) => { const cssValue = getComputedStyle(elem).transitionDuration || ''; const seconds = Number(cssValue.replace(/s$/, '')); return 1000 * seconds; }; const animateProp = ( elem: HTMLElement, prop: T, from: CSSStyleDeclaration[T], to: CSSStyleDeclaration[T], duration = 200 ) => { const animationsDisabled = !DocViewerComponent.animationsEnabled || this.hostElement.classList.contains(NO_ANIMATIONS); if (prop === 'length' || prop === 'parentRule') { // We cannot animate length or parentRule properties because they are readonly return this.void$; } elem.style.transition = ''; return animationsDisabled ? this.void$.pipe(tap(() => (elem.style[prop] = to))) : this.void$.pipe( // In order to ensure that the `from` value will be applied immediately (i.e. // without transition) and that the `to` value will be affected by the // `transition` style, we need to ensure an animation frame has passed between // setting each style. switchMap(() => raf$), tap(() => (elem.style[prop] = from)), switchMap(() => raf$), tap(() => (elem.style.transition = `all ${duration}ms ease-in-out`)), switchMap(() => raf$), tap(() => (elem.style[prop] = to)), switchMap(() => timer(getActualDuration(elem))), switchMap(() => this.void$) ); }; const animateLeave = (elem: HTMLElement) => animateProp(elem, 'opacity', '1', '0.1'); const animateEnter = (elem: HTMLElement) => animateProp(elem, 'opacity', '0.1', '1'); let done$ = this.void$; if (this.currViewContainer.parentElement) { done$ = done$.pipe( // Remove the current view from the viewer. switchMap(() => animateLeave(this.currViewContainer)), tap(() => this.currViewContainer.parentElement?.removeChild(this.currViewContainer)), tap(() => this.docRemoved.emit()) ); } return done$.pipe( // Insert the next view into the viewer. tap(() => this.hostElement.appendChild(this.nextViewContainer)), tap(() => onInsertedCb()), tap(() => this.docInserted.emit()), switchMap(() => animateEnter(this.nextViewContainer)), // Update the view references and clean up unused nodes. tap(() => { const prevViewContainer = this.currViewContainer; this.currViewContainer = this.nextViewContainer; this.nextViewContainer = prevViewContainer; this.nextViewContainer.innerHTML = ''; // Empty to release memory. }) ); } private addDocumentMetaTags(title: string, description: string | null): void { this.metaService.updateTag({ name: 'twitter:title', content: title }); this.metaService.updateTag({ name: 'twitter:card', content: 'summary' }); this.metaService.updateTag({ property: 'og:title', content: title }); this.metaService.updateTag({ property: 'og:type', content: 'article' }); if (description) { const formattedDescription = description.replace(/<\/?\w*>/gm, ''); this.metaService.updateTag({ name: 'twitter:description', content: formattedDescription }); this.metaService.updateTag({ property: 'og:description', content: formattedDescription }); } } } ================================================ FILE: apps/rxjs.dev/src/app/layout/doc-viewer/dt.component.ts ================================================ import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { DocumentContents } from 'app/documents/document.service'; @Component({ selector: 'aio-dt', template: `


` }) export class DtComponent { @Input() on = false; @Input() doc: DocumentContents; @Output() docChange = new EventEmitter(); @ViewChild('dt', { read: ElementRef }) dt: ElementRef; get text() { return this.doc && this.doc.contents; } dtextSet() { this.doc.contents = this.dt.nativeElement.value; this.docChange.emit({ ...this.doc }); } } ================================================ FILE: apps/rxjs.dev/src/app/layout/footer/footer.component.ts ================================================ import { Component, Input } from '@angular/core'; import { NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; @Component({ selector: 'aio-footer', template: `

Code licensed under an Apache-2.0 License. Documentation licensed under CC BY 4.0.

Version {{ versionInfo?.full }}.

`, }) export class FooterComponent { @Input() nodes: NavigationNode[]; @Input() versionInfo: VersionInfo; } ================================================ FILE: apps/rxjs.dev/src/app/layout/mode-banner/mode-banner.component.ts ================================================ import { Component, Input } from '@angular/core'; import { VersionInfo } from 'app/navigation/navigation.service'; @Component({ selector: 'aio-mode-banner', template: `
This is the archived documentation for Angular v{{version?.major}}. Please visit angular.io to see documentation for the current version of Angular.
` }) export class ModeBannerComponent { @Input() mode: string; @Input() version: VersionInfo; } ================================================ FILE: apps/rxjs.dev/src/app/layout/nav-item/nav-item.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NavItemComponent } from './nav-item.component'; import { NavigationNode } from 'app/navigation/navigation.model'; describe('NavItemComponent', () => { // Testing the component class behaviors, independent of its template // No dependencies. Just new it and test :) // Let e2e tests verify how it displays. describe('(class-only)', () => { let component: NavItemComponent; let selectedNodes: NavigationNode[]; let setClassesSpy: jasmine.Spy; function initialize(nd: NavigationNode) { component.node = nd; onChanges(); // Angular calls when initializing the component } // Enough to triggers component's ngOnChange method function onChanges() { component.ngOnChanges(); } beforeEach(() => { component = new NavItemComponent(); setClassesSpy = spyOn(component, 'setClasses').and.callThrough(); // Selected nodes is the selected node and its header ancestors selectedNodes = [ { title: 'a' }, // selected node: an item or a header { title: 'parent' }, // selected node's header parent { title: 'grandparent' }, // selected node's header grandparent ]; component.selectedNodes = selectedNodes; }); describe('should have expected classes when initialized', () => { it('with selected node', () => { initialize(selectedNodes[0]); expect(component.classes).toEqual( // selected node should be expanded even if is a header. { 'level-1': true, collapsed: false, expanded: true, selected: true } ); }); it('with selected node ancestor', () => { initialize(selectedNodes[1]); expect(component.classes).toEqual( // ancestor is a header and should be expanded { 'level-1': true, collapsed: false, expanded: true, selected: true } ); }); it('with other than a selected node or ancestor', () => { initialize({ title: 'x' }); expect(component.classes).toEqual( { 'level-1': true, collapsed: true, expanded: false, selected: false } ); }); }); describe('when becomes a non-selected node', () => { // this node won't be the selected node when ngOnChanges() called beforeEach(() => component.node = { title: 'x' }); it('should de-select if previously selected', () => { component.isSelected = true; onChanges(); expect(component.isSelected).toBe(false, 'becomes de-selected'); }); it('should collapse if previously expanded in narrow mode', () => { component.isWide = false; component.isExpanded = true; onChanges(); expect(component.isExpanded).toBe(false, 'becomes collapsed'); }); it('should remain expanded in wide mode', () => { component.isWide = true; component.isExpanded = true; onChanges(); expect(component.isExpanded).toBe(true, 'remains expanded'); }); }); describe('when becomes a selected node', () => { // this node will be the selected node when ngOnChanges() called beforeEach(() => component.node = selectedNodes[0]); it('should select when previously not selected', () => { component.isSelected = false; onChanges(); expect(component.isSelected).toBe(true, 'becomes selected'); }); it('should expand the current node or keep it expanded', () => { component.isExpanded = false; onChanges(); expect(component.isExpanded).toBe(true, 'becomes true'); component.isExpanded = true; onChanges(); expect(component.isExpanded).toBe(true, 'remains true'); }); }); describe('when becomes a selected ancestor node', () => { // this node will be a selected node ancestor header when ngOnChanges() called beforeEach(() => component.node = selectedNodes[2]); it('should select when previously not selected', () => { component.isSelected = false; onChanges(); expect(component.isSelected).toBe(true, 'becomes selected'); }); it('should always expand this header', () => { component.isExpanded = false; onChanges(); expect(component.isExpanded).toBe(true, 'becomes expanded'); component.isExpanded = false; onChanges(); expect(component.isExpanded).toBe(true, 'stays expanded'); }); }); describe('when headerClicked()', () => { // current node doesn't matter in these tests. it('should expand when headerClicked() and previously collapsed', () => { component.isExpanded = false; component.headerClicked(); expect(component.isExpanded).toBe(true, 'should be expanded'); }); it('should collapse when headerClicked() and previously expanded', () => { component.isExpanded = true; component.headerClicked(); expect(component.isExpanded).toBe(false, 'should be collapsed'); }); it('should not change isSelected when headerClicked()', () => { component.isSelected = true; component.headerClicked(); expect(component.isSelected).toBe(true, 'remains selected'); component.isSelected = false; component.headerClicked(); expect(component.isSelected).toBe(false, 'remains not selected'); }); it('should set classes', () => { component.headerClicked(); expect(setClassesSpy).toHaveBeenCalled(); }); }); }); describe('(via TestBed)', () => { let component: NavItemComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ declarations: [NavItemComponent], schemas: [NO_ERRORS_SCHEMA] }); fixture = TestBed.createComponent(NavItemComponent); component = fixture.componentInstance; component.node = { title: 'x', children: [ { title: 'a' }, { title: 'b', hidden: true}, { title: 'c' } ] }; }); it('should not show the hidden child nav-item', () => { component.ngOnChanges(); // assume component.ngOnChanges ignores arguments fixture.detectChanges(); const children = fixture.debugElement.queryAll(By.directive(NavItemComponent)); expect(children.length).toEqual(2); }); it('should pass the `isWide` property to all displayed child nav-items', () => { component.isWide = true; component.ngOnChanges(); // assume component.ngOnChanges ignores arguments fixture.detectChanges(); let children = fixture.debugElement.queryAll(By.directive(NavItemComponent)); expect(children.length).toEqual(2, 'when IsWide is true'); children.forEach(child => expect(child.componentInstance.isWide).toBe(true)); component.isWide = false; component.ngOnChanges(); fixture.detectChanges(); children = fixture.debugElement.queryAll(By.directive(NavItemComponent)); expect(children.length).toEqual(2, 'when IsWide is false'); children.forEach(child => expect(child.componentInstance.isWide).toBe(false)); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/layout/nav-item/nav-item.component.ts ================================================ import { Component, Input, OnChanges } from '@angular/core'; import { NavigationNode } from 'app/navigation/navigation.model'; @Component({ selector: 'aio-nav-item', template: `
{{ node.title }}
{{ node.title }}
`, }) export class NavItemComponent implements OnChanges { @Input() isWide = false; @Input() level = 1; @Input() node: NavigationNode; @Input() isParentExpanded = true; @Input() selectedNodes: NavigationNode[] | undefined; isExpanded = false; isSelected = false; classes: { [index: string]: boolean }; nodeChildren: NavigationNode[]; ngOnChanges() { this.nodeChildren = this.node && this.node.children ? this.node.children.filter((n) => !n.hidden) : []; if (this.selectedNodes) { const ix = this.selectedNodes.indexOf(this.node); this.isSelected = ix !== -1; // this node is the selected node or its ancestor this.isExpanded = this.isParentExpanded && (this.isSelected || // expand if selected or ... // preserve expanded state when display is wide; collapse in mobile. (this.isWide && this.isExpanded)); } else { this.isSelected = false; } this.setClasses(); } setClasses() { this.classes = { ['level-' + this.level]: true, collapsed: !this.isExpanded, expanded: this.isExpanded, selected: this.isSelected, }; } headerClicked() { this.isExpanded = !this.isExpanded; this.setClasses(); } } ================================================ FILE: apps/rxjs.dev/src/app/layout/nav-menu/nav-menu.component.spec.ts ================================================ import { NavMenuComponent } from './nav-menu.component'; import { NavigationNode } from 'app/navigation/navigation.service'; // Testing the component class behaviors, independent of its template // No dependencies, no life-cycle hooks. Just new it and test :) // Let e2e tests verify how it displays. describe('NavMenuComponent (class-only)', () => { it('should filter out hidden nodes', () => { const component = new NavMenuComponent(); const nodes: NavigationNode[] = [ { title: 'a' }, { title: 'b', hidden: true}, { title: 'c'} ]; component.nodes = nodes; expect(component.filteredNodes).toEqual([ nodes[0], nodes[2] ]); }); }); ================================================ FILE: apps/rxjs.dev/src/app/layout/nav-menu/nav-menu.component.ts ================================================ import { Component, Input } from '@angular/core'; import { CurrentNode, NavigationNode } from 'app/navigation/navigation.service'; @Component({ selector: 'aio-nav-menu', template: ` `, }) export class NavMenuComponent { @Input() currentNode: CurrentNode | undefined; @Input() isWide = false; @Input() nodes: NavigationNode[]; get filteredNodes() { return this.nodes ? this.nodes.filter((n) => !n.hidden) : []; } } ================================================ FILE: apps/rxjs.dev/src/app/layout/notification/notification.component.ts ================================================ import { animate, state, style, trigger, transition } from '@angular/animations'; import { Component, EventEmitter, HostBinding, Inject, Input, OnInit, Output } from '@angular/core'; import { CurrentDateToken } from 'app/shared/current-date'; import { WindowToken } from 'app/shared/window'; const LOCAL_STORAGE_NAMESPACE = 'aio-notification/'; @Component({ selector: 'aio-notification', template: ` `, animations: [ trigger('hideAnimation', [ state('show', style({ height: '*' })), state('hide', style({ height: 0 })), // this should be kept in sync with the animation durations in: // - aio/src/styles/2-modules/_notification.scss // - aio/src/app/app.component.ts : notificationDismissed() transition('show => hide', animate(250)), ]), ], }) export class NotificationComponent implements OnInit { private storage: Storage; @Input() dismissOnContentClick: boolean; @Input() notificationId: string; @Input() expirationDate: string; @Output() dismissed = new EventEmitter(); @HostBinding('@hideAnimation') showNotification: 'show' | 'hide'; constructor(@Inject(WindowToken) window: Window, @Inject(CurrentDateToken) private currentDate: Date) { try { this.storage = window.localStorage; } catch { // When cookies are disabled in the browser, even trying to access // `window.localStorage` throws an error. Use a no-op storage. this.storage = { length: 0, clear: () => undefined, getItem: () => null, key: () => null, removeItem: () => undefined, setItem: () => undefined, }; } } ngOnInit() { const previouslyHidden = this.storage.getItem(LOCAL_STORAGE_NAMESPACE + this.notificationId) === 'hide'; const expired = this.currentDate > new Date(this.expirationDate); this.showNotification = previouslyHidden || expired ? 'hide' : 'show'; } contentClick() { if (this.dismissOnContentClick) { this.dismiss(); } } dismiss() { this.storage.setItem(LOCAL_STORAGE_NAMESPACE + this.notificationId, 'hide'); this.showNotification = 'hide'; this.dismissed.next(null); } } ================================================ FILE: apps/rxjs.dev/src/app/layout/top-menu/top-menu.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BehaviorSubject } from 'rxjs'; import { TopMenuComponent } from './top-menu.component'; import { NavigationService, NavigationViews } from 'app/navigation/navigation.service'; describe('TopMenuComponent', () => { let component: TopMenuComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ declarations: [ TopMenuComponent ], providers: [ { provide: NavigationService, useClass: TestNavigationService } ] }); }); beforeEach(() => { fixture = TestBed.createComponent(TopMenuComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); //// Test Helpers //// class TestNavigationService { navJson = { TopBar: [ {url: 'api', title: 'API' }, {url: 'features', title: 'Features' } ], }; navigationViews = new BehaviorSubject(this.navJson); } ================================================ FILE: apps/rxjs.dev/src/app/layout/top-menu/top-menu.component.ts ================================================ import { Component, Input } from '@angular/core'; import { NavigationNode } from 'app/navigation/navigation.service'; @Component({ selector: 'aio-top-menu', template: `
  • {{ node.title }}
` }) export class TopMenuComponent { @Input() nodes: NavigationNode[]; } ================================================ FILE: apps/rxjs.dev/src/app/navigation/navigation.model.ts ================================================ // Pulled all interfaces out of `navigation.service.ts` because of this: // https://github.com/angular/angular-cli/issues/2034 // Then re-export them from `navigation.service.ts` export interface NavigationNode { url?: string; title: string; tooltip?: string; hidden?: boolean; children?: NavigationNode[]; } export type NavigationResponse = { __versionInfo: VersionInfo } & { [name: string]: NavigationNode[] | VersionInfo }; export interface NavigationViews { [name: string]: NavigationNode[]; } /** * Navigation information about a node at specific URL * url: the current URL * view: 'SideNav' | 'TopBar' | 'Footer' | etc * nodes: the current node and its ancestor nodes within that view */ export interface CurrentNode { url: string; view: string; nodes: NavigationNode[]; } /** * A map of current nodes by view. * This is needed because some urls map to nodes in more than one view. * If a view does not contain a node that matches the current url then the value will be undefined. */ export interface CurrentNodes { [view: string]: CurrentNode; } export interface VersionInfo { raw: string; major: number; minor: number; patch: number; prerelease: string[]; build: string; version: string; codeName: string; isSnapshot: boolean; full: string; branch: string; commitSHA: string; } ================================================ FILE: apps/rxjs.dev/src/app/navigation/navigation.service.spec.ts ================================================ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CurrentNodes, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; describe('NavigationService', () => { let injector: Injector; let navService: NavigationService; let httpMock: HttpTestingController; beforeEach(() => { injector = TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [NavigationService, { provide: LocationService, useFactory: () => new MockLocationService('a') }], }); navService = injector.get(NavigationService); httpMock = injector.get(HttpTestingController); }); afterEach(() => httpMock.verify()); describe('navigationViews', () => { it('should make a single connection to the server', () => { const req = httpMock.expectOne({}); expect(req.request.url).toBe('generated/navigation.json'); }); it('navigationViews observable should complete', () => { navService.navigationViews.subscribe(); // Stop `$httpMock.verify()` from complaining. httpMock.expectOne({}); }); it('should return the same object to all subscribers', () => { let views1: NavigationViews | undefined; navService.navigationViews.subscribe((views) => (views1 = views)); let views2: NavigationViews | undefined; navService.navigationViews.subscribe((views) => (views2 = views)); httpMock.expectOne({}).flush({ TopBar: [{ url: 'a' }] }); let views3: NavigationViews | undefined; navService.navigationViews.subscribe((views) => (views3 = views)); expect(views2).toBe(views1); expect(views3).toBe(views1); // Verify that subsequent subscriptions did not trigger another request. httpMock.expectNone({}); }); it('should do WHAT(?) if the request fails'); }); describe('node.tooltip', () => { let view: NavigationNode[]; const sideNav: NavigationNode[] = [{ title: 'a', tooltip: 'a tip' }, { title: 'b' }, { title: 'c!' }, { url: 'foo', title: '' }]; beforeEach(() => { navService.navigationViews.subscribe((views) => (view = views.sideNav)); httpMock.expectOne({}).flush({ sideNav }); }); it('should have the supplied tooltip', () => { expect(view[0].tooltip).toEqual('a tip'); }); it('should create a tooltip from title + period', () => { expect(view[1].tooltip).toEqual('b.'); }); it('should create a tooltip from title, keeping its trailing punctuation', () => { expect(view[2].tooltip).toEqual('c!'); }); it('should not create a tooltip if there is no title', () => { expect(view[3].tooltip).toBeUndefined(); }); }); describe('currentNode', () => { let currentNodes: CurrentNodes; let locationService: MockLocationService; const topBarNodes: NavigationNode[] = [{ url: 'features', title: 'Features', tooltip: 'tip' }]; const sideNavNodes: NavigationNode[] = [ { title: 'a', tooltip: 'tip', children: [ { url: 'b', title: 'b', tooltip: 'tip', children: [ { url: 'c', title: 'c', tooltip: 'tip' }, { url: 'd', title: 'd', tooltip: 'tip' }, ], }, { url: 'e', title: 'e', tooltip: 'tip' }, ], }, { url: 'f', title: 'f', tooltip: 'tip' }, ]; const navJson = { TopBar: topBarNodes, SideNav: sideNavNodes, __versionInfo: {}, }; beforeEach(() => { locationService = injector.get(LocationService) as any as MockLocationService; navService.currentNodes.subscribe((selected) => (currentNodes = selected)); httpMock.expectOne({}).flush(navJson); }); it('should list the side navigation node that matches the current location, and all its ancestors', () => { locationService.go('b'); expect(currentNodes).toEqual({ SideNav: { url: 'b', view: 'SideNav', nodes: [sideNavNodes[0].children![0], sideNavNodes[0]], }, } as any); locationService.go('d'); expect(currentNodes).toEqual({ SideNav: { url: 'd', view: 'SideNav', nodes: [sideNavNodes[0].children![0].children![1], sideNavNodes[0].children![0], sideNavNodes[0]], }, } as any); locationService.go('f'); expect(currentNodes).toEqual({ SideNav: { url: 'f', view: 'SideNav', nodes: [sideNavNodes[1]], }, }); }); it('should be a TopBar selected node if the current location is a top menu node', () => { locationService.go('features'); expect(currentNodes).toEqual({ TopBar: { url: 'features', view: 'TopBar', nodes: [topBarNodes[0]], }, }); }); it('should be a plain object if no navigation node matches the current location', () => { locationService.go('g?search=moo#anchor-1'); expect(currentNodes).toEqual({ '': { url: 'g', view: '', nodes: [], }, }); }); it('should ignore trailing slashes, hashes, and search params on URLs in the navmap', () => { const cnode: CurrentNodes = { SideNav: { url: 'c', view: 'SideNav', nodes: [sideNavNodes[0].children![0].children![0], sideNavNodes[0].children![0], sideNavNodes[0]], }, }; locationService.go('c'); expect(currentNodes).toEqual(cnode, 'location: c'); locationService.go('c#foo'); expect(currentNodes).toEqual(cnode, 'location: c#foo'); locationService.go('c?foo=1'); expect(currentNodes).toEqual(cnode, 'location: c?foo=1'); locationService.go('c#foo?bar=1&baz=2'); expect(currentNodes).toEqual(cnode, 'location: c#foo?bar=1&baz=2'); }); }); describe('versionInfo', () => { const expectedVersionInfo = { raw: '4.0.0' } as VersionInfo; let versionInfo: VersionInfo; beforeEach(() => { navService.versionInfo.subscribe((info) => (versionInfo = info)); httpMock.expectOne({}).flush({ __versionInfo: expectedVersionInfo, }); }); it('should extract the version info', () => { expect(versionInfo).toEqual(expectedVersionInfo); }); }); describe('docVersions', () => { let actualDocVersions: NavigationNode[]; let docVersions: NavigationNode[]; let expectedDocVersions: NavigationNode[]; beforeEach(() => { actualDocVersions = []; docVersions = [{ title: 'v4.0.0' }, { title: 'v2', url: 'https://v2.angular.io' }]; expectedDocVersions = docVersions.map((v) => ({ ...v, ...{ tooltip: v.title + '.' } })); navService.navigationViews.subscribe((views) => (actualDocVersions = views.docVersions)); }); it('should extract the docVersions', () => { httpMock.expectOne({}).flush({ docVersions }); expect(actualDocVersions).toEqual(expectedDocVersions); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/navigation/navigation.service.ts ================================================ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { combineLatest, ConnectableObservable, Observable } from 'rxjs'; import { map, publishLast, publishReplay } from 'rxjs/operators'; import { LocationService } from 'app/shared/location.service'; import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; // Import and re-export the Navigation model types import { CurrentNodes, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model'; export { CurrentNodes, CurrentNode, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model'; const navigationPath = CONTENT_URL_PREFIX + 'navigation.json'; @Injectable() export class NavigationService { /** * An observable collection of NavigationNode trees, which can be used to render navigational menus */ navigationViews: Observable; /** * The current version of doc-app that we are running */ versionInfo: Observable; /** * An observable of the current node with info about the * node (if any) that matches the current URL location * including its navigation view and its ancestor nodes in that view */ currentNodes: Observable; constructor(private http: HttpClient, private location: LocationService) { const navigationInfo = this.fetchNavigationInfo(); this.navigationViews = this.getNavigationViews(navigationInfo); this.currentNodes = this.getCurrentNodes(this.navigationViews); // The version information is packaged inside the navigation response to save us an extra request. this.versionInfo = this.getVersionInfo(navigationInfo); } /** * Get an observable that fetches the `NavigationResponse` from the server. * We create an observable by calling `http.get` but then publish it to share the result * among multiple subscribers, without triggering new requests. * We use `publishLast` because once the http request is complete the request observable completes. * If you use `publish` here then the completed request observable will cause the subscribed observables to complete too. * We `connect` to the published observable to trigger the request immediately. * We could use `.refCount` here but then if the subscribers went from 1 -> 0 -> 1 then you would get * another request to the server. * We are not storing the subscription from connecting as we do not expect this service to be destroyed. */ private fetchNavigationInfo(): Observable { const navigationInfo = this.http.get(navigationPath) .pipe(publishLast()); (navigationInfo as ConnectableObservable).connect(); return navigationInfo; } private getVersionInfo(navigationInfo: Observable) { const versionInfo = navigationInfo.pipe( map(response => response.__versionInfo), publishLast(), ); (versionInfo as ConnectableObservable).connect(); return versionInfo; } private getNavigationViews(navigationInfo: Observable): Observable { const navigationViews = navigationInfo.pipe( map(response => { const views = Object.assign({}, response); Object.keys(views).forEach(key => { if (key[0] === '_') { delete views[key]; } }); return views as NavigationViews; }), publishLast(), ); (navigationViews as ConnectableObservable).connect(); return navigationViews; } /** * Get an observable of the current nodes (the ones that match the current URL) * We use `publishReplay(1)` because otherwise subscribers will have to wait until the next * URL change before they receive an emission. * See above for discussion of using `connect`. */ private getCurrentNodes(navigationViews: Observable): Observable { const currentNodes = combineLatest( navigationViews.pipe(map(views => this.computeUrlToNavNodesMap(views))), this.location.currentPath, (navMap, url) => { const urlKey = url.startsWith('api/') ? 'api' : url; return navMap.get(urlKey) || { '' : { view: '', url: urlKey, nodes: [] }}; }) .pipe(publishReplay(1)); (currentNodes as ConnectableObservable).connect(); return currentNodes; } /** * Compute a mapping from URL to an array of nodes, where the first node in the array * is the one that matches the URL and the rest are the ancestors of that node. * * @param navigation - A collection of navigation nodes that are to be mapped */ private computeUrlToNavNodesMap(navigation: NavigationViews) { const navMap = new Map(); Object.keys(navigation) .forEach(view => navigation[view] .forEach(node => this.walkNodes(view, navMap, node))); return navMap; } /** * Add tooltip to node if it doesn't have one and have title. * If don't want tooltip, specify `"tooltip": ""` in navigation.json */ private ensureHasTooltip(node: NavigationNode) { const title = node.title; const tooltip = node.tooltip; if (tooltip == null && title ) { // add period if no trailing punctuation node.tooltip = title + (/[a-zA-Z0-9]$/.test(title) ? '.' : ''); } } /** * Walk the nodes of a navigation tree-view, * patching them and computing their ancestor nodes */ private walkNodes( view: string, navMap: Map, node: NavigationNode, ancestors: NavigationNode[] = []) { const nodes = [node, ...ancestors]; const url = node.url; this.ensureHasTooltip(node); // only map to this node if it has a url if (url) { // Strip off trailing slashes from nodes in the navMap - they are not relevant to matching const cleanedUrl = url.replace(/\/$/, ''); if (!navMap.has(cleanedUrl)) { navMap.set(cleanedUrl, {}); } const navMapItem = navMap.get(cleanedUrl)!; navMapItem[view] = { url, view, nodes }; } if (node.children) { node.children.forEach(child => this.walkNodes(view, navMap, child, nodes)); } } } ================================================ FILE: apps/rxjs.dev/src/app/search/interfaces.ts ================================================ export interface SearchResults { query: string; results: SearchResult[]; } export interface SearchResult { path: string; title: string; type: string; titleWords: string; keywords: string; } export interface SearchArea { name: string; pages: SearchResult[]; priorityPages: SearchResult[]; } ================================================ FILE: apps/rxjs.dev/src/app/search/search-box/search-box.component.spec.ts ================================================ import { Component } from '@angular/core'; import { ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SearchBoxComponent } from './search-box.component'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; @Component({ template: '' }) class HostComponent { searchHandler = jasmine.createSpy('searchHandler'); focusHandler = jasmine.createSpy('focusHandler'); } describe('SearchBoxComponent', () => { let component: SearchBoxComponent; let host: HostComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ declarations: [ SearchBoxComponent, HostComponent ], providers: [ { provide: LocationService, useFactory: () => new MockLocationService('') } ] }); }); beforeEach(() => { fixture = TestBed.createComponent(HostComponent); host = fixture.componentInstance; component = fixture.debugElement.query(By.directive(SearchBoxComponent)).componentInstance; fixture.detectChanges(); }); describe('initialisation', () => { it('should get the current search query from the location service', fakeAsync(inject([LocationService], (location: MockLocationService) => { location.search.and.returnValue({ search: 'initial search' }); component.ngOnInit(); expect(location.search).toHaveBeenCalled(); tick(300); expect(host.searchHandler).toHaveBeenCalledWith('initial search'); expect(component.searchBox.nativeElement.value).toEqual('initial search'); }))); }); describe('onSearch', () => { it('should debounce by 300ms', fakeAsync(() => { component.doSearch(); expect(host.searchHandler).not.toHaveBeenCalled(); tick(300); expect(host.searchHandler).toHaveBeenCalled(); })); it('should pass through the value of the input box', fakeAsync(() => { const input = fixture.debugElement.query(By.css('input')); input.nativeElement.value = 'some query (input)'; component.doSearch(); tick(300); expect(host.searchHandler).toHaveBeenCalledWith('some query (input)'); })); it('should only send events if the search value has changed', fakeAsync(() => { const input = fixture.debugElement.query(By.css('input')); input.nativeElement.value = 'some query'; component.doSearch(); tick(300); expect(host.searchHandler).toHaveBeenCalledTimes(1); component.doSearch(); tick(300); expect(host.searchHandler).toHaveBeenCalledTimes(1); input.nativeElement.value = 'some other query'; component.doSearch(); tick(300); expect(host.searchHandler).toHaveBeenCalledTimes(2); })); }); describe('on input', () => { it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); spyOn(component, 'doSearch'); input.triggerEventHandler('input', { }); expect(component.doSearch).toHaveBeenCalled(); }); }); describe('on keyup', () => { it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); spyOn(component, 'doSearch'); input.triggerEventHandler('keyup', { }); expect(component.doSearch).toHaveBeenCalled(); }); }); describe('on focus', () => { it('should trigger the onFocus event', () => { const input = fixture.debugElement.query(By.css('input')); input.nativeElement.value = 'some query (focus)'; input.triggerEventHandler('focus', { }); expect(host.focusHandler).toHaveBeenCalledWith('some query (focus)'); }); }); describe('on click', () => { it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); spyOn(component, 'doSearch'); input.triggerEventHandler('click', { }); expect(component.doSearch).toHaveBeenCalled(); }); }); describe('focus', () => { it('should set the focus to the input box', () => { const input = fixture.debugElement.query(By.css('input')); component.focus(); expect(document.activeElement).toBe(input.nativeElement); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/search/search-box/search-box.component.ts ================================================ import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; /** * This component provides a text box to type a search query that will be sent to the SearchService. * * When you arrive at a page containing this component, it will retrieve the `query` from the browser * address bar. If there is a query then this will be made. * * Focussing on the input box will resend whatever query is there. This can be useful if the search * results had been hidden for some reason. * */ @Component({ selector: 'aio-search-box', template: `` }) export class SearchBoxComponent implements OnInit { private searchDebounce = 300; private searchSubject = new Subject(); @ViewChild('searchBox', {static: true}) searchBox: ElementRef; // eslint-disable-next-line @angular-eslint/no-output-on-prefix @Output() onSearch = this.searchSubject.pipe(distinctUntilChanged(), debounceTime(this.searchDebounce)); // eslint-disable-next-line @angular-eslint/no-output-on-prefix @Output() onFocus = new EventEmitter(); constructor(private locationService: LocationService) { } /** * When we first show this search box we trigger a search if there is a search query in the URL */ ngOnInit() { const query = this.locationService.search().search; if (query) { this.query = query; this.doSearch(); } } doSearch() { this.searchSubject.next(this.query); } doFocus() { this.onFocus.emit(this.query); } focus() { this.searchBox.nativeElement.focus(); } private get query() { return this.searchBox.nativeElement.value; } private set query(value: string) { this.searchBox.nativeElement.value = value; } } ================================================ FILE: apps/rxjs.dev/src/app/search/search.service.ts ================================================ /* Copyright 2016 Google Inc. All Rights Reserved. Use of this source code is governed by an MIT-style license that can be found in the LICENSE file at http://angular.io/license */ import { NgZone, Injectable } from '@angular/core'; import { ConnectableObservable, Observable, race, ReplaySubject, timer } from 'rxjs'; import { concatMap, first, publishReplay } from 'rxjs/operators'; import { WebWorkerClient } from 'app/shared/web-worker'; import { SearchResults } from 'app/search/interfaces'; @Injectable() export class SearchService { private ready: Observable; private searchesSubject = new ReplaySubject(1); private worker: WebWorkerClient; constructor(private zone: NgZone) {} /** * Initialize the search engine. We offer an `initDelay` to prevent the search initialisation from delaying the * initial rendering of the web page. Triggering a search will override this delay and cause the index to be * loaded immediately. * * @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index */ initWorker(initDelay: number) { // Wait for the initDelay or the first search const ready = (this.ready = race(timer(initDelay), this.searchesSubject.asObservable().pipe(first())).pipe( concatMap(() => { // Create the worker and load the index const worker = new Worker(new URL('./search.worker', import.meta.url), { type: 'module' }); this.worker = WebWorkerClient.create(worker, this.zone); return this.worker.sendMessage('load-index'); }), publishReplay(1) )); // Connect to the observable to kick off the timer (ready as ConnectableObservable).connect(); return ready; } /** * Search the index using the given query and emit results on the observable that is returned. * * @param query The query to run against the index. * @returns an observable collection of search results */ search(query: string): Observable { // Trigger the searches subject to override the init delay timer this.searchesSubject.next(query); // Once the index has loaded, switch to listening to the searches coming in. return this.ready.pipe(concatMap(() => this.worker.sendMessage('query-index', query))); } } ================================================ FILE: apps/rxjs.dev/src/app/search/search.worker.ts ================================================ /* eslint-env worker */ /// import * as lunr from 'lunr'; import { WebWorkerMessage } from '../shared/web-worker-message'; const SEARCH_TERMS_URL = '/generated/docs/app/search-data.json'; let index: lunr.Index; const pageMap: SearchInfo = {}; interface SearchInfo { [key: string]: PageInfo; } interface PageInfo { path: string; type: string; title: string; headings: string; keywords: string; members: string; topics: string; } interface EncodedPages { dictionary: string; pages: EncodedPage[]; } interface EncodedPage { path: string; type: string; title: string; headings: number[]; keywords: number[]; members: number[]; topics: string; } addEventListener('message', handleMessage); const customLunr = function(config: lunr.ConfigFunction) { const builder = new lunr.Builder(); builder.pipeline.add(lunr.trimmer, lunr.stemmer); builder.searchPipeline.add(lunr.stemmer); config.call(builder, builder); return builder.build(); }; // Create the lunr index - the docs should be an array of objects, each object containing // the path and search terms for a page function createIndex(loadIndexFn: IndexLoader): lunr.Index { // The lunr typings are missing QueryLexer so we have to add them here manually. const queryLexer = (lunr as any as { QueryLexer: { termSeparator: RegExp } }).QueryLexer; queryLexer.termSeparator = lunr.tokenizer.separator = /\s+/; return customLunr(function() { this.ref('path'); this.field('topics', { boost: 15 }); this.field('title', { boost: 10 }); this.field('headings', { boost: 5 }); this.field('members', { boost: 4 }); this.field('keywords', { boost: 2 }); loadIndexFn(this); }); } // The worker receives a message to load the index and to query the index function handleMessage(message: { data: WebWorkerMessage }): void { const type = message.data.type; const id = message.data.id; const payload = message.data.payload; switch (type) { case 'load-index': makeRequest(SEARCH_TERMS_URL, (encodedPages: EncodedPages) => { index = createIndex(loadIndex(encodedPages)); postMessage({ type, id, payload: true }); }); break; case 'query-index': postMessage({ type, id, payload: { query: payload, results: queryIndex(payload) } }); break; default: postMessage({ type, id, payload: { error: 'invalid message type' } }); } } // Use XHR to make a request to the server function makeRequest(url: string, callback: (response: any) => void): void { // The JSON file that is loaded should be an array of PageInfo: const searchDataRequest = new XMLHttpRequest(); searchDataRequest.onload = function() { callback(JSON.parse(this.responseText)); }; searchDataRequest.open('GET', url); searchDataRequest.send(); } // Create the search index from the searchInfo which contains the information about each page to be // indexed function loadIndex({ dictionary, pages }: EncodedPages): IndexLoader { const dictionaryArray = dictionary.split(' '); return (indexBuilder: lunr.Builder) => { // Store the pages data to be used in mapping query results back to pages // Add search terms from each page to the search index pages.forEach((encodedPage) => { const page = decodePage(encodedPage, dictionaryArray); indexBuilder.add(page); pageMap[page.path] = page; }); }; } function decodePage(encodedPage: EncodedPage, dictionary: string[]): PageInfo { return { ...encodedPage, headings: encodedPage.headings?.map((i) => dictionary[i]).join(' ') ?? '', keywords: encodedPage.keywords?.map((i) => dictionary[i]).join(' ') ?? '', members: encodedPage.members?.map((i) => dictionary[i]).join(' ') ?? '', }; } // Query the index and return the processed results function queryIndex(query: string): PageInfo[] { // Strip off quotes query = query.replace(/^["']|['"]$/g, ''); try { if (query.length) { let results = index.query((queryBuilder) => { queryBuilder.term(lunr.tokenizer(query), { fields: ['title'], // eslint-disable-next-line no-bitwise wildcard: lunr.Query.wildcard.TRAILING | lunr.Query.wildcard.LEADING, usePipeline: true, presence: lunr.Query.presence.REQUIRED, }); }); if (results.length === 0) { // First try a query where every term must be present // (see https://lunrjs.com/guides/searching.html#term-presence) results = index.query((queryBuilder) => { const tokens = lunr.tokenizer(query); for (const token of tokens) { queryBuilder.term(token, { usePipeline: true, presence: lunr.Query.presence.REQUIRED, }); } }); } // If that was too restrictive just query for any term to be present if (results.length === 0) { results = index.search(query); } // If that is still too restrictive then search in the title for the first word in the query if (results.length === 0) { // E.g. if the search is "ngCont guide" then we search for "ngCont guide title:*ngCont*" const titleQuery = 'title:*' + query.split(' ', 1)[0] + '*'; results = index.search(query + ' ' + titleQuery); } // Map the hits into info about each page to be returned as results return results.map((hit) => pageMap[hit.ref]); } } catch (e) { // If the search query cannot be parsed the index throws an error // Log it and recover console.error(e); } return []; } type IndexLoader = (indexBuilder: lunr.Builder) => void; ================================================ FILE: apps/rxjs.dev/src/app/shared/attribute-utils.spec.ts ================================================ import { ElementRef } from '@angular/core'; import { AttrMap, getAttrs, getAttrValue, getBoolFromAttribute, boolFromValue } from './attribute-utils'; describe('Attribute Utilities', () => { let testEl: HTMLElement; beforeEach(() => { const div = document.createElement('div'); div.innerHTML = '
'; testEl = div.querySelector('div')!; }); describe('getAttrs', () => { const expectedMap = { a: '', b: 'true', c: 'false', d: 'foo', 'd-e': '' }; it('should get attr map from getAttrs(element)', () => { const actual = getAttrs(testEl); expect(actual).toEqual(expectedMap); }); it('should get attr map from getAttrs(elementRef)', () => { const actual = getAttrs(new ElementRef(testEl)); expect(actual).toEqual(expectedMap); }); }); describe('getAttrValue', () => { let attrMap: AttrMap; beforeEach(() => { attrMap = getAttrs(testEl); }); it('should return empty string for attribute "a"', () => { expect(getAttrValue(attrMap, 'a')).toBe(''); }); it('should return empty string for attribute "A"', () => { expect(getAttrValue(attrMap, 'A')).toBe(''); }); it('should return "true" for attribute "b"', () => { expect(getAttrValue(attrMap, 'b')).toBe('true'); }); it('should return empty string for attribute "d-E"', () => { expect(getAttrValue(attrMap, 'd-E')).toBe(''); }); it('should return empty string for attribute ["d-e"]', () => { expect(getAttrValue(attrMap, ['d-e'])).toBe(''); }); it('should return "foo" for attribute ["d", "d-e"]', () => { // because d will be found before d-e expect(getAttrValue(attrMap, ['d', 'd-e'])).toBe('foo'); }); it('should return empty string for attribute ["d-e", "d"]', () => { // because d-e will be found before d expect(getAttrValue(attrMap, ['d-e', 'd'])).toBe(''); }); it('should return undefined for non-existent attributes', () => { expect(getAttrValue(attrMap, 'x')).toBeUndefined(); expect(getAttrValue(attrMap, '')).toBeUndefined(); expect(getAttrValue(attrMap, ['', 'x'])).toBeUndefined(); }); }); describe('boolFromValue', () => { it('should return true for "" as in present but unassigned attr "a"', () => { expect(boolFromValue('')).toBe(true); }); it('should return false for "false" as in attr "c"', () => { expect(boolFromValue('false')).toBe(false); }); it('should return true for "true" as in attr "b"', () => { expect(boolFromValue('true')).toBe(true); }); it('should return true for something other than "false"', () => { expect(boolFromValue('foo')).toBe(true); }); it('should return true for "False" because case-sensitive', () => { expect(boolFromValue('False')).toBe(true); }); it('should return false by default as in undefined attr "x"', () => { expect(boolFromValue(undefined)).toBe(false); }); it('should return true for undefined value when default is true', () => { expect(boolFromValue(undefined, true)).toBe(true); }); it('should return false for undefined value when default is false', () => { expect(boolFromValue(undefined, false)).toBe(false); }); it('should return true for "" as in unassigned attr "a" even when default is false', () => { // default value is only applied when the attribute is missing expect(boolFromValue('', false)).toBe(true); }); }); // Combines the three utilities for convenience. describe('getBoolFromAttribute', () => { it('should return true for present but unassigned attr "a"', () => { expect(getBoolFromAttribute(testEl, 'a')).toBe(true); }); it('should return true for attr "b" which is "true"', () => { expect(getBoolFromAttribute(testEl, 'b')).toBe(true); }); it('should return false for attr "c" which is "false"', () => { expect(getBoolFromAttribute(testEl, 'c')).toBe(false); }); it('should return true for attributes ["d-e", "d"]', () => { // because d-e will be found before D="foo" expect(getBoolFromAttribute(testEl, ['d-e', 'd'])).toBe(true); }); it('should return false for non-existent attribute "x"', () => { expect(getBoolFromAttribute(testEl, 'x')).toBe(false); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/attribute-utils.ts ================================================ // Utilities for processing HTML element attributes import { ElementRef } from '@angular/core'; export interface AttrMap { [key: string]: string; } /** * Get attribute map from element or ElementRef `attributes`. * Attribute map keys are forced lowercase for case-insensitive lookup. * * @param el The source of the attributes. */ export function getAttrs(el: HTMLElement | ElementRef): AttrMap { const attrs: NamedNodeMap = el instanceof ElementRef ? el.nativeElement.attributes : el.attributes; const attrMap: AttrMap = {}; for (const attr of attrs as any as Attr[] /* cast due to https://github.com/Microsoft/TypeScript/issues/2695 */) { attrMap[attr.name.toLowerCase()] = attr.value; } return attrMap; } /** * Return the attribute that matches `attr`. * * @param attr Name of the attribute or a string of candidate attribute names. */ export function getAttrValue(attrs: AttrMap, attr: string | string[]): string | undefined { const key = (typeof attr === 'string') ? attr : attr.find(a => attrs.hasOwnProperty(a.toLowerCase())); return (key === undefined) ? undefined : attrs[key.toLowerCase()]; } /** * Return the boolean state of an attribute value (if supplied) * * @param attrValue The string value of some attribute (or undefined if attribute not present). * @param def Default boolean value when attribute is undefined. */ export function boolFromValue(attrValue: string | undefined, def: boolean = false) { return attrValue === undefined ? def : attrValue.trim() !== 'false'; } /** * Return the boolean state of attribute from an element * * @param el The source of the attributes. * @param atty Name of the attribute or a string of candidate attribute names. * @param def Default boolean value when attribute is undefined. */ export function getBoolFromAttribute( el: HTMLElement | ElementRef, attr: string | string[], def: boolean = false): boolean { return boolFromValue(getAttrValue(getAttrs(el), attr), def); } ================================================ FILE: apps/rxjs.dev/src/app/shared/copier.service.ts ================================================ import { Injectable } from '@angular/core'; /** * This class is based on the code in the following projects: * * - https://github.com/zenorocha/select * - https://github.com/zenorocha/clipboard.js/ * * Both released under MIT license - © Zeno Rocha */ @Injectable() export class CopierService { private fakeElem: HTMLTextAreaElement|null; /** * Creates a fake textarea element, sets its value from `text` property, * and makes a selection on it. */ createFake(text: string) { const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; // Create a fake element to hold the contents to copy this.fakeElem = document.createElement('textarea'); // Prevent zooming on iOS this.fakeElem.style.fontSize = '12pt'; // Reset box model this.fakeElem.style.border = '0'; this.fakeElem.style.padding = '0'; this.fakeElem.style.margin = '0'; // Move element out of screen horizontally this.fakeElem.style.position = 'absolute'; this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px'; // Move element to the same position vertically const yPosition = window.pageYOffset || document.documentElement.scrollTop; this.fakeElem.style.top = yPosition + 'px'; this.fakeElem.setAttribute('readonly', ''); this.fakeElem.value = text; document.body.appendChild(this.fakeElem); this.fakeElem.select(); this.fakeElem.setSelectionRange(0, this.fakeElem.value.length); } removeFake() { if (this.fakeElem) { document.body.removeChild(this.fakeElem); this.fakeElem = null; } } copyText(text: string) { try { this.createFake(text); return document.execCommand('copy'); } catch (err) { return false; } finally { this.removeFake(); } } } ================================================ FILE: apps/rxjs.dev/src/app/shared/current-date.ts ================================================ import { InjectionToken } from '@angular/core'; export const CurrentDateToken = new InjectionToken('CurrentDate'); export function currentDateProvider() { return new Date(); } ================================================ FILE: apps/rxjs.dev/src/app/shared/custom-icon-registry.spec.ts ================================================ import { ErrorHandler } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { CustomIconRegistry, SvgIconInfo } from './custom-icon-registry'; describe('CustomIconRegistry', () => { it('should get the SVG element for a preloaded icon from the cache', () => { const mockHttp: any = {}; const mockSanitizer: any = {}; const mockDocument: any = {}; const svgSrc = ''; const svgIcons: SvgIconInfo[] = [ { name: 'test_icon', svgSource: svgSrc } ]; const errorHandler = new ErrorHandler(); const registry = new CustomIconRegistry(mockHttp, mockSanitizer, mockDocument, svgIcons, errorHandler); let svgElement: SVGElement|undefined; registry.getNamedSvgIcon('test_icon').subscribe(el => svgElement = el); expect(svgElement).toEqual(createSvg(svgSrc)); }); it('should call through to the MdIconRegistry if the icon name is not in the preloaded cache', () => { const mockHttp: any = {}; const mockSanitizer: any = {}; const mockDocument: any = {}; const svgSrc = ''; const svgIcons: SvgIconInfo[] = [ { name: 'test_icon', svgSource: svgSrc } ]; spyOn(MatIconRegistry.prototype, 'getNamedSvgIcon'); const errorHandler = new ErrorHandler(); const registry = new CustomIconRegistry(mockHttp, mockSanitizer, mockDocument, svgIcons, errorHandler); registry.getNamedSvgIcon('other_icon'); expect(MatIconRegistry.prototype.getNamedSvgIcon).toHaveBeenCalledWith('other_icon', undefined); registry.getNamedSvgIcon('other_icon', 'foo'); expect(MatIconRegistry.prototype.getNamedSvgIcon).toHaveBeenCalledWith('other_icon', 'foo'); }); }); function createSvg(svgSrc: string): SVGElement { const div = document.createElement('div'); div.innerHTML = svgSrc; return div.querySelector('svg')!; } ================================================ FILE: apps/rxjs.dev/src/app/shared/custom-icon-registry.ts ================================================ import { InjectionToken, Inject, Injectable, Optional, ErrorHandler } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { of } from 'rxjs'; import { MatIconRegistry } from '@angular/material/icon'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer } from '@angular/platform-browser'; /** * Use SVG_ICONS (and SvgIconInfo) as "multi" providers to provide the SVG source * code for the icons that you wish to have preloaded in the `CustomIconRegistry` * For compatibility with the MdIconComponent, please ensure that the SVG source has * the following attributes: * * * `xmlns="http://www.w3.org/2000/svg"` * * `focusable="false"` (disable IE11 default behavior to make SVGs focusable) * * `height="100%"` (the default) * * `width="100%"` (the default) * * `preserveAspectRatio="xMidYMid meet"` (the default) * */ export const SVG_ICONS = new InjectionToken>('SvgIcons'); export interface SvgIconInfo { name: string; svgSource: string; } interface SvgIconMap { [iconName: string]: SVGElement; } /** * A custom replacement for Angular Material's `MdIconRegistry`, which allows * us to provide preloaded icon SVG sources. */ @Injectable() export class CustomIconRegistry extends MatIconRegistry { private preloadedSvgElements: SvgIconMap = {}; constructor(http: HttpClient, sanitizer: DomSanitizer, @Optional() @Inject(DOCUMENT) document: Document, @Inject(SVG_ICONS) svgIcons: SvgIconInfo[], errorHandler: ErrorHandler) { super(http, sanitizer, document, errorHandler); this.loadSvgElements(svgIcons); } override getNamedSvgIcon(iconName: string, namespace?: string) { if (this.preloadedSvgElements[iconName]) { return of(this.preloadedSvgElements[iconName].cloneNode(true) as SVGElement); } return super.getNamedSvgIcon(iconName, namespace); } private loadSvgElements(svgIcons: SvgIconInfo[]) { const div = document.createElement('DIV'); svgIcons.forEach(icon => { // SECURITY: the source for the SVG icons is provided in code by trusted developers div.innerHTML = icon.svgSource; this.preloadedSvgElements[icon.name] = div.querySelector('svg')!; }); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/deployment.service.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { environment } from 'environments/environment'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; import { Deployment } from './deployment.service'; describe('Deployment service', () => { describe('mode', () => { it('should get the mode from the environment', () => { environment.mode = 'foo'; const deployment = getInjector().get(Deployment); expect(deployment.mode).toEqual('foo'); }); it('should get the mode from the `mode` query parameter if available', () => { const injector = getInjector(); const locationService: MockLocationService = injector.get(LocationService); locationService.search.and.returnValue({ mode: 'bar' }); const deployment = injector.get(Deployment); expect(deployment.mode).toEqual('bar'); }); }); }); function getInjector() { return ReflectiveInjector.resolveAndCreate([ Deployment, { provide: LocationService, useFactory: () => new MockLocationService('') } ]); } ================================================ FILE: apps/rxjs.dev/src/app/shared/deployment.service.ts ================================================ import { Injectable } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; import { environment } from 'environments/environment'; /** * Information about the deployment of this application. */ @Injectable() export class Deployment { /** * The deployment mode set from the environment provided at build time; * or overridden by the `mode` query parameter: e.g. `...?mode=archive` */ mode: string = this.location.search().mode || environment.mode; constructor(private location: LocationService) {} }; ================================================ FILE: apps/rxjs.dev/src/app/shared/ga.service.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { GaService } from 'app/shared/ga.service'; import { WindowToken } from 'app/shared/window'; describe('GaService', () => { let gaService: GaService; let injector: ReflectiveInjector; let gaSpy: jasmine.Spy; let mockWindow: any; beforeEach(() => { gaSpy = jasmine.createSpy('ga'); mockWindow = { ga: gaSpy }; injector = ReflectiveInjector.resolveAndCreate([GaService, { provide: WindowToken, useFactory: () => mockWindow }]); gaService = injector.get(GaService); }); it('should initialize ga with "create" when constructed', () => { const first = gaSpy.calls.first().args; expect(first[0]).toBe('create'); }); describe('#locationChanged(url)', () => { it('should send page to url w/ leading slash', () => { gaService.locationChanged('testUrl'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); }); }); describe('#sendPage(url)', () => { it('should set page to url w/ leading slash', () => { gaService.sendPage('testUrl'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl'); }); it('should send "pageview" ', () => { gaService.sendPage('testUrl'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); }); it('should not send twice with same URL, back-to-back', () => { gaService.sendPage('testUrl'); gaSpy.calls.reset(); gaService.sendPage('testUrl'); expect(gaSpy).not.toHaveBeenCalled(); }); it('should send again even if only the hash changes', () => { // Therefore it is up to caller NOT to call it when hash changes if this is unwanted. // See LocationService and its specs gaService.sendPage('testUrl#one'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl#one'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); gaSpy.calls.reset(); gaService.sendPage('testUrl#two'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl#two'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); }); it('should send same URL twice when other intervening URL', () => { gaService.sendPage('testUrl'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); gaSpy.calls.reset(); gaService.sendPage('testUrl2'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl2'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); gaSpy.calls.reset(); gaService.sendPage('testUrl'); expect(gaSpy).toHaveBeenCalledWith('set', 'page', '/testUrl'); expect(gaSpy).toHaveBeenCalledWith('send', 'pageview'); }); }); describe('sendEvent', () => { it('should send "event" with associated data', () => { gaService.sendEvent('some source', 'some campaign', 'a label', 45); expect(gaSpy).toHaveBeenCalledWith('send', 'event', 'some source', 'some campaign', 'a label', 45); }); }); it('should support replacing the `window.ga` function', () => { const gaSpy2 = jasmine.createSpy('new ga'); mockWindow.ga = gaSpy2; gaSpy.calls.reset(); gaService.sendPage('testUrl'); expect(gaSpy).not.toHaveBeenCalled(); expect(gaSpy2).toHaveBeenCalledWith('set', 'page', '/testUrl'); expect(gaSpy2).toHaveBeenCalledWith('send', 'pageview'); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/ga.service.ts ================================================ import { Inject, Injectable } from '@angular/core'; import { environment } from '../../environments/environment'; import { WindowToken } from 'app/shared/window'; @Injectable() /** * Google Analytics Service - captures app behaviors and sends them to Google Analytics (GA). * Presupposes that GA script has been loaded from a script on the host web page. * Associates data with a GA "property" from the environment (`gaId`). */ export class GaService { private previousUrl: string; constructor(@Inject(WindowToken) private window: Window) { this.ga('create', environment.gaId , 'auto'); } locationChanged(url: string) { this.sendPage(url); } sendPage(url: string) { // Won't re-send if the url hasn't changed. if (url === this.previousUrl) { return; } this.previousUrl = url; this.ga('set', 'page', '/' + url); this.ga('send', 'pageview'); } sendEvent(source: string, action: string, label?: string, value?: number) { this.ga('send', 'event', source, action, label, value); } ga(...args: any[]) { const gaFn = (this.window as any).ga; if (gaFn) { gaFn(...args); } } } ================================================ FILE: apps/rxjs.dev/src/app/shared/location.service.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { Location, LocationStrategy, PlatformLocation } from '@angular/common'; import { MockLocationStrategy } from '@angular/common/testing'; import { Subject } from 'rxjs'; import { GaService } from 'app/shared/ga.service'; import { SwUpdatesService } from 'app/sw-updates/sw-updates.service'; import { LocationService } from './location.service'; describe('LocationService', () => { let injector: ReflectiveInjector; let location: MockLocationStrategy; let service: LocationService; let swUpdates: MockSwUpdatesService; beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ LocationService, Location, { provide: GaService, useClass: TestGaService }, { provide: LocationStrategy, useClass: MockLocationStrategy }, { provide: PlatformLocation, useClass: MockPlatformLocation }, { provide: SwUpdatesService, useClass: MockSwUpdatesService } ]); location = injector.get(LocationStrategy); service = injector.get(LocationService); swUpdates = injector.get(SwUpdatesService); }); describe('currentUrl', () => { it('should emit the latest url at the time it is subscribed to', () => { location.simulatePopState('/initial-url1'); location.simulatePopState('/initial-url2'); location.simulatePopState('/initial-url3'); location.simulatePopState('/next-url1'); location.simulatePopState('/next-url2'); location.simulatePopState('/next-url3'); let initialUrl: string|undefined; service.currentUrl.subscribe(url => initialUrl = url); expect(initialUrl).toEqual('next-url3'); }); it('should emit all location changes after it has been subscribed to', () => { location.simulatePopState('/initial-url1'); location.simulatePopState('/initial-url2'); location.simulatePopState('/initial-url3'); const urls: string[] = []; service.currentUrl.subscribe(url => urls.push(url)); location.simulatePopState('/next-url1'); location.simulatePopState('/next-url2'); location.simulatePopState('/next-url3'); expect(urls).toEqual([ 'initial-url3', 'next-url1', 'next-url2', 'next-url3' ]); }); it('should pass only the latest and later urls to each subscriber', () => { location.simulatePopState('/initial-url1'); location.simulatePopState('/initial-url2'); location.simulatePopState('/initial-url3'); const urls1: string[] = []; service.currentUrl.subscribe(url => urls1.push(url)); location.simulatePopState('/next-url1'); location.simulatePopState('/next-url2'); const urls2: string[] = []; service.currentUrl.subscribe(url => urls2.push(url)); location.simulatePopState('/next-url3'); expect(urls1).toEqual([ 'initial-url3', 'next-url1', 'next-url2', 'next-url3' ]); expect(urls2).toEqual([ 'next-url2', 'next-url3' ]); }); it('should strip leading and trailing slashes', () => { const urls: string[] = []; service.currentUrl.subscribe(u => urls.push(u)); location.simulatePopState('///some/url1///'); location.simulatePopState('///some/url2///?foo=bar'); location.simulatePopState('///some/url3///#baz'); location.simulatePopState('///some/url4///?foo=bar#baz'); expect(urls.slice(-4)).toEqual([ 'some/url1', 'some/url2?foo=bar', 'some/url3#baz', 'some/url4?foo=bar#baz' ]); }); }); describe('currentPath', () => { it('should strip leading and trailing slashes off the url', () => { const paths: string[] = []; service.currentPath.subscribe(p => paths.push(p)); location.simulatePopState('///initial/url1///'); location.simulatePopState('///initial/url2///?foo=bar'); location.simulatePopState('///initial/url3///#baz'); location.simulatePopState('///initial/url4///?foo=bar#baz'); expect(paths.slice(-4)).toEqual([ 'initial/url1', 'initial/url2', 'initial/url3', 'initial/url4' ]); }); it('should not strip other slashes off the url', () => { const paths: string[] = []; service.currentPath.subscribe(p => paths.push(p)); location.simulatePopState('initial///url1'); location.simulatePopState('initial///url2?foo=bar'); location.simulatePopState('initial///url3#baz'); location.simulatePopState('initial///url4?foo=bar#baz'); expect(paths.slice(-4)).toEqual([ 'initial///url1', 'initial///url2', 'initial///url3', 'initial///url4' ]); }); it('should strip the query off the url', () => { let path: string|undefined; service.currentPath.subscribe(p => path = p); location.simulatePopState('/initial/url1?foo=bar'); expect(path).toBe('initial/url1'); }); it('should strip the hash fragment off the url', () => { const paths: string[] = []; service.currentPath.subscribe(p => paths.push(p)); location.simulatePopState('/initial/url1#foo'); location.simulatePopState('/initial/url2?foo=bar#baz'); expect(paths.slice(-2)).toEqual([ 'initial/url1', 'initial/url2' ]); }); it('should emit the latest path at the time it is subscribed to', () => { location.simulatePopState('/initial/url1'); location.simulatePopState('/initial/url2'); location.simulatePopState('/initial/url3'); location.simulatePopState('/next/url1'); location.simulatePopState('/next/url2'); location.simulatePopState('/next/url3'); let initialPath: string|undefined; service.currentPath.subscribe(path => initialPath = path); expect(initialPath).toEqual('next/url3'); }); it('should emit all location changes after it has been subscribed to', () => { location.simulatePopState('/initial/url1'); location.simulatePopState('/initial/url2'); location.simulatePopState('/initial/url3'); const paths: string[] = []; service.currentPath.subscribe(path => paths.push(path)); location.simulatePopState('/next/url1'); location.simulatePopState('/next/url2'); location.simulatePopState('/next/url3'); expect(paths).toEqual([ 'initial/url3', 'next/url1', 'next/url2', 'next/url3' ]); }); it('should pass only the latest and later paths to each subscriber', () => { location.simulatePopState('/initial/url1'); location.simulatePopState('/initial/url2'); location.simulatePopState('/initial/url3'); const paths1: string[] = []; service.currentPath.subscribe(path => paths1.push(path)); location.simulatePopState('/next/url1'); location.simulatePopState('/next/url2'); const paths2: string[] = []; service.currentPath.subscribe(path => paths2.push(path)); location.simulatePopState('/next/url3'); expect(paths1).toEqual([ 'initial/url3', 'next/url1', 'next/url2', 'next/url3' ]); expect(paths2).toEqual([ 'next/url2', 'next/url3' ]); }); }); describe('go', () => { it('should update the location', () => { service.go('some-new-url'); expect(location.internalPath).toEqual('some-new-url'); expect(location.path(true)).toEqual('some-new-url'); }); it('should emit the new url', () => { const urls: string[] = []; service.go('some-initial-url'); service.currentUrl.subscribe(url => urls.push(url)); service.go('some-new-url'); expect(urls).toEqual([ 'some-initial-url', 'some-new-url' ]); }); it('should strip leading and trailing slashes', () => { let url: string|undefined; service.currentUrl.subscribe(u => url = u); service.go('/some/url/'); expect(location.internalPath).toEqual('some/url'); expect(location.path(true)).toEqual('some/url'); expect(url).toBe('some/url'); }); it('should ignore empty URL string', () => { const initialUrl = 'some/url'; const goExternalSpy = spyOn(service, 'goExternal'); let url: string|undefined; service.go(initialUrl); service.currentUrl.subscribe(u => url = u); service.go(''); expect(url).toEqual(initialUrl, 'should not have re-navigated locally'); expect(goExternalSpy).not.toHaveBeenCalled(); }); it('should leave the site for external url that starts with "http"', () => { const goExternalSpy = spyOn(service, 'goExternal'); const externalUrl = 'http://some/far/away/land'; service.go(externalUrl); expect(goExternalSpy).toHaveBeenCalledWith(externalUrl); }); it('should do a "full page navigation" if a ServiceWorker update has been activated', () => { const goExternalSpy = spyOn(service, 'goExternal'); // Internal URL - No ServiceWorker update service.go('some-internal-url'); expect(goExternalSpy).not.toHaveBeenCalled(); expect(location.path(true)).toEqual('some-internal-url'); // Internal URL - ServiceWorker update swUpdates.updateActivated.next('foo'); service.go('other-internal-url'); expect(goExternalSpy).toHaveBeenCalledWith('other-internal-url'); expect(location.path(true)).toEqual('some-internal-url'); }); it('should not update currentUrl for external url that starts with "http"', () => { let localUrl: string|undefined; spyOn(service, 'goExternal'); service.currentUrl.subscribe(url => localUrl = url); service.go('https://some/far/away/land'); expect(localUrl).toBeFalsy('should not set local url'); }); }); describe('search', () => { it('should read the query from the current location.path', () => { location.simulatePopState('a/b/c?foo=bar&moo=car'); expect(service.search()).toEqual({ foo: 'bar', moo: 'car' }); }); it('should cope with an empty query', () => { location.simulatePopState('a/b/c'); expect(service.search()).toEqual({ }); location.simulatePopState('x/y/z?'); expect(service.search()).toEqual({ }); location.simulatePopState('x/y/z?x='); expect(service.search()).toEqual({ x: '' }); location.simulatePopState('x/y/z?x'); expect(service.search()).toEqual({ x: undefined }); }); it('should URL decode query values', () => { location.simulatePopState('a/b/c?query=a%26b%2Bc%20d'); expect(service.search()).toEqual({ query: 'a&b+c d' }); }); it('should URL decode query keys', () => { location.simulatePopState('a/b/c?a%26b%2Bc%20d=value'); expect(service.search()).toEqual({ 'a&b+c d': 'value' }); }); it('should cope with a hash on the URL', () => { spyOn(location, 'path').and.callThrough(); service.search(); expect(location.path).toHaveBeenCalledWith(false); }); }); describe('setSearch', () => { let platformLocation: MockPlatformLocation; beforeEach(() => { platformLocation = injector.get(PlatformLocation); }); it('should call replaceState on PlatformLocation', () => { const params = {}; service.setSearch('Some label', params); expect(platformLocation.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', 'a/b/c'); }); it('should convert the params to a query string', () => { const params = { foo: 'bar', moo: 'car' }; service.setSearch('Some label', params); expect(platformLocation.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', jasmine.any(String)); const [path, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?'); expect(path).toEqual('a/b/c'); expect(query).toContain('foo=bar'); expect(query).toContain('moo=car'); }); it('should URL encode param values', () => { const params = { query: 'a&b+c d' }; service.setSearch('', params); const [, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?'); expect(query).toContain('query=a%26b%2Bc%20d'); }); it('should URL encode param keys', () => { const params = { 'a&b+c d': 'value' }; service.setSearch('', params); const [, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?'); expect(query).toContain('a%26b%2Bc%20d=value'); }); }); describe('handleAnchorClick', () => { let anchor: HTMLAnchorElement; beforeEach(() => { anchor = document.createElement('a'); spyOn(service, 'go'); }); describe('should try to navigate with go() when anchor clicked for', () => { it('relative local url', () => { anchor.href = 'some/local/url'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url'); expect(result).toBe(false); }); it('absolute local url', () => { anchor.href = '/some/local/url'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url'); expect(result).toBe(false); }); it('local url with query params', () => { anchor.href = 'some/local/url?query=xxx&other=yyy'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url?query=xxx&other=yyy'); expect(result).toBe(false); }); it('local url with hash fragment', () => { anchor.href = 'some/local/url#somefragment'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url#somefragment'); expect(result).toBe(false); }); it('local url with query params and hash fragment', () => { anchor.href = 'some/local/url?query=xxx&other=yyy#somefragment'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url?query=xxx&other=yyy#somefragment'); expect(result).toBe(false); }); it('local url with period in a path segment but no extension', () => { anchor.href = 'tut.or.ial/toh-p2'; const result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalled(); expect(result).toBe(false); }); }); describe('should let browser handle anchor click when', () => { it('url is external to the site', () => { anchor.href = 'http://other.com/some/local/url?query=xxx&other=yyy#somefragment'; let result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); anchor.href = 'some/local/url.pdf'; anchor.protocol = 'ftp'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('mouse button is not zero (middle or right)', () => { anchor.href = 'some/local/url'; const result = service.handleAnchorClick(anchor, 1, false, false); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('ctrl key is pressed', () => { anchor.href = 'some/local/url'; const result = service.handleAnchorClick(anchor, 0, true, false); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('meta key is pressed', () => { anchor.href = 'some/local/url'; const result = service.handleAnchorClick(anchor, 0, false, true); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('anchor has (non-_self) target', () => { anchor.href = 'some/local/url'; anchor.target = '_blank'; let result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); anchor.target = '_parent'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); anchor.target = '_top'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); anchor.target = 'other-frame'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); anchor.target = '_self'; result = service.handleAnchorClick(anchor); expect(service.go).toHaveBeenCalledWith('/some/local/url'); expect(result).toBe(false); }); it('zip url', () => { anchor.href = 'tutorial/toh-p2.zip'; const result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('image or media url', () => { anchor.href = 'cat-photo.png'; let result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true, 'png'); anchor.href = 'cat-photo.gif'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true, 'gif'); anchor.href = 'cat-photo.jpg'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true, 'jpg'); anchor.href = 'dog-bark.mp3'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true, 'mp3'); anchor.href = 'pet-tricks.mp4'; result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true, 'mp4'); }); it('url has any extension', () => { anchor.href = 'tutorial/toh-p2.html'; const result = service.handleAnchorClick(anchor); expect(service.go).not.toHaveBeenCalled(); expect(result).toBe(true); }); }); }); describe('google analytics - GaService#locationChanged', () => { let gaLocationChanged: jasmine.Spy; beforeEach(() => { const gaService = injector.get(GaService); gaLocationChanged = gaService.locationChanged; // execute currentPath observable so that gaLocationChanged is called service.currentPath.subscribe(); }); it('should call locationChanged with initial URL', () => { const initialUrl = location.path().replace(/^\/+/, ''); // strip leading slashes expect(gaLocationChanged.calls.count()).toBe(1, 'gaService.locationChanged'); const args = gaLocationChanged.calls.first().args; expect(args[0]).toBe(initialUrl); }); it('should call locationChanged when `go` to a page', () => { service.go('some-new-url'); expect(gaLocationChanged.calls.count()).toBe(2, 'gaService.locationChanged'); const args = gaLocationChanged.calls.argsFor(1); expect(args[0]).toBe('some-new-url'); }); it('should call locationChanged with url stripped of hash or query', () => { // Important to keep GA service from sending tracking event when the doc hasn't changed // e.g., when the user navigates within the page via # fragments. service.go('some-new-url#one'); service.go('some-new-url#two'); service.go('some-new-url/?foo="true"'); expect(gaLocationChanged.calls.count()).toBe(4, 'gaService.locationChanged called'); const args = gaLocationChanged.calls.allArgs(); expect(args[1]).toEqual(args[2], 'same url for hash calls'); expect(args[1]).toEqual(args[3], 'same url for query string call'); }); it('should call locationChanged when window history changes', () => { location.simulatePopState('/next-url'); expect(gaLocationChanged.calls.count()).toBe(2, 'gaService.locationChanged'); const args = gaLocationChanged.calls.argsFor(1); expect(args[0]).toBe('next-url'); }); }); }); /// Test Helpers /// class MockPlatformLocation { pathname = 'a/b/c'; replaceState = jasmine.createSpy('PlatformLocation.replaceState'); } class MockSwUpdatesService { updateActivated = new Subject(); } class TestGaService { locationChanged = jasmine.createSpy('locationChanged'); } ================================================ FILE: apps/rxjs.dev/src/app/shared/location.service.ts ================================================ import { Injectable } from '@angular/core'; import { Location, PlatformLocation } from '@angular/common'; import { ReplaySubject } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { GaService } from 'app/shared/ga.service'; import { SwUpdatesService } from 'app/sw-updates/sw-updates.service'; @Injectable() export class LocationService { private readonly urlParser = document.createElement('a'); private urlSubject = new ReplaySubject(1); private swUpdateActivated = false; currentUrl = this.urlSubject .pipe(map(url => this.stripSlashes(url))); currentPath = this.currentUrl.pipe( map(url => (url.match(/[^?#]*/) || [])[0]), // strip query and hash tap(path => this.gaService.locationChanged(path)), ); constructor( private gaService: GaService, private location: Location, private platformLocation: PlatformLocation, swUpdates: SwUpdatesService) { this.urlSubject.next(location.path(true)); this.location.subscribe(state => this.urlSubject.next(state.url || '')); swUpdates.updateActivated.subscribe(() => this.swUpdateActivated = true); } // TODO: ignore if url-without-hash-or-search matches current location? go(url: string|null|undefined) { if (!url) { return; } url = this.stripSlashes(url); if (/^http/.test(url) || this.swUpdateActivated) { // Has http protocol so leave the site // (or do a "full page navigation" if a ServiceWorker update has been activated) this.goExternal(url); } else { this.location.go(url); this.urlSubject.next(url); } } goExternal(url: string) { window.location.assign(url); } replace(url: string) { window.location.replace(url); } private stripSlashes(url: string) { return url.replace(/^\/+/, '').replace(/\/+(\?|#|$)/, '$1'); } search() { const search: { [index: string]: string|undefined } = {}; const path = this.location.path(); const q = path.indexOf('?'); if (q > -1) { try { const params = path.substr(q + 1).split('&'); params.forEach(p => { const pair = p.split('='); if (pair[0]) { search[decodeURIComponent(pair[0])] = pair[1] && decodeURIComponent(pair[1]); } }); } catch (e) { /* don't care */ } } return search; } setSearch(label: string, params: { [key: string]: string|undefined}) { const search = Object.keys(params).reduce((acc, key) => { const value = params[key]; return (value === undefined) ? acc : acc += (acc ? '&' : '?') + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }, ''); this.platformLocation.replaceState({}, label, this.platformLocation.pathname + search); } /** * Handle user's anchor click * * @param anchor {HTMLAnchorElement} - the anchor element clicked * @param button Number of the mouse button held down. 0 means left or none * @param ctrlKey True if control key held down * @param metaKey True if command or window key held down * @return false if service navigated with `go()`; true if browser should handle it. * * Since we are using `LocationService` to navigate between docs, without the browser * reloading the page, we must intercept clicks on links. * If the link is to a document that we will render, then we navigate using `Location.go()` * and tell the browser not to handle the event. * * In most apps you might do this in a `LinkDirective` attached to anchors but in this app * we have a special situation where the `DocViewerComponent` is displaying semi-static * content that cannot contain directives. So all the links in that content would not be * able to use such a `LinkDirective`. Instead we are adding a click handler to the * `AppComponent`, whose element contains all the of the application and so captures all * link clicks both inside and outside the `DocViewerComponent`. */ handleAnchorClick(anchor: HTMLAnchorElement, button = 0, ctrlKey = false, metaKey = false) { // Check for modifier keys and non-left-button, which indicate the user wants to control navigation if (button !== 0 || ctrlKey || metaKey) { return true; } // If there is a target and it is not `_self` then we take this // as a signal that it doesn't want to be intercepted. // TODO: should we also allow an explicit `_self` target to opt-out? const anchorTarget = anchor.target; if (anchorTarget && anchorTarget !== '_self') { return true; } if (anchor.getAttribute('download') != null) { return true; // let the download happen } const { pathname, search, hash } = anchor; const relativeUrl = pathname + search + hash; this.urlParser.href = relativeUrl; // don't navigate if external link or has extension if ( anchor.href !== this.urlParser.href || !/\/[^/.]*$/.test(pathname) ) { return true; } // approved for navigation this.go(relativeUrl); return false; } } ================================================ FILE: apps/rxjs.dev/src/app/shared/logger.service.spec.ts ================================================ import { ErrorHandler, ReflectiveInjector } from '@angular/core'; import { Logger } from './logger.service'; describe('logger service', () => { let logSpy: jasmine.Spy; let warnSpy: jasmine.Spy; let logger: Logger; let errorHandler: ErrorHandler; beforeEach(() => { logSpy = spyOn(console, 'log'); warnSpy = spyOn(console, 'warn'); const injector = ReflectiveInjector.resolveAndCreate([ Logger, { provide: ErrorHandler, useClass: MockErrorHandler } ]); logger = injector.get(Logger); errorHandler = injector.get(ErrorHandler); }); describe('log', () => { it('should delegate to console.log', () => { logger.log('param1', 'param2', 'param3'); expect(logSpy).toHaveBeenCalledWith('param1', 'param2', 'param3'); }); }); describe('warn', () => { it('should delegate to console.warn', () => { logger.warn('param1', 'param2', 'param3'); expect(warnSpy).toHaveBeenCalledWith('param1', 'param2', 'param3'); }); }); describe('error', () => { it('should delegate to ErrorHandler', () => { const err = new Error('some error message'); logger.error(err); expect(errorHandler.handleError).toHaveBeenCalledWith(err); }); }); }); class MockErrorHandler implements ErrorHandler { handleError = jasmine.createSpy('handleError'); } ================================================ FILE: apps/rxjs.dev/src/app/shared/logger.service.ts ================================================ import { ErrorHandler, Injectable } from '@angular/core'; import { environment } from '../../environments/environment'; @Injectable() export class Logger { constructor(private errorHandler: ErrorHandler) {} log(value: any, ...rest: any[]) { if (!environment.production) { console.log(value, ...rest); } } error(error: Error) { this.errorHandler.handleError(error); } warn(value: any, ...rest: any[]) { console.warn(value, ...rest); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/reporting-error-handler.spec.ts ================================================ import { ErrorHandler, ReflectiveInjector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { WindowToken } from 'app/shared/window'; import { AppModule } from 'app/app.module'; import { ReportingErrorHandler } from './reporting-error-handler'; describe('ReportingErrorHandler service', () => { let handler: ReportingErrorHandler; let superHandler: jasmine.Spy; let onerrorSpy: jasmine.Spy; beforeEach(() => { onerrorSpy = jasmine.createSpy('onerror'); superHandler = spyOn(ErrorHandler.prototype, 'handleError'); const injector = ReflectiveInjector.resolveAndCreate([ { provide: ErrorHandler, useClass: ReportingErrorHandler }, { provide: WindowToken, useFactory: () => ({ onerror: onerrorSpy }) } ]); handler = injector.get(ErrorHandler); }); it('should be registered on the AppModule', () => { handler = TestBed.configureTestingModule({ imports: [AppModule] }).get(ErrorHandler); expect(handler).toEqual(jasmine.any(ReportingErrorHandler)); }); describe('handleError', () => { it('should call the super class handleError', () => { const error = new Error(); handler.handleError(error); expect(superHandler).toHaveBeenCalledWith(error); }); it('should cope with the super handler throwing an error', () => { const error = new Error('initial error'); superHandler.and.throwError('super handler error'); handler.handleError(error); expect(onerrorSpy).toHaveBeenCalledTimes(2); // Error from super handler is reported first expect(onerrorSpy.calls.argsFor(0)[0]).toEqual('super handler error'); expect(onerrorSpy.calls.argsFor(0)[4]).toEqual(jasmine.any(Error)); // Then error from initial exception expect(onerrorSpy.calls.argsFor(1)[0]).toEqual('initial error'); expect(onerrorSpy.calls.argsFor(1)[4]).toEqual(error); }); it('should send an error object to window.onerror', () => { const error = new Error('this is an error message'); handler.handleError(error); expect(onerrorSpy).toHaveBeenCalledWith(error.message, undefined, undefined, undefined, error); }); it('should send an error string to window.onerror', () => { const error = 'this is an error message'; handler.handleError(error); expect(onerrorSpy).toHaveBeenCalledWith(error); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/reporting-error-handler.ts ================================================ import { ErrorHandler, Inject, Injectable } from '@angular/core'; import { WindowToken } from './window'; /** * Extend the default error handling to report errors to an external service - e.g Google Analytics. * * Errors outside the Angular application may also be handled by `window.onerror`. */ @Injectable() export class ReportingErrorHandler extends ErrorHandler { constructor(@Inject(WindowToken) private window: Window) { super(); } /** * Send error info to Google Analytics, in addition to the default handling. * * @param error Information about the error. */ override handleError(error: string | Error) { try { super.handleError(error); } catch (e) { this.reportError(e); } this.reportError(error); } private reportError(error: unknown) { if (this.window.onerror) { if (error instanceof Error) { this.window.onerror(error.message, undefined, undefined, undefined, error); } else { if (typeof error === 'object') { try { error = JSON.stringify(error); } catch { // Ignore the error and just let it be stringified. } } this.window.onerror(`${error}`); } } } } ================================================ FILE: apps/rxjs.dev/src/app/shared/scroll-spy.service.spec.ts ================================================ import { Injector, ReflectiveInjector } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { DOCUMENT } from '@angular/common'; import { ScrollService } from 'app/shared/scroll.service'; import { ScrollItem, ScrollSpiedElement, ScrollSpiedElementGroup, ScrollSpyService } from 'app/shared/scroll-spy.service'; describe('ScrollSpiedElement', () => { it('should expose the spied element and index', () => { const elem = {} as Element; const spiedElem = new ScrollSpiedElement(elem, 42); expect(spiedElem.element).toBe(elem); expect(spiedElem.index).toBe(42); }); describe('#calculateTop()', () => { it('should calculate the `top` value', () => { const elem = { getBoundingClientRect: () => ({ top: 100 }) } as Element; const spiedElem = new ScrollSpiedElement(elem, 42); spiedElem.calculateTop(0, 0); expect(spiedElem.top).toBe(100); spiedElem.calculateTop(20, 0); expect(spiedElem.top).toBe(120); spiedElem.calculateTop(0, 10); expect(spiedElem.top).toBe(90); spiedElem.calculateTop(20, 10); expect(spiedElem.top).toBe(110); }); }); }); describe('ScrollSpiedElementGroup', () => { describe('#calibrate()', () => { it('should calculate `top` for all spied elements', () => { const spy = spyOn(ScrollSpiedElement.prototype, 'calculateTop'); const elems = [{}, {}, {}] as Element[]; const group = new ScrollSpiedElementGroup(elems); expect(spy).not.toHaveBeenCalled(); group.calibrate(20, 10); const callInfo = spy.calls.all(); expect(spy).toHaveBeenCalledTimes(3); expect(callInfo[0].object.index).toBe(0); expect(callInfo[1].object.index).toBe(1); expect(callInfo[2].object.index).toBe(2); expect(callInfo[0].args).toEqual([20, 10]); expect(callInfo[1].args).toEqual([20, 10]); expect(callInfo[2].args).toEqual([20, 10]); }); }); describe('#onScroll()', () => { let group: ScrollSpiedElementGroup; let activeItems: (ScrollItem | null)[]; const activeIndices = () => activeItems.map((x) => x && x.index); beforeEach(() => { const tops = [50, 150, 100]; spyOn(ScrollSpiedElement.prototype, 'calculateTop').and.callFake(function( this: ScrollSpiedElement, scrollTop: number, topOffset: number ) { this.top = tops[this.index]; }); activeItems = []; group = new ScrollSpiedElementGroup([{}, {}, {}] as Element[]); group.activeScrollItem.subscribe((item) => activeItems.push(item)); group.calibrate(20, 10); }); it('should emit a `ScrollItem` on `activeScrollItem`', () => { expect(activeItems.length).toBe(0); group.onScroll(20, 140); expect(activeItems.length).toBe(1); group.onScroll(20, 140); expect(activeItems.length).toBe(2); }); it('should emit the lower-most element that is above `scrollTop`', () => { group.onScroll(45, 200); group.onScroll(55, 200); expect(activeIndices()).toEqual([null, 0]); activeItems.length = 0; group.onScroll(95, 200); group.onScroll(105, 200); expect(activeIndices()).toEqual([0, 2]); activeItems.length = 0; group.onScroll(145, 200); group.onScroll(155, 200); expect(activeIndices()).toEqual([2, 1]); activeItems.length = 0; group.onScroll(75, 200); group.onScroll(175, 200); group.onScroll(125, 200); group.onScroll(25, 200); expect(activeIndices()).toEqual([0, 1, 2, null]); }); it('should always emit the lower-most element if scrolled to the bottom', () => { group.onScroll(140, 140); group.onScroll(145, 140); group.onScroll(138.5, 140); group.onScroll(139.5, 140); expect(activeIndices()).toEqual([1, 1, 2, 1]); }); it('should emit null if all elements are below `scrollTop`', () => { group.onScroll(0, 140); expect(activeItems).toEqual([null]); group.onScroll(49, 140); expect(activeItems).toEqual([null, null]); }); it('should emit null if there are no spied elements (even if scrolled to the bottom)', () => { group = new ScrollSpiedElementGroup([]); group.activeScrollItem.subscribe((item) => activeItems.push(item)); group.onScroll(20, 140); expect(activeItems).toEqual([null]); group.onScroll(140, 140); expect(activeItems).toEqual([null, null]); group.onScroll(145, 140); expect(activeItems).toEqual([null, null, null]); }); }); }); describe('ScrollSpyService', () => { let injector: Injector; let scrollSpyService: ScrollSpyService; beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ { provide: DOCUMENT, useValue: { body: {} } }, { provide: ScrollService, useValue: { topOffset: 50 } }, ScrollSpyService, ]); scrollSpyService = injector.get(ScrollSpyService); }); describe('#spyOn()', () => { let getSpiedElemGroups: () => ScrollSpiedElementGroup[]; beforeEach(() => { getSpiedElemGroups = () => (scrollSpyService as any).spiedElementGroups; }); it('should create a `ScrollSpiedElementGroup` when called', () => { expect(getSpiedElemGroups().length).toBe(0); scrollSpyService.spyOn([]); expect(getSpiedElemGroups().length).toBe(1); }); it('should initialize the newly created `ScrollSpiedElementGroup`', () => { const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate'); const onScrollSpy = spyOn(ScrollSpiedElementGroup.prototype, 'onScroll'); scrollSpyService.spyOn([]); expect(calibrateSpy).toHaveBeenCalledTimes(1); expect(onScrollSpy).toHaveBeenCalledTimes(1); scrollSpyService.spyOn([]); expect(calibrateSpy).toHaveBeenCalledTimes(2); expect(onScrollSpy).toHaveBeenCalledTimes(2); }); it('should call `onResize()` if it is the first `ScrollSpiedElementGroup`', () => { const actions: string[] = []; const onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize').and.callFake(() => actions.push('onResize')); const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate').and.callFake(() => actions.push('calibrate')); expect(onResizeSpy).not.toHaveBeenCalled(); expect(calibrateSpy).not.toHaveBeenCalled(); scrollSpyService.spyOn([]); expect(actions).toEqual(['onResize', 'calibrate']); scrollSpyService.spyOn([]); expect(actions).toEqual(['onResize', 'calibrate', 'calibrate']); }); it('should forward `ScrollSpiedElementGroup#activeScrollItem` as `active`', () => { const activeIndices1: (number | null)[] = []; const activeIndices2: (number | null)[] = []; const info1 = scrollSpyService.spyOn([]); const info2 = scrollSpyService.spyOn([]); const spiedElemGroups = getSpiedElemGroups(); info1.active.subscribe((item) => activeIndices1.push(item && item.index)); info2.active.subscribe((item) => activeIndices2.push(item && item.index)); activeIndices1.length = 0; activeIndices2.length = 0; spiedElemGroups[0].activeScrollItem.next({ index: 1 } as ScrollItem); spiedElemGroups[0].activeScrollItem.next({ index: 2 } as ScrollItem); spiedElemGroups[1].activeScrollItem.next({ index: 3 } as ScrollItem); spiedElemGroups[0].activeScrollItem.next(null); spiedElemGroups[1].activeScrollItem.next({ index: 4 } as ScrollItem); spiedElemGroups[1].activeScrollItem.next(null); spiedElemGroups[0].activeScrollItem.next({ index: 5 } as ScrollItem); spiedElemGroups[1].activeScrollItem.next({ index: 6 } as ScrollItem); expect(activeIndices1).toEqual([1, 2, null, 5]); expect(activeIndices2).toEqual([3, 4, null, 6]); }); it('should remember and emit the last active item to new subscribers', () => { const items = [{ index: 1 }, { index: 2 }, { index: 3 }] as ScrollItem[]; let lastActiveItem: ScrollItem | null; const info = scrollSpyService.spyOn([]); const spiedElemGroup = getSpiedElemGroups()[0]; spiedElemGroup.activeScrollItem.next(items[0]); spiedElemGroup.activeScrollItem.next(items[1]); spiedElemGroup.activeScrollItem.next(items[2]); spiedElemGroup.activeScrollItem.next(null); spiedElemGroup.activeScrollItem.next(items[1]); info.active.subscribe((item) => (lastActiveItem = item)); expect(lastActiveItem!).toBe(items[1]); spiedElemGroup.activeScrollItem.next(null); info.active.subscribe((item) => (lastActiveItem = item)); expect(lastActiveItem!).toBeNull(); }); it('should only emit distinct values on `active`', () => { const items = [{ index: 1 }, { index: 2 }] as ScrollItem[]; const activeIndices: (number | null)[] = []; const info = scrollSpyService.spyOn([]); const spiedElemGroup = getSpiedElemGroups()[0]; info.active.subscribe((item) => activeIndices.push(item && item.index)); activeIndices.length = 0; spiedElemGroup.activeScrollItem.next(items[0]); spiedElemGroup.activeScrollItem.next(items[0]); spiedElemGroup.activeScrollItem.next(items[1]); spiedElemGroup.activeScrollItem.next(items[1]); spiedElemGroup.activeScrollItem.next(null); spiedElemGroup.activeScrollItem.next(null); spiedElemGroup.activeScrollItem.next(items[0]); spiedElemGroup.activeScrollItem.next(items[1]); spiedElemGroup.activeScrollItem.next(null); expect(activeIndices).toEqual([1, 2, null, 1, 2, null]); }); it('should remove the corresponding `ScrollSpiedElementGroup` when calling `unspy()`', () => { const info1 = scrollSpyService.spyOn([]); const info2 = scrollSpyService.spyOn([]); const info3 = scrollSpyService.spyOn([]); const groups = getSpiedElemGroups().slice(); expect(getSpiedElemGroups()).toEqual(groups); info2.unspy(); expect(getSpiedElemGroups()).toEqual([groups[0], groups[2]]); info1.unspy(); expect(getSpiedElemGroups()).toEqual([groups[2]]); info3.unspy(); expect(getSpiedElemGroups()).toEqual([]); }); }); describe('window resize events', () => { const RESIZE_EVENT_DELAY = 300; let onResizeSpy: jasmine.Spy; beforeEach(() => { onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize'); }); it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => { window.dispatchEvent(new Event('resize')); expect(onResizeSpy).not.toHaveBeenCalled(); scrollSpyService.spyOn([]); onResizeSpy.calls.reset(); window.dispatchEvent(new Event('resize')); expect(onResizeSpy).not.toHaveBeenCalled(); tick(RESIZE_EVENT_DELAY); expect(onResizeSpy).toHaveBeenCalled(); })); it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => { const info1 = scrollSpyService.spyOn([]); const info2 = scrollSpyService.spyOn([]); onResizeSpy.calls.reset(); window.dispatchEvent(new Event('resize')); tick(RESIZE_EVENT_DELAY); expect(onResizeSpy).toHaveBeenCalled(); info1.unspy(); onResizeSpy.calls.reset(); window.dispatchEvent(new Event('resize')); tick(RESIZE_EVENT_DELAY); expect(onResizeSpy).toHaveBeenCalled(); info2.unspy(); onResizeSpy.calls.reset(); window.dispatchEvent(new Event('resize')); tick(RESIZE_EVENT_DELAY); expect(onResizeSpy).not.toHaveBeenCalled(); })); it(`should only fire every ${RESIZE_EVENT_DELAY}ms`, fakeAsync(() => { scrollSpyService.spyOn([]); onResizeSpy.calls.reset(); window.dispatchEvent(new Event('resize')); tick(RESIZE_EVENT_DELAY - 2); expect(onResizeSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('resize')); tick(1); expect(onResizeSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('resize')); tick(1); expect(onResizeSpy).toHaveBeenCalledTimes(1); onResizeSpy.calls.reset(); tick(RESIZE_EVENT_DELAY / 2); window.dispatchEvent(new Event('resize')); tick(RESIZE_EVENT_DELAY - 2); expect(onResizeSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('resize')); tick(1); expect(onResizeSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('resize')); tick(1); expect(onResizeSpy).toHaveBeenCalledTimes(1); })); }); describe('window scroll events', () => { const SCROLL_EVENT_DELAY = 10; let onScrollSpy: jasmine.Spy; beforeEach(() => { onScrollSpy = spyOn(ScrollSpyService.prototype as any, 'onScroll'); }); it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => { window.dispatchEvent(new Event('scroll')); expect(onScrollSpy).not.toHaveBeenCalled(); scrollSpyService.spyOn([]); window.dispatchEvent(new Event('scroll')); expect(onScrollSpy).not.toHaveBeenCalled(); tick(SCROLL_EVENT_DELAY); expect(onScrollSpy).toHaveBeenCalled(); })); it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => { const info1 = scrollSpyService.spyOn([]); const info2 = scrollSpyService.spyOn([]); window.dispatchEvent(new Event('scroll')); tick(SCROLL_EVENT_DELAY); expect(onScrollSpy).toHaveBeenCalled(); info1.unspy(); onScrollSpy.calls.reset(); window.dispatchEvent(new Event('scroll')); tick(SCROLL_EVENT_DELAY); expect(onScrollSpy).toHaveBeenCalled(); info2.unspy(); onScrollSpy.calls.reset(); window.dispatchEvent(new Event('scroll')); tick(SCROLL_EVENT_DELAY); expect(onScrollSpy).not.toHaveBeenCalled(); })); it(`should only fire every ${SCROLL_EVENT_DELAY}ms`, fakeAsync(() => { scrollSpyService.spyOn([]); window.dispatchEvent(new Event('scroll')); tick(SCROLL_EVENT_DELAY - 2); expect(onScrollSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('scroll')); tick(1); expect(onScrollSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('scroll')); tick(1); expect(onScrollSpy).toHaveBeenCalledTimes(1); onScrollSpy.calls.reset(); tick(SCROLL_EVENT_DELAY / 2); window.dispatchEvent(new Event('scroll')); tick(SCROLL_EVENT_DELAY - 2); expect(onScrollSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('scroll')); tick(1); expect(onScrollSpy).not.toHaveBeenCalled(); window.dispatchEvent(new Event('scroll')); tick(1); expect(onScrollSpy).toHaveBeenCalledTimes(1); })); }); describe('#onResize()', () => { it('should re-calibrate each `ScrollSpiedElementGroup`', () => { scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups; const calibrateSpies = spiedElemGroups.map((group) => spyOn(group, 'calibrate')); calibrateSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); (scrollSpyService as any).onResize(); calibrateSpies.forEach((spy) => expect(spy).toHaveBeenCalled()); }); }); describe('#onScroll()', () => { it('should propagate to each `ScrollSpiedElementGroup`', () => { scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups; const onScrollSpies = spiedElemGroups.map((group) => spyOn(group, 'onScroll')); onScrollSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); (scrollSpyService as any).onScroll(); onScrollSpies.forEach((spy) => expect(spy).toHaveBeenCalled()); }); it('should first re-calibrate if the content height has changed', () => { const body = injector.get(DOCUMENT).body as any; scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); scrollSpyService.spyOn([]); const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups; const onScrollSpies = spiedElemGroups.map((group) => spyOn(group, 'onScroll')); const calibrateSpies = spiedElemGroups.map((group, i) => spyOn(group, 'calibrate').and.callFake(() => expect(onScrollSpies[i]).not.toHaveBeenCalled()) ); calibrateSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); onScrollSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); // No content height change... (scrollSpyService as any).onScroll(); calibrateSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); onScrollSpies.forEach((spy) => expect(spy).toHaveBeenCalled()); onScrollSpies.forEach((spy) => spy.calls.reset()); body.scrollHeight = 100; // Viewport changed... (scrollSpyService as any).onScroll(); calibrateSpies.forEach((spy) => expect(spy).toHaveBeenCalled()); onScrollSpies.forEach((spy) => expect(spy).toHaveBeenCalled()); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/scroll-spy.service.ts ================================================ import { Inject, Injectable } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { fromEvent, Observable, ReplaySubject, Subject } from 'rxjs'; import { auditTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { ScrollService } from 'app/shared/scroll.service'; export interface ScrollItem { element: Element; index: number; } export interface ScrollSpyInfo { active: Observable; unspy: () => void; } /* * Represents a "scroll-spied" element. Contains info and methods for determining whether this * element is the active one (i.e. whether it has been scrolled passed), based on the window's * scroll position. * * @prop {Element} element - The element whose position relative to the viewport is tracked. * @prop {number} index - The index of the element in the original list of element (group). * @prop {number} top - The `scrollTop` value at which this element becomes active. */ export class ScrollSpiedElement implements ScrollItem { top = 0; /* * @constructor * @param {Element} element - The element whose position relative to the viewport is tracked. * @param {number} index - The index of the element in the original list of element (group). */ constructor(public readonly element: Element, public readonly index: number) {} /* * @method * Calculate the `top` value, i.e. the value of the `scrollTop` property at which this element * becomes active. The current implementation assumes that window is the scroll-container. * * @param {number} scrollTop - How much is window currently scrolled (vertically). * @param {number} topOffset - The distance from the top at which the element becomes active. */ calculateTop(scrollTop: number, topOffset: number) { this.top = scrollTop + this.element.getBoundingClientRect().top - topOffset; } } /* * Represents a group of "scroll-spied" elements. Contains info and methods for efficiently * determining which element should be considered "active", i.e. which element has been scrolled * passed the top of the viewport. * * @prop {Observable} activeScrollItem - An observable that emits ScrollItem * elements (containing the HTML element and its original index) identifying the latest "active" * element from a list of elements. */ export class ScrollSpiedElementGroup { activeScrollItem: ReplaySubject = new ReplaySubject(1); private spiedElements: ScrollSpiedElement[]; /* * @constructor * @param {Element[]} elements - A list of elements whose position relative to the viewport will * be tracked, in order to determine which one is "active" at any given moment. */ constructor(elements: Element[]) { this.spiedElements = elements.map((elem, i) => new ScrollSpiedElement(elem, i)); } /* * @method * Calculate the `top` value of each ScrollSpiedElement of this group (based on te current * `scrollTop` and `topOffset` values), so that the active element can be later determined just by * comparing its `top` property with the then current `scrollTop`. * * @param {number} scrollTop - How much is window currently scrolled (vertically). * @param {number} topOffset - The distance from the top at which the element becomes active. */ calibrate(scrollTop: number, topOffset: number) { this.spiedElements.forEach(spiedElem => spiedElem.calculateTop(scrollTop, topOffset)); this.spiedElements.sort((a, b) => b.top - a.top); // Sort in descending `top` order. } /* * @method * Determine which element is the currently active one, i.e. the lower-most element that is * scrolled passed the top of the viewport (taking offsets into account) and emit it on * `activeScrollItem`. * If no element can be considered active, `null` is emitted instead. * If window is scrolled all the way to the bottom, then the lower-most element is considered * active even if it not scrolled passed the top of the viewport. * * @param {number} scrollTop - How much is window currently scrolled (vertically). * @param {number} maxScrollTop - The maximum possible `scrollTop` (based on the viewport size). */ onScroll(scrollTop: number, maxScrollTop: number) { let activeItem: ScrollItem|undefined; if (scrollTop + 1 >= maxScrollTop) { activeItem = this.spiedElements[0]; } else { this.spiedElements.some(spiedElem => { if (spiedElem.top <= scrollTop) { activeItem = spiedElem; return true; } return false; }); } this.activeScrollItem.next(activeItem || null); } } @Injectable() export class ScrollSpyService { private spiedElementGroups: ScrollSpiedElementGroup[] = []; private onStopListening = new Subject(); private resizeEvents = fromEvent(window, 'resize').pipe(auditTime(300), takeUntil(this.onStopListening)); private scrollEvents = fromEvent(window, 'scroll').pipe(auditTime(10), takeUntil(this.onStopListening)); private lastContentHeight: number; private lastMaxScrollTop: number; constructor(@Inject(DOCUMENT) private doc: any, private scrollService: ScrollService) {} /* * @method * Start tracking a group of elements and emitting active elements; i.e. elements that are * currently visible in the viewport. If there was no other group being spied, start listening for * `resize` and `scroll` events. * * @param {Element[]} elements - A list of elements to track. * * @return {ScrollSpyInfo} - An object containing the following properties: * - `active`: An observable of distinct ScrollItems. * - `unspy`: A method to stop tracking this group of elements. */ spyOn(elements: Element[]): ScrollSpyInfo { if (!this.spiedElementGroups.length) { this.resizeEvents.subscribe(() => this.onResize()); this.scrollEvents.subscribe(() => this.onScroll()); this.onResize(); } const scrollTop = this.getScrollTop(); const topOffset = this.getTopOffset(); const maxScrollTop = this.lastMaxScrollTop; const spiedGroup = new ScrollSpiedElementGroup(elements); spiedGroup.calibrate(scrollTop, topOffset); spiedGroup.onScroll(scrollTop, maxScrollTop); this.spiedElementGroups.push(spiedGroup); return { active: spiedGroup.activeScrollItem.asObservable().pipe(distinctUntilChanged()), unspy: () => this.unspy(spiedGroup) }; } private getContentHeight() { return this.doc.body.scrollHeight || Number.MAX_SAFE_INTEGER; } private getScrollTop() { return window && window.pageYOffset || 0; } private getTopOffset() { return this.scrollService.topOffset + 50; } private getViewportHeight() { return this.doc.body.clientHeight || 0; } /* * @method * The size of the window has changed. Re-calculate all affected values, * so that active elements can be determined efficiently on scroll. */ private onResize() { const contentHeight = this.getContentHeight(); const viewportHeight = this.getViewportHeight(); const scrollTop = this.getScrollTop(); const topOffset = this.getTopOffset(); this.lastContentHeight = contentHeight; this.lastMaxScrollTop = contentHeight - viewportHeight; this.spiedElementGroups.forEach(group => group.calibrate(scrollTop, topOffset)); } /* * @method * Determine which element for each ScrollSpiedElementGroup is active. If the content height has * changed since last check, re-calculate all affected values first. */ private onScroll() { if (this.lastContentHeight !== this.getContentHeight()) { // Something has caused the scroll height to change. // (E.g. image downloaded, accordion expanded/collapsed etc.) this.onResize(); } const scrollTop = this.getScrollTop(); const maxScrollTop = this.lastMaxScrollTop; this.spiedElementGroups.forEach(group => group.onScroll(scrollTop, maxScrollTop)); } /* * @method * Stop tracking this group of elements and emitting active elements. If there is no other group * being spied, stop listening for `resize` or `scroll` events. * * @param {ScrollSpiedElementGroup} spiedGroup - The group to stop tracking. */ private unspy(spiedGroup: ScrollSpiedElementGroup) { spiedGroup.activeScrollItem.complete(); this.spiedElementGroups = this.spiedElementGroups.filter(group => group !== spiedGroup); if (!this.spiedElementGroups.length) { this.onStopListening.next(null); } } } ================================================ FILE: apps/rxjs.dev/src/app/shared/scroll.service.spec.ts ================================================ import { ReflectiveInjector } from '@angular/core'; import { Location, LocationStrategy, PlatformLocation, ViewportScroller } from '@angular/common'; import { DOCUMENT } from '@angular/common'; import { MockLocationStrategy, SpyLocation } from '@angular/common/testing'; import { fakeAsync, tick } from '@angular/core/testing'; import { ScrollService, topMargin } from './scroll.service'; describe('ScrollService', () => { const topOfPageElem = {} as Element; let injector: ReflectiveInjector; let document: MockDocument; let platformLocation: MockPlatformLocation; let scrollService: ScrollService; let location: SpyLocation; class MockPlatformLocation { hash: string; } class MockDocument { body = new MockElement(); getElementById = jasmine.createSpy('Document getElementById').and.returnValue(topOfPageElem); querySelector = jasmine.createSpy('Document querySelector'); } class MockElement { getBoundingClientRect = jasmine.createSpy('Element getBoundingClientRect') .and.returnValue({top: 0}); scrollIntoView = jasmine.createSpy('Element scrollIntoView'); } const viewportScrollerStub = jasmine.createSpyObj( 'viewportScroller', ['getScrollPosition', 'scrollToPosition']); beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ ScrollService, { provide: Location, useClass: SpyLocation }, { provide: DOCUMENT, useClass: MockDocument }, { provide: PlatformLocation, useClass: MockPlatformLocation }, { provide: ViewportScroller, useValue: viewportScrollerStub }, { provide: LocationStrategy, useClass: MockLocationStrategy } ]); platformLocation = injector.get(PlatformLocation); document = injector.get(DOCUMENT); scrollService = injector.get(ScrollService); location = injector.get(Location); spyOn(window, 'scrollBy'); }); it('should debounce `updateScrollPositonInHistory()`', fakeAsync(() => { const updateScrollPositionInHistorySpy = spyOn(scrollService, 'updateScrollPositionInHistory'); window.dispatchEvent(new Event('scroll')); tick(249); window.dispatchEvent(new Event('scroll')); tick(249); window.dispatchEvent(new Event('scroll')); tick(249); expect(updateScrollPositionInHistorySpy).not.toHaveBeenCalled(); tick(1); expect(updateScrollPositionInHistorySpy).toHaveBeenCalledTimes(1); })); it('should set `scrollRestoration` to `manual` if supported', () => { if (scrollService.supportManualScrollRestoration) { expect(window.history.scrollRestoration).toBe('manual'); } else { expect(window.history.scrollRestoration).toBeUndefined(); } }); describe('#topOffset', () => { it('should query for the top-bar by CSS selector', () => { expect(document.querySelector).not.toHaveBeenCalled(); expect(scrollService.topOffset).toBe(topMargin); expect(document.querySelector).toHaveBeenCalled(); }); it('should be calculated based on the top-bar\'s height + margin', () => { (document.querySelector as jasmine.Spy).and.returnValue({clientHeight: 50}); expect(scrollService.topOffset).toBe(50 + topMargin); }); it('should only query for the top-bar once', () => { expect(scrollService.topOffset).toBe(topMargin); (document.querySelector as jasmine.Spy).calls.reset(); expect(scrollService.topOffset).toBe(topMargin); expect(document.querySelector).not.toHaveBeenCalled(); }); it('should retrieve the top-bar\'s height again after resize', () => { let clientHeight = 50; (document.querySelector as jasmine.Spy).and.callFake(() => ({clientHeight})); expect(scrollService.topOffset).toBe(50 + topMargin); expect(document.querySelector).toHaveBeenCalled(); (document.querySelector as jasmine.Spy).calls.reset(); clientHeight = 100; expect(scrollService.topOffset).toBe(50 + topMargin); expect(document.querySelector).not.toHaveBeenCalled(); window.dispatchEvent(new Event('resize')); expect(scrollService.topOffset).toBe(100 + topMargin); expect(document.querySelector).toHaveBeenCalled(); }); }); describe('#topOfPageElement', () => { it('should query for the top-of-page element by ID', () => { expect(document.getElementById).not.toHaveBeenCalled(); expect(scrollService.topOfPageElement).toBe(topOfPageElem); expect(document.getElementById).toHaveBeenCalled(); }); it('should only query for the top-of-page element once', () => { expect(scrollService.topOfPageElement).toBe(topOfPageElem); (document.getElementById as jasmine.Spy).calls.reset(); expect(scrollService.topOfPageElement).toBe(topOfPageElem); expect(document.getElementById).not.toHaveBeenCalled(); }); it('should return `` if unable to find the top-of-page element', () => { (document.getElementById as jasmine.Spy).and.returnValue(null); expect(scrollService.topOfPageElement).toBe(document.body as any); }); }); describe('#scroll', () => { it('should scroll to the top if there is no hash', () => { platformLocation.hash = ''; const topOfPage = new MockElement(); document.getElementById.and .callFake((id: string) => id === 'top-of-page' ? topOfPage : null); scrollService.scroll(); expect(topOfPage.scrollIntoView).toHaveBeenCalled(); }); it('should not scroll if the hash does not match an element id', () => { platformLocation.hash = 'not-found'; document.getElementById.and.returnValue(null); scrollService.scroll(); expect(document.getElementById).toHaveBeenCalledWith('not-found'); expect(window.scrollBy).not.toHaveBeenCalled(); }); it('should scroll to the element whose id matches the hash', () => { const element = new MockElement(); platformLocation.hash = 'some-id'; document.getElementById.and.returnValue(element); scrollService.scroll(); expect(document.getElementById).toHaveBeenCalledWith('some-id'); expect(element.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalled(); }); it('should scroll to the element whose id matches the hash with encoded characters', () => { const element = new MockElement(); platformLocation.hash = '%F0%9F%91%8D'; // 👍 document.getElementById.and.returnValue(element); scrollService.scroll(); expect(document.getElementById).toHaveBeenCalledWith('👍'); expect(element.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalled(); }); }); describe('#scrollToElement', () => { it('should scroll to element', () => { const element: Element = new MockElement() as any; scrollService.scrollToElement(element); expect(element.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalledWith(0, -scrollService.topOffset); }); it('should not scroll more than necessary (e.g. for elements close to the bottom)', () => { const element: Element = new MockElement() as any; const getBoundingClientRect = element.getBoundingClientRect as jasmine.Spy; const topOffset = scrollService.topOffset; getBoundingClientRect.and.returnValue({top: topOffset + 100}); scrollService.scrollToElement(element); expect(element.scrollIntoView).toHaveBeenCalledTimes(1); expect(window.scrollBy).toHaveBeenCalledWith(0, 100); getBoundingClientRect.and.returnValue({top: topOffset - 10}); scrollService.scrollToElement(element); expect(element.scrollIntoView).toHaveBeenCalledTimes(2); expect(window.scrollBy).toHaveBeenCalledWith(0, -10); }); it('should scroll all the way to the top if close enough', () => { const element: Element = new MockElement() as any; (window as any).pageYOffset = 25; scrollService.scrollToElement(element); expect(element.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalledWith(0, -scrollService.topOffset); (window.scrollBy as jasmine.Spy).calls.reset(); (window as any).pageYOffset = 15; scrollService.scrollToElement(element); expect(element.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalledWith(0, -scrollService.topOffset); expect(window.scrollBy).toHaveBeenCalledWith(0, -15); }); it('should do nothing if no element', () => { scrollService.scrollToElement(null); expect(window.scrollBy).not.toHaveBeenCalled(); }); }); describe('#scrollToTop', () => { it('should scroll to top', () => { const topOfPageElement = new MockElement() as any as Element; document.getElementById.and.callFake( (id: string) => id === 'top-of-page' ? topOfPageElement : null ); scrollService.scrollToTop(); expect(topOfPageElement.scrollIntoView).toHaveBeenCalled(); expect(window.scrollBy).toHaveBeenCalledWith(0, -topMargin); }); }); describe('#isLocationWithHash', () => { it('should return true when the location has a hash', () => { platformLocation.hash = 'anchor'; expect(scrollService.isLocationWithHash()).toBe(true); }); it('should return false when the location has no hash', () => { platformLocation.hash = ''; expect(scrollService.isLocationWithHash()).toBe(false); }); }); describe('#needToFixScrollPosition', async () => { it('should return true when popState event was fired after a back navigation if the browser supports ' + 'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => { if (scrollService.supportManualScrollRestoration) { location.go('/initial-url1'); // We simulate a scroll down location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]}); location.go('/initial-url2'); location.back(); expect(scrollService.poppedStateScrollPosition).toEqual([2000, 0]); expect(scrollService.needToFixScrollPosition()).toBe(true); } else { location.go('/initial-url1'); location.go('/initial-url2'); location.back(); expect(scrollService.poppedStateScrollPosition).toBe(null); expect(scrollService.needToFixScrollPosition()).toBe(false); } }); it('should return true when popState event was fired after a forward navigation if the browser supports ' + 'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => { if (scrollService.supportManualScrollRestoration) { location.go('/initial-url1'); location.go('/initial-url2'); // We simulate a scroll down location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]}); location.back(); scrollService.poppedStateScrollPosition = [0, 0]; location.forward(); expect(scrollService.poppedStateScrollPosition).toEqual([2000, 0]); expect(scrollService.needToFixScrollPosition()).toBe(true); } else { location.go('/initial-url1'); location.go('/initial-url2'); location.back(); location.forward(); expect(scrollService.poppedStateScrollPosition).toBe(null); expect(scrollService.needToFixScrollPosition()).toBe(false); } }); }); describe('#scrollAfterRender', async () => { let scrollSpy: jasmine.Spy; let scrollToTopSpy: jasmine.Spy; let needToFixScrollPositionSpy: jasmine.Spy; let scrollToPosition: jasmine.Spy; let isLocationWithHashSpy: jasmine.Spy; let getStoredScrollPositionSpy: jasmine.Spy; const scrollDelay = 500; beforeEach(() => { scrollSpy = spyOn(scrollService, 'scroll'); scrollToTopSpy = spyOn(scrollService, 'scrollToTop'); scrollToPosition = spyOn(scrollService, 'scrollToPosition'); needToFixScrollPositionSpy = spyOn(scrollService, 'needToFixScrollPosition'); getStoredScrollPositionSpy = spyOn(scrollService, 'getStoredScrollPosition'); isLocationWithHashSpy = spyOn(scrollService, 'isLocationWithHash'); }); it('should call `scroll` when we navigate to a location with anchor', fakeAsync(() => { needToFixScrollPositionSpy.and.returnValue(false); getStoredScrollPositionSpy.and.returnValue(null); isLocationWithHashSpy.and.returnValue(true); scrollService.scrollAfterRender(scrollDelay); expect(scrollSpy).not.toHaveBeenCalled(); tick(scrollDelay); expect(scrollSpy).toHaveBeenCalled(); })); it('should call `scrollToTop` when we navigate to a location without anchor', fakeAsync(() => { needToFixScrollPositionSpy.and.returnValue(false); getStoredScrollPositionSpy.and.returnValue(null); isLocationWithHashSpy.and.returnValue(false); scrollService.scrollAfterRender(scrollDelay); expect(scrollToTopSpy).toHaveBeenCalled(); tick(scrollDelay); expect(scrollSpy).not.toHaveBeenCalled(); })); it('should call `viewportScroller.scrollToPosition` when we reload a page', fakeAsync(() => { getStoredScrollPositionSpy.and.returnValue([0, 1000]); scrollService.scrollAfterRender(scrollDelay); expect(viewportScrollerStub.scrollToPosition).toHaveBeenCalled(); expect(getStoredScrollPositionSpy).toHaveBeenCalled(); })); it('should call `scrollToPosition` after a popState', fakeAsync(() => { needToFixScrollPositionSpy.and.returnValue(true); getStoredScrollPositionSpy.and.returnValue(null); scrollService.scrollAfterRender(scrollDelay); expect(scrollToPosition).toHaveBeenCalled(); tick(scrollDelay); expect(scrollSpy).not.toHaveBeenCalled(); expect(scrollToTopSpy).not.toHaveBeenCalled(); })); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/scroll.service.ts ================================================ import { DOCUMENT, Location, PlatformLocation, PopStateEvent, ViewportScroller } from '@angular/common'; import { Injectable, Inject } from '@angular/core'; import { fromEvent } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; type ScrollPosition = [number, number]; interface ScrollPositionPopStateEvent extends PopStateEvent { // If there is history state, it should always include `scrollPosition`. state?: {scrollPosition: ScrollPosition}; } export const topMargin = 16; /** * A service that scrolls document elements into view */ @Injectable() export class ScrollService { private _topOffset: number | null; private _topOfPageElement: Element; // The scroll position which has to be restored, after a `popstate` event. poppedStateScrollPosition: ScrollPosition | null = null; // Whether the browser supports the necessary features for manual scroll restoration. supportManualScrollRestoration: boolean = !!window && ('scrollTo' in window) && ('scrollX' in window) && ('scrollY' in window) && !!history && ('scrollRestoration' in history); // Offset from the top of the document to bottom of any static elements // at the top (e.g. toolbar) + some margin get topOffset() { if (!this._topOffset) { const toolbar = this.document.querySelector('.app-toolbar'); this._topOffset = (toolbar && toolbar.clientHeight || 0) + topMargin; } return this._topOffset!; } get topOfPageElement() { if (!this._topOfPageElement) { this._topOfPageElement = this.document.getElementById('top-of-page') || this.document.body; } return this._topOfPageElement; } constructor( @Inject(DOCUMENT) private document: any, private platformLocation: PlatformLocation, private viewportScroller: ViewportScroller, private location: Location) { // On resize, the toolbar might change height, so "invalidate" the top offset. fromEvent(window, 'resize').subscribe(() => this._topOffset = null); fromEvent(window, 'scroll') .pipe(debounceTime(250)).subscribe(() => this.updateScrollPositionInHistory()); // Change scroll restoration strategy to `manual` if it's supported if (this.supportManualScrollRestoration) { history.scrollRestoration = 'manual'; // we have to detect forward and back navigation thanks to popState event this.location.subscribe((event: ScrollPositionPopStateEvent) => { // the type is `hashchange` when the fragment identifier of the URL has changed. It allows us to go to position // just before a click on an anchor if (event.type === 'hashchange') { this.scrollToPosition(); } else { // Navigating with the forward/back button, we have to remove the position from the // session storage in order to avoid a race-condition. this.removeStoredScrollPosition(); // The `popstate` event is always triggered by a browser action such as clicking the // forward/back button. It can be followed by a `hashchange` event. this.poppedStateScrollPosition = event.state ? event.state.scrollPosition : null; } }); } } /** * Scroll to the element with id extracted from the current location hash fragment. * Scroll to top if no hash. * Don't scroll if hash not found. */ scroll() { const hash = this.getCurrentHash(); const element: HTMLElement = hash ? this.document.getElementById(hash) : this.topOfPageElement; this.scrollToElement(element); } /** * test if the current location has a hash */ isLocationWithHash(): boolean { return !!this.getCurrentHash(); } /** * When we load a document, we have to scroll to the correct position depending on whether this is a new location, * a back/forward in the history, or a refresh * * @param delay before we scroll to the good position */ scrollAfterRender(delay: number) { // If we do rendering following a refresh, we use the scroll position from the storage. const storedScrollPosition = this.getStoredScrollPosition(); if (storedScrollPosition) { this.viewportScroller.scrollToPosition(storedScrollPosition); } else { if (this.needToFixScrollPosition()) { // The document was reloaded following a `popstate` event (triggered by clicking the // forward/back button), so we manage the scroll position. this.scrollToPosition(); } else { // The document was loaded as a result of one of the following cases: // - Typing the URL in the address bar (direct navigation). // - Clicking on a link. // (If the location contains a hash, we have to wait for async layout.) if (this.isLocationWithHash()) { // Delay scrolling by the specified amount to allow time for async layout to complete. setTimeout(() => this.scroll(), delay); } else { // If the location doesn't contain a hash, we scroll to the top of the page. this.scrollToTop(); } } } } /** * Scroll to the element. * Don't scroll if no element. */ scrollToElement(element: Element|null) { if (element) { element.scrollIntoView(); if (window && window.scrollBy) { // Scroll as much as necessary to align the top of `element` at `topOffset`. // (Usually, `.top` will be 0, except for cases where the element cannot be scrolled all the // way to the top, because the viewport is larger than the height of the content after the // element.) window.scrollBy(0, element.getBoundingClientRect().top - this.topOffset); // If we are very close to the top (<20px), then scroll all the way up. // (This can happen if `element` is at the top of the page, but has a small top-margin.) if (window.pageYOffset < 20) { window.scrollBy(0, -window.pageYOffset); } } } } /** Scroll to the top of the document. */ scrollToTop() { this.scrollToElement(this.topOfPageElement); } scrollToPosition() { if (this.poppedStateScrollPosition) { this.viewportScroller.scrollToPosition(this.poppedStateScrollPosition); this.poppedStateScrollPosition = null; } } /** * Update the state with scroll position into history. */ updateScrollPositionInHistory() { if (this.supportManualScrollRestoration) { const currentScrollPosition = this.viewportScroller.getScrollPosition(); if (currentScrollPosition) { this.location.replaceState(this.location.path(true), undefined, {scrollPosition: currentScrollPosition}); window.sessionStorage.setItem('scrollPosition', currentScrollPosition.join(',')); } } } getStoredScrollPosition(): ScrollPosition | null { const position = window.sessionStorage.getItem('scrollPosition'); if (!position) { return null; } const [x, y] = position.split(','); return [+x, +y]; } removeStoredScrollPosition() { window.sessionStorage.removeItem('scrollPosition'); } /** * Check if the scroll position need to be manually fixed after popState event */ needToFixScrollPosition(): boolean { return this.supportManualScrollRestoration && !!this.poppedStateScrollPosition; } /** * Return the hash fragment from the `PlatformLocation`, minus the leading `#`. */ private getCurrentHash() { return decodeURIComponent(this.platformLocation.hash.replace(/^#/, '')); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/search-results/search-results.component.spec.ts ================================================ import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SearchResult } from 'app/search/interfaces'; import { SearchResultsComponent } from './search-results.component'; describe('SearchResultsComponent', () => { let component: SearchResultsComponent; let fixture: ComponentFixture; /** Get all text from component element */ function getText() { return fixture.debugElement.nativeElement.textContent; } /** Get a full set of test results. "Take" what you need */ function getTestResults(take?: number) { const results: SearchResult[] = [ { path: 'guide/a', title: 'Guide A' }, { path: 'api/d', title: 'API D' }, { path: 'guide/b', title: 'Guide B' }, { path: 'guide/a/c', title: 'Guide A - C' }, { path: 'api/c', title: 'API C' }, ] // fill it out to exceed 10 guide pages .concat('nmlkjihgfe'.split('').map((l) => ({ path: 'guide/' + l, title: 'Guide ' + l }))) // add these empty fields to satisfy interface .map((r) => ({ ...{ keywords: '', titleWords: '', type: '' }, ...r })); return take === undefined ? results : results.slice(0, take); } function compareTitle(l: SearchResult, r: SearchResult) { return l.title?.toUpperCase() > r.title?.toUpperCase() ? 1 : -1; } function setSearchResults(query: string, results: SearchResult[]) { component.searchResults = { query, results }; component.ngOnChanges(); fixture.detectChanges(); } beforeEach(() => { TestBed.configureTestingModule({ declarations: [SearchResultsComponent], }); }); beforeEach(() => { fixture = TestBed.createComponent(SearchResultsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should map the search results into groups based on their containing folder', () => { setSearchResults('', getTestResults(3)); expect(component.searchAreas).toEqual([ { name: 'api', priorityPages: [{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' }], pages: [] }, { name: 'guide', priorityPages: [ { path: 'guide/a', title: 'Guide A', type: '', keywords: '', titleWords: '' }, { path: 'guide/b', title: 'Guide B', type: '', keywords: '', titleWords: '' }, ], pages: [], }, ]); }); it('should special case results that are top level folders', () => { setSearchResults('', [ { path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' }, { path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' }, ]); expect(component.searchAreas).toEqual([ { name: 'tutorial', priorityPages: [ { path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' }, { path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' }, ], pages: [], }, ]); }); it('should put first 5 results for each area into priorityPages', () => { const results = getTestResults(); setSearchResults('', results); expect(component.searchAreas[0].priorityPages).toEqual(results.filter((p) => p.path.startsWith('api')).slice(0, 5)); expect(component.searchAreas[1].priorityPages).toEqual(results.filter((p) => p.path.startsWith('guide')).slice(0, 5)); }); it('should put the nonPriorityPages into the pages array, sorted by title', () => { const results = getTestResults(); setSearchResults('', results); expect(component.searchAreas[0].pages).toEqual([]); expect(component.searchAreas[1].pages).toEqual( results .filter((p) => p.path.startsWith('guide')) .slice(5) .sort(compareTitle) ); }); it('should put a total count in the header of each area of search results', () => { const results = getTestResults(); setSearchResults('', results); fixture.detectChanges(); const headers = fixture.debugElement.queryAll(By.css('h3')); expect(headers.length).toEqual(2); expect(headers[0].nativeElement.textContent).toContain('(2)'); expect(headers[1].nativeElement.textContent).toContain('(13)'); }); it('should put search results with no containing folder into the default area (other)', () => { const results = [{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }]; setSearchResults('', results); expect(component.searchAreas).toEqual([ { name: 'other', priorityPages: [{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }], pages: [] }, ]); }); it('should omit search results with no title', () => { const results = [{ path: 'news', title: '', type: 'marketing', keywords: '', titleWords: '' }]; setSearchResults('something', results); expect(component.searchAreas).toEqual([]); }); it('should display "Searching ..." while waiting for search results', () => { fixture.detectChanges(); expect(getText()).toContain('Searching ...'); }); describe('when a search result anchor is clicked', () => { let searchResult: SearchResult; let selected: SearchResult | null; let anchor: DebugElement; beforeEach(() => { component.resultSelected.subscribe((result: SearchResult) => (selected = result)); selected = null; searchResult = { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }; setSearchResults('something', [searchResult]); fixture.detectChanges(); anchor = fixture.debugElement.query(By.css('a')); expect(selected).toBeNull(); }); it('should emit a "resultSelected" event', () => { anchor.triggerEventHandler('click', { button: 0, ctrlKey: false, metaKey: false }); fixture.detectChanges(); expect(selected).toBe(searchResult); }); it('should not emit an event if mouse button is not zero (middle or right)', () => { anchor.triggerEventHandler('click', { button: 1, ctrlKey: false, metaKey: false }); fixture.detectChanges(); expect(selected).toBeNull(); }); it('should not emit an event if the `ctrl` key is pressed', () => { anchor.triggerEventHandler('click', { button: 0, ctrlKey: true, metaKey: false }); fixture.detectChanges(); expect(selected).toBeNull(); }); it('should not emit an event if the `meta` key is pressed', () => { anchor.triggerEventHandler('click', { button: 0, ctrlKey: false, metaKey: true }); fixture.detectChanges(); expect(selected).toBeNull(); }); }); describe('when no query results', () => { it('should display "not found" message', () => { setSearchResults('something', []); expect(getText()).toContain('No results'); }); }); }); ================================================ FILE: apps/rxjs.dev/src/app/shared/search-results/search-results.component.ts ================================================ import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { SearchResult, SearchResults, SearchArea } from 'app/search/interfaces'; /** * A component to display search results in groups */ @Component({ selector: 'aio-search-results', template: `

Search Results

{{ area.name }} ({{ area.pages.length + area.priorityPages.length }})

  • {{ page.title }}
  • {{ page.title }}

{{ notFoundMessage }}

`, }) export class SearchResultsComponent implements OnChanges { /** * The results to display */ @Input() searchResults: SearchResults | null; /** * Emitted when the user selects a search result */ @Output() resultSelected = new EventEmitter(); readonly defaultArea = 'other'; notFoundMessage = 'Searching ...'; readonly topLevelFolders = ['guide', 'tutorial']; searchAreas: SearchArea[] = []; ngOnChanges() { this.searchAreas = this.searchResults ? this.processSearchResults(this.searchResults) : []; } onResultSelected(page: SearchResult, event: MouseEvent) { // Emit a `resultSelected` event if the result is to be displayed on this page. if (event.button === 0 && !event.ctrlKey && !event.metaKey) { this.resultSelected.emit(page); } } // Map the search results into groups by area private processSearchResults(search: SearchResults) { if (!search) { return []; } this.notFoundMessage = 'No results found.'; const searchAreaMap: { [key: string]: SearchResult[] } = {}; search.results.forEach((result) => { if (!result.title) { return; } // bad data; should fix const areaName = this.computeAreaName(result) || this.defaultArea; const area = (searchAreaMap[areaName] = searchAreaMap[areaName] || []); area.push(result); }); const keys = Object.keys(searchAreaMap).sort((l, r) => (l > r ? 1 : -1)); return keys.map((name) => { let pages: SearchResult[] = searchAreaMap[name]; // Extract the top 5 most relevant results as priorityPages const priorityPages = pages.splice(0, 5); pages = pages.sort(compareResults); return { name, pages, priorityPages }; }); } // Split the search result path and use the top level folder, if there is one, as the area name. private computeAreaName(result: SearchResult) { if (this.topLevelFolders.indexOf(result.path) !== -1) { return result.path; } const [areaName, rest] = result.path.split('/', 2); return rest && areaName; } } function compareResults(l: SearchResult, r: SearchResult) { return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1; } ================================================ FILE: apps/rxjs.dev/src/app/shared/select/select.component.spec.ts ================================================ import { Component, DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SelectComponent, Option } from './select.component'; const options = [ { title: 'Option A', value: 'option-a' }, { title: 'Option B', value: 'option-b' } ]; let host: HostComponent; let fixture: ComponentFixture; let element: DebugElement; describe('SelectComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [ SelectComponent, HostComponent ], }); }); beforeEach(() => { fixture = TestBed.createComponent(HostComponent); host = fixture.componentInstance; element = fixture.debugElement.query(By.directive(SelectComponent)); }); describe('(initially)', () => { it('should show the button and no options', () => { expect(getButton()).toBeDefined(); expect(getOptionContainer()).toEqual(null); }); }); describe('button', () => { it('should display the label if provided', () => { expect(getButton().textContent!.trim()).toEqual(''); host.label = 'Label:'; fixture.detectChanges(); expect(getButton().textContent!.trim()).toEqual('Label:'); }); it('should contain a symbol `` if hasSymbol is true', () => { expect(getButton().querySelector('span')).toEqual(null); host.showSymbol = true; fixture.detectChanges(); const span = getButton().querySelector('span'); expect(span).not.toEqual(null); expect(span!.className).toContain('symbol'); }); it('should display the selected option, if there is one', () => { host.showSymbol = true; host.selected = options[0]; fixture.detectChanges(); expect(getButton().textContent).toContain(options[0].title); expect(getButton().querySelector('span')!.className).toContain(options[0].value); }); it('should toggle the visibility of the options list when clicked', () => { host.options = options; getButton().click(); fixture.detectChanges(); expect(getOptionContainer()).not.toEqual(null); getButton().click(); fixture.detectChanges(); expect(getOptionContainer()).toEqual(null); }); }); describe('options list', () => { beforeEach(() => { host.options = options; host.showSymbol = true; getButton().click(); // ensure the options are visible fixture.detectChanges(); }); it('should show the corresponding title of each option', () => { getOptions().forEach((li, index) => { expect(li.textContent).toContain(options[index].title); }); }); it('should select the option that is clicked', () => { getOptions()[0].click(); fixture.detectChanges(); expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); expect(getButton().textContent).toContain(options[0].title); expect(getButton().querySelector('span')!.className).toContain(options[0].value); }); it('should select the current option when enter is pressed', () => { const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'Enter'}); getOptions()[0].dispatchEvent(e); fixture.detectChanges(); expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); expect(getButton().textContent).toContain(options[0].title); expect(getButton().querySelector('span')!.className).toContain(options[0].value); }); it('should select the current option when space is pressed', () => { const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: ' '}); getOptions()[0].dispatchEvent(e); fixture.detectChanges(); expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 }); expect(getButton().textContent).toContain(options[0].title); expect(getButton().querySelector('span')!.className).toContain(options[0].value); }); it('should hide when an option is clicked', () => { getOptions()[0].click(); fixture.detectChanges(); expect(getOptionContainer()).toEqual(null); }); it('should hide when there is a click that is not on the option list', () => { fixture.nativeElement.click(); fixture.detectChanges(); expect(getOptionContainer()).toEqual(null); }); it('should hide if the escape button is pressed', () => { const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); document.dispatchEvent(e); fixture.detectChanges(); expect(getOptionContainer()).toEqual(null); }); }); }); @Component({ template: ` ` }) class HostComponent { onChange = jasmine.createSpy('onChange'); options: Option[]; selected: Option; label: string; showSymbol: boolean; } function getButton(): HTMLButtonElement { return element.query(By.css('button')).nativeElement; } function getOptionContainer(): HTMLUListElement|null { const de = element.query(By.css('ul')); return de && de.nativeElement; } function getOptions(): HTMLLIElement[] { return element.queryAll(By.css('li')).map(de => de.nativeElement); } ================================================ FILE: apps/rxjs.dev/src/app/shared/select/select.component.ts ================================================ import { Component, ElementRef, EventEmitter, HostListener, Input, Output, OnInit } from '@angular/core'; export interface Option { title: string; value?: any; } @Component({ selector: 'aio-select', template: `
  • {{ option.title }}
`, }) export class SelectComponent implements OnInit { @Input() selected: Option; @Input() options: Option[]; @Output() // eslint-disable-next-line @angular-eslint/no-output-native change = new EventEmitter<{ option: Option, index: number }>(); @Input() showSymbol = false; @Input() label: string; showOptions = false; constructor(private hostElement: ElementRef) {} ngOnInit() { this.label = this.label || ''; } toggleOptions() { this.showOptions = !this.showOptions; } hideOptions() { this.showOptions = false; } select(option: Option, index: number) { this.selected = option; this.change.emit({ option, index }); this.hideOptions(); } @HostListener('document:click', ['$event.target']) onClick(eventTarget: HTMLElement) { // Hide the options if we clicked outside the component if (!this.hostElement.nativeElement.contains(eventTarget)) { this.hideOptions(); } } @HostListener('document:keydown.escape') onKeyDown() { this.hideOptions(); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/shared.module.ts ================================================ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SearchResultsComponent } from './search-results/search-results.component'; import { SelectComponent } from './select/select.component'; @NgModule({ imports: [ CommonModule ], exports: [ SearchResultsComponent, SelectComponent ], declarations: [ SearchResultsComponent, SelectComponent ] }) export class SharedModule {} ================================================ FILE: apps/rxjs.dev/src/app/shared/stackblitz.service.ts ================================================ import StackBlitzkSDK from '@stackblitz/sdk'; import { Injectable } from '@angular/core'; import { Project } from '@stackblitz/sdk'; interface StackBlitzExampleConfig { code: string; language: string; html?: string; dependencies: { [name: string]: string; }; } @Injectable({ providedIn: 'root', }) export class StackblitzService { openProject(config: StackBlitzExampleConfig) { const codeExtension: 'js' | string = { ts: 'ts', typescript: 'ts', }[config.language] || 'js'; const template: Project['template'] = codeExtension === 'ts' ? 'typescript' : 'javascript'; StackBlitzkSDK.openProject( { files: { 'index.html': config.html || '', [`index.${codeExtension}`]: config.code, }, title: 'RxJS example', description: 'RxJS example', template, tags: ['rxjs', 'demo'], dependencies: config.dependencies, settings: { compile: { trigger: 'auto', action: 'refresh', clearConsole: true, }, }, }, { devToolsHeight: 50, } ); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/toc.service.spec.ts ================================================ import { DOCUMENT } from '@angular/common'; import { ReflectiveInjector } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Subject } from 'rxjs'; import { ScrollItem, ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service'; import { TocItem, TocService } from './toc.service'; describe('TocService', () => { let injector: ReflectiveInjector; let scrollSpyService: MockScrollSpyService; let tocService: TocService; let lastTocList: TocItem[]; // call TocService.genToc function callGenToc(html = '', docId = 'fizz/buzz'): HTMLDivElement { const el = document.createElement('div'); el.innerHTML = html; tocService.genToc(el, docId); return el; } beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ { provide: DomSanitizer, useClass: TestDomSanitizer }, { provide: DOCUMENT, useValue: document }, { provide: ScrollSpyService, useClass: MockScrollSpyService }, TocService, ]); scrollSpyService = injector.get(ScrollSpyService); tocService = injector.get(TocService); tocService.tocList.subscribe(tocList => lastTocList = tocList); }); describe('tocList', () => { it('should emit the latest value to new subscribers', () => { const expectedValue1 = createTocItem('Heading A'); const expectedValue2 = createTocItem('Heading B'); let value1: TocItem[]|undefined; let value2: TocItem[]|undefined; tocService.tocList.next([]); tocService.tocList.subscribe(v => value1 = v); expect(value1).toEqual([]); tocService.tocList.next([expectedValue1, expectedValue2]); tocService.tocList.subscribe(v => value2 = v); expect(value2).toEqual([expectedValue1, expectedValue2]); }); it('should emit the same values to all subscribers', () => { const expectedValue1 = createTocItem('Heading A'); const expectedValue2 = createTocItem('Heading B'); const emittedValues: TocItem[][] = []; tocService.tocList.subscribe(v => emittedValues.push(v)); tocService.tocList.subscribe(v => emittedValues.push(v)); tocService.tocList.next([expectedValue1, expectedValue2]); expect(emittedValues).toEqual([ [expectedValue1, expectedValue2], [expectedValue1, expectedValue2] ]); }); }); describe('activeItemIndex', () => { it('should emit the active heading index (or null)', () => { const indices: (number | null)[] = []; tocService.activeItemIndex.subscribe(i => indices.push(i)); callGenToc(); scrollSpyService.$lastInfo.active.next({index: 42} as ScrollItem); scrollSpyService.$lastInfo.active.next({index: 0} as ScrollItem); scrollSpyService.$lastInfo.active.next(null); scrollSpyService.$lastInfo.active.next({index: 7} as ScrollItem); expect(indices).toEqual([null, 42, 0, null, 7]); }); it('should reset the active index (and unspy) when calling `reset()`', () => { const indices: (number | null)[] = []; tocService.activeItemIndex.subscribe(i => indices.push(i)); callGenToc(); const unspy = scrollSpyService.$lastInfo.unspy; scrollSpyService.$lastInfo.active.next({index: 42} as ScrollItem); expect(unspy).not.toHaveBeenCalled(); expect(indices).toEqual([null, 42]); tocService.reset(); expect(unspy).toHaveBeenCalled(); expect(indices).toEqual([null, 42, null]); }); it('should reset the active index (and unspy) when a new `tocList` is requested', () => { const indices: (number | null)[] = []; tocService.activeItemIndex.subscribe(i => indices.push(i)); callGenToc(); const unspy1 = scrollSpyService.$lastInfo.unspy; scrollSpyService.$lastInfo.active.next({index: 1} as ScrollItem); expect(unspy1).not.toHaveBeenCalled(); expect(indices).toEqual([null, 1]); tocService.genToc(); expect(unspy1).toHaveBeenCalled(); expect(indices).toEqual([null, 1, null]); callGenToc(); const unspy2 = scrollSpyService.$lastInfo.unspy; scrollSpyService.$lastInfo.active.next({index: 3} as ScrollItem); expect(unspy2).not.toHaveBeenCalled(); expect(indices).toEqual([null, 1, null, null, 3]); callGenToc(); scrollSpyService.$lastInfo.active.next({index: 4} as ScrollItem); expect(unspy2).toHaveBeenCalled(); expect(indices).toEqual([null, 1, null, null, 3, null, 4]); }); it('should emit the active index for the latest `tocList`', () => { const indices: (number | null)[] = []; tocService.activeItemIndex.subscribe(i => indices.push(i)); callGenToc(); const activeSubject1 = scrollSpyService.$lastInfo.active; activeSubject1.next({index: 1} as ScrollItem); activeSubject1.next({index: 2} as ScrollItem); callGenToc(); const activeSubject2 = scrollSpyService.$lastInfo.active; activeSubject2.next({index: 3} as ScrollItem); activeSubject2.next({index: 4} as ScrollItem); expect(indices).toEqual([null, 1, 2, null, 3, 4]); }); }); describe('should clear tocList', () => { beforeEach(() => { // Start w/ dummy data from previous usage const expectedValue1 = createTocItem('Heading A'); const expectedValue2 = createTocItem('Heading B'); tocService.tocList.next([expectedValue1, expectedValue2]); expect(lastTocList).not.toEqual([]); }); it('when reset()', () => { tocService.reset(); expect(lastTocList).toEqual([]); }); it('when given undefined doc element', () => { tocService.genToc(undefined); expect(lastTocList).toEqual([]); }); it('when given doc element w/ no headings', () => { callGenToc('

This

and

that

'); expect(lastTocList).toEqual([]); }); it('when given doc element w/ headings other than h1, h2 & h3', () => { callGenToc('

and

that
'); expect(lastTocList).toEqual([]); }); it('when given doc element w/ no-toc headings', () => { // tolerates different spellings/casing of the no-toc class callGenToc(`

one

some one

two

some two

three

some three

four

some four

`); expect(lastTocList).toEqual([]); }); }); describe('when given many headings', () => { let docId: string; let docEl: HTMLDivElement; let headings: NodeListOf; beforeEach(() => { docId = 'fizz/buzz'; docEl = callGenToc(`

Fun with TOC

Heading one

h2 toc 0

H2 Two

h2 toc 1

H2 Three

h2 toc 2

H3 3a

h3 toc 3

H3 3b

h3 toc 4

H4 of h3-3b

an h4

H2 4 repeat

h2 toc 5

H2 4 repeat

h2 toc 6

Skippy

Skip this header

H2 6

h2 toc 7

H3 6a

h3 toc 8

`, docId); headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf; }); it('should have tocList with expect number of TocItems', () => { // should ignore h4, and the no-toc h2 expect(lastTocList.length).toEqual(headings.length - 2); }); it('should have href with docId and heading\'s id', () => { const tocItem = lastTocList.find(item => item.title === 'Heading one')!; expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`); }); it('should have level "h1" for an

', () => { const tocItem = lastTocList.find(item => item.title === 'Fun with TOC')!; expect(tocItem.level).toEqual('h1'); }); it('should have level "h2" for an

', () => { const tocItem = lastTocList.find(item => item.title === 'Heading one')!; expect(tocItem.level).toEqual('h2'); }); it('should have level "h3" for an

', () => { const tocItem = lastTocList.find(item => item.title === 'H3 3a')!; expect(tocItem.level).toEqual('h3'); }); it('should have title which is heading\'s textContent ', () => { const heading = headings[3]; const tocItem = lastTocList[3]; expect(heading.textContent).toEqual(tocItem.title); }); it('should have "SafeHtml" content which is heading\'s innerHTML ', () => { const heading = headings[3]; const content = lastTocList[3].content; expect((content as TestSafeHtml).changingThisBreaksApplicationSecurity) .toEqual(heading.innerHTML); }); it('should calculate and set id of heading without an id', () => { const id = headings[2].getAttribute('id'); expect(id).toEqual('h2-two'); }); it('should have href with docId and calculated heading id', () => { const tocItem = lastTocList.find(item => item.title === 'H2 Two')!; expect(tocItem.href).toEqual(`${docId}#h2-two`); }); it('should ignore HTML in heading when calculating id', () => { const id = headings[3].getAttribute('id'); const tocItem = lastTocList[3]; expect(id).toEqual('h2-three', 'heading id'); expect(tocItem.href).toEqual(`${docId}#h2-three`, 'tocItem href'); }); it('should avoid repeating an id when calculating', () => { const tocItems = lastTocList.filter(item => item.title === 'H2 4 repeat'); expect(tocItems[0].href).toEqual(`${docId}#h2-4-repeat`, 'first'); expect(tocItems[1].href).toEqual(`${docId}#h2-4-repeat-2`, 'second'); }); }); describe('TocItem for an h2 with links and extra whitespace', () => { let docId: string; let tocItem: TocItem; beforeEach(() => { docId = 'fizz/buzz/'; // An almost-actual

... with extra whitespace callGenToc(`

Setup to develop locally.

`, docId); tocItem = lastTocList[0]; }); it('should have expected href', () => { expect(tocItem.href).toEqual(`${docId}#setup-to-develop-locally`); }); it('should have expected title', () => { expect(tocItem.title).toEqual('Setup to develop locally.'); }); it('should have removed anchor link from tocItem html content', () => { expect((tocItem.content as TestSafeHtml) .changingThisBreaksApplicationSecurity) .toEqual('Setup to develop locally.'); }); it('should have bypassed HTML sanitizing of heading\'s innerHTML ', () => { const domSanitizer: TestDomSanitizer = injector.get(DomSanitizer); expect(domSanitizer.bypassSecurityTrustHtml) .toHaveBeenCalledWith('Setup to develop locally.'); }); }); }); interface TestSafeHtml extends SafeHtml { changingThisBreaksApplicationSecurity: string; getTypeName: () => string; } class TestDomSanitizer { bypassSecurityTrustHtml = jasmine.createSpy('bypassSecurityTrustHtml') .and.callFake((html: string) => ({ changingThisBreaksApplicationSecurity: html, getTypeName: () => 'HTML', } as TestSafeHtml)); } class MockScrollSpyService { private $$lastInfo: { active: Subject; unspy: jasmine.Spy; } | undefined; get $lastInfo() { if (!this.$$lastInfo) { throw new Error('$lastInfo is not yet defined. You must call `spyOn` first.'); } return this.$$lastInfo; } spyOn(headings: HTMLHeadingElement[]): ScrollSpyInfo { return this.$$lastInfo = { active: new Subject(), unspy: jasmine.createSpy('unspy'), }; } } function createTocItem(title: string, level = 'h2', href = '', content = title) { return { title, href, level, content }; } ================================================ FILE: apps/rxjs.dev/src/app/shared/toc.service.ts ================================================ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ReplaySubject } from 'rxjs'; import { ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service'; export interface TocItem { content: SafeHtml; href: string; isSecondary?: boolean; level: string; title: string; } @Injectable() export class TocService { tocList = new ReplaySubject(1); activeItemIndex = new ReplaySubject(1); private scrollSpyInfo: ScrollSpyInfo | null = null; constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer, private scrollSpyService: ScrollSpyService) {} genToc(docElement?: Element, docId = '') { this.resetScrollSpyInfo(); if (!docElement) { this.tocList.next([]); return; } const headings = this.findTocHeadings(docElement); const idMap = new Map(); const tocList = headings.map((heading) => ({ content: this.extractHeadingSafeHtml(heading), href: `${docId}#${this.getId(heading, idMap)}`, level: heading.tagName.toLowerCase(), title: (heading.textContent || '').trim(), })); this.tocList.next(tocList); this.scrollSpyInfo = this.scrollSpyService.spyOn(headings); this.scrollSpyInfo.active.subscribe((item) => this.activeItemIndex.next(item && item.index)); } reset() { this.resetScrollSpyInfo(); this.tocList.next([]); } // This bad boy exists only to strip off the anchor link attached to a heading private extractHeadingSafeHtml(heading: HTMLHeadingElement) { const div: HTMLDivElement = this.document.createElement('div'); div.innerHTML = heading.innerHTML; const anchorLinks: NodeListOf = div.querySelectorAll('a'); // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < anchorLinks.length; i++) { const anchorLink = anchorLinks[i]; if (!anchorLink.classList.contains('header-link')) { // this is an anchor that contains actual content that we want to keep // move the contents of the anchor into its parent const parent = anchorLink.parentNode!; while (anchorLink.childNodes.length) { parent.insertBefore(anchorLink.childNodes[0], anchorLink); } } // now remove the anchor anchorLink.remove(); } // security: the document element which provides this heading content // is always authored by the documentation team and is considered to be safe return this.domSanitizer.bypassSecurityTrustHtml(div.innerHTML.trim()); } private findTocHeadings(docElement: Element): HTMLHeadingElement[] { const headings = docElement.querySelectorAll('h1,h2,h3'); const skipNoTocHeadings = (heading: HTMLHeadingElement) => !/(?:no-toc|notoc)/i.test(heading.className); return Array.prototype.filter.call(headings, skipNoTocHeadings); } private resetScrollSpyInfo() { if (this.scrollSpyInfo) { this.scrollSpyInfo.unspy(); this.scrollSpyInfo = null; } this.activeItemIndex.next(null); } // Extract the id from the heading; create one if necessary // Is it possible for a heading to lack an id? private getId(h: HTMLHeadingElement, idMap: Map) { let id = h.id; if (id) { addToMap(id); } else { id = (h.textContent || '').trim().toLowerCase().replace(/\W+/g, '-'); id = addToMap(id); h.id = id; } return id; // Map guards against duplicate id creation. function addToMap(key: string) { const oldCount = idMap.get(key) || 0; const count = oldCount + 1; idMap.set(key, count); return count === 1 ? key : `${key}-${count}`; } } } ================================================ FILE: apps/rxjs.dev/src/app/shared/web-worker-message.ts ================================================ export interface WebWorkerMessage { type: string; payload: any; id?: number; } ================================================ FILE: apps/rxjs.dev/src/app/shared/web-worker.ts ================================================ /* Copyright 2016 Google Inc. All Rights Reserved. Use of this source code is governed by an MIT-style license that can be found in the LICENSE file at http://angular.io/license */ import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; import { WebWorkerMessage } from './web-worker-message'; export class WebWorkerClient { private nextId = 0; static create(worker: Worker, zone: NgZone) { return new WebWorkerClient(worker, zone); } private constructor(private worker: Worker, private zone: NgZone) {} sendMessage(type: string, payload?: any): Observable { return new Observable((subscriber) => { const id = this.nextId++; const handleMessage = (response: MessageEvent) => { const { type: responseType, id: responseId, payload: responsePayload } = response.data as WebWorkerMessage; if (type === responseType && id === responseId) { this.zone.run(() => { subscriber.next(responsePayload); subscriber.complete(); }); } }; const handleError = (error: ErrorEvent) => { // Since we do not check type and id any error from the webworker will kill all subscribers this.zone.run(() => subscriber.error(error)); }; // Wire up the event listeners for this message this.worker.addEventListener('message', handleMessage); this.worker.addEventListener('error', handleError); // Post the message to the web worker this.worker.postMessage({ type, id, payload }); // At completion/error unwire the event listeners return () => { this.worker.removeEventListener('message', handleMessage); this.worker.removeEventListener('error', handleError); }; }); } } ================================================ FILE: apps/rxjs.dev/src/app/shared/window.ts ================================================ import { InjectionToken } from '@angular/core'; export const WindowToken = new InjectionToken('Window'); export function windowProvider() { return window; } ================================================ FILE: apps/rxjs.dev/src/app/sw-updates/sw-updates.module.ts ================================================ import { NgModule } from '@angular/core'; import { SwUpdatesService } from './sw-updates.service'; @NgModule({ providers: [ SwUpdatesService ] }) export class SwUpdatesModule {} ================================================ FILE: apps/rxjs.dev/src/app/sw-updates/sw-updates.service.spec.ts ================================================ import { ApplicationRef, ReflectiveInjector } from '@angular/core'; import { discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { SwUpdate } from '@angular/service-worker'; import { Subject } from 'rxjs'; import { Logger } from 'app/shared/logger.service'; import { SwUpdatesService } from './sw-updates.service'; describe('SwUpdatesService', () => { let injector: ReflectiveInjector; let appRef: MockApplicationRef; let service: SwUpdatesService; let swu: MockSwUpdate; let checkInterval: number; // Helpers // NOTE: // Because `SwUpdatesService` uses the `interval` operator, it needs to be instantiated and // destroyed inside the `fakeAsync` zone (when `fakeAsync` is used for the test). Thus, we can't // run `setup()`/`tearDown()` in `beforeEach()`/`afterEach()` blocks. We use the `run()` helper // to call them inside each test's zone. const setup = (isSwUpdateEnabled: boolean) => { injector = ReflectiveInjector.resolveAndCreate([ { provide: ApplicationRef, useClass: MockApplicationRef }, { provide: Logger, useClass: MockLogger }, { provide: SwUpdate, useFactory: () => new MockSwUpdate(isSwUpdateEnabled) }, SwUpdatesService ]); appRef = injector.get(ApplicationRef); service = injector.get(SwUpdatesService); swu = injector.get(SwUpdate); checkInterval = (service as any).checkInterval; }; const finalize = () => service.ngOnDestroy(); const run = (specFn: VoidFunction, isSwUpdateEnabled = true) => () => { setup(isSwUpdateEnabled); specFn(); finalize(); }; it('should create', run(() => { expect(service).toBeTruthy(); })); it('should start checking for updates when instantiated (once the app stabilizes)', run(() => { expect(swu.checkForUpdate).not.toHaveBeenCalled(); appRef.isStable.next(false); expect(swu.checkForUpdate).not.toHaveBeenCalled(); appRef.isStable.next(true); expect(swu.checkForUpdate).toHaveBeenCalled(); })); it('should periodically check for updates', fakeAsync(run(() => { appRef.isStable.next(true); swu.checkForUpdate.calls.reset(); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(1); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(2); appRef.isStable.next(false); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(3); discardPeriodicTasks(); }))); it('should activate available updates immediately', fakeAsync(run(() => { appRef.isStable.next(true); expect(swu.activateUpdate).not.toHaveBeenCalled(); swu.$$availableSubj.next({available: {hash: 'foo'}}); expect(swu.activateUpdate).toHaveBeenCalled(); }))); it('should keep periodically checking for updates even after one is available/activated', fakeAsync(run(() => { appRef.isStable.next(true); swu.checkForUpdate.calls.reset(); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(1); swu.$$availableSubj.next({available: {hash: 'foo'}}); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(2); tick(checkInterval); expect(swu.checkForUpdate).toHaveBeenCalledTimes(3); discardPeriodicTasks(); }))); it('should emit on `updateActivated` when an update has been activated', run(() => { const activatedVersions: (string|undefined)[] = []; service.updateActivated.subscribe(v => activatedVersions.push(v)); swu.$$availableSubj.next({available: {hash: 'foo'}}); swu.$$activatedSubj.next({current: {hash: 'bar'}}); swu.$$availableSubj.next({available: {hash: 'baz'}}); swu.$$activatedSubj.next({current: {hash: 'qux'}}); expect(activatedVersions).toEqual(['bar', 'qux']); })); describe('when `SwUpdate` is not enabled', () => { const runDeactivated = (specFn: VoidFunction) => run(specFn, false); it('should not check for updates', fakeAsync(runDeactivated(() => { appRef.isStable.next(true); tick(checkInterval); tick(checkInterval); swu.$$availableSubj.next({available: {hash: 'foo'}}); swu.$$activatedSubj.next({current: {hash: 'bar'}}); tick(checkInterval); tick(checkInterval); expect(swu.checkForUpdate).not.toHaveBeenCalled(); }))); it('should not activate available updates', fakeAsync(runDeactivated(() => { swu.$$availableSubj.next({available: {hash: 'foo'}}); expect(swu.activateUpdate).not.toHaveBeenCalled(); }))); it('should never emit on `updateActivated`', runDeactivated(() => { const activatedVersions: (string|undefined)[] = []; service.updateActivated.subscribe(v => activatedVersions.push(v)); swu.$$availableSubj.next({available: {hash: 'foo'}}); swu.$$activatedSubj.next({current: {hash: 'bar'}}); swu.$$availableSubj.next({available: {hash: 'baz'}}); swu.$$activatedSubj.next({current: {hash: 'qux'}}); expect(activatedVersions).toEqual([]); })); }); describe('when destroyed', () => { it('should not schedule a new check for update (after current check)', fakeAsync(run(() => { appRef.isStable.next(true); expect(swu.checkForUpdate).toHaveBeenCalled(); service.ngOnDestroy(); swu.checkForUpdate.calls.reset(); tick(checkInterval); tick(checkInterval); expect(swu.checkForUpdate).not.toHaveBeenCalled(); }))); it('should not schedule a new check for update (after activating an update)', fakeAsync(run(() => { appRef.isStable.next(true); expect(swu.checkForUpdate).toHaveBeenCalled(); service.ngOnDestroy(); swu.checkForUpdate.calls.reset(); swu.$$availableSubj.next({available: {hash: 'foo'}}); swu.$$activatedSubj.next({current: {hash: 'baz'}}); tick(checkInterval); tick(checkInterval); expect(swu.checkForUpdate).not.toHaveBeenCalled(); }))); it('should not activate available updates', fakeAsync(run(() => { service.ngOnDestroy(); swu.$$availableSubj.next({available: {hash: 'foo'}}); expect(swu.activateUpdate).not.toHaveBeenCalled(); }))); it('should stop emitting on `updateActivated`', run(() => { const activatedVersions: (string|undefined)[] = []; service.updateActivated.subscribe(v => activatedVersions.push(v)); swu.$$availableSubj.next({available: {hash: 'foo'}}); swu.$$activatedSubj.next({current: {hash: 'bar'}}); service.ngOnDestroy(); swu.$$availableSubj.next({available: {hash: 'baz'}}); swu.$$activatedSubj.next({current: {hash: 'qux'}}); expect(activatedVersions).toEqual(['bar']); })); }); }); // Mocks class MockApplicationRef { isStable = new Subject(); } class MockLogger { log = jasmine.createSpy('MockLogger.log'); } class MockSwUpdate { $$availableSubj = new Subject<{available: {hash: string}}>(); $$activatedSubj = new Subject<{current: {hash: string}}>(); available = this.$$availableSubj.asObservable(); activated = this.$$activatedSubj.asObservable(); activateUpdate = jasmine.createSpy('MockSwUpdate.activateUpdate') .and.callFake(() => Promise.resolve()); checkForUpdate = jasmine.createSpy('MockSwUpdate.checkForUpdate') .and.callFake(() => Promise.resolve()); constructor(public isEnabled: boolean) {} } ================================================ FILE: apps/rxjs.dev/src/app/sw-updates/sw-updates.service.ts ================================================ import { ApplicationRef, Injectable, OnDestroy } from '@angular/core'; import { SwUpdate } from '@angular/service-worker'; import { concat, interval, NEVER, Observable, Subject } from 'rxjs'; import { first, map, takeUntil, tap } from 'rxjs/operators'; import { Logger } from 'app/shared/logger.service'; /** * SwUpdatesService * * @description * 1. Checks for available ServiceWorker updates once instantiated. * 2. Re-checks every 6 hours. * 3. Whenever an update is available, it activates the update. * * @property * `updateActivated` {Observable} - Emit the version hash whenever an update is activated. */ @Injectable() export class SwUpdatesService implements OnDestroy { private checkInterval = 1000 * 60 * 60 * 6; // 6 hours private onDestroy = new Subject(); updateActivated: Observable; constructor(appRef: ApplicationRef, private logger: Logger, private swu: SwUpdate) { if (!swu.isEnabled) { this.updateActivated = NEVER.pipe(takeUntil(this.onDestroy)); return; } // Periodically check for updates (after the app is stabilized). const appIsStable = appRef.isStable.pipe(first(v => v)); concat(appIsStable, interval(this.checkInterval)) .pipe( tap(() => this.log('Checking for update...')), takeUntil(this.onDestroy), ) .subscribe(() => this.swu.checkForUpdate()); // Activate available updates. this.swu.available .pipe( tap(evt => this.log(`Update available: ${JSON.stringify(evt)}`)), takeUntil(this.onDestroy), ) .subscribe(() => this.swu.activateUpdate()); // Notify about activated updates. this.updateActivated = this.swu.activated.pipe( tap(evt => this.log(`Update activated: ${JSON.stringify(evt)}`)), map(evt => evt.current.hash), takeUntil(this.onDestroy), ); } ngOnDestroy() { this.onDestroy.next(); } private log(message: string) { const timestamp = (new Date()).toISOString(); this.logger.log(`[SwUpdates - ${timestamp}]: ${message}`); } } ================================================ FILE: apps/rxjs.dev/src/assets/.gitkeep ================================================ ================================================ FILE: apps/rxjs.dev/src/assets/js/devtools-welcome.js ================================================ var welcomeText = ( ' ____ _ ____ \n' + '| _ \\ __ __ | / ___| \n' + '| |_) |\\ \\/ / | \\___ \\ \n' + '| _ < > < |_| |___) | \n' + '|_| \\_\\/_/\\_\\___/|____/ \n' + '\nTo start experimenting with RxJS: https://stackblitz.com/edit/rxjs\n' ); if (console.info) { console.info(welcomeText); } else { console.log(welcomeText); } ================================================ FILE: apps/rxjs.dev/src/assets/js/prettify.js ================================================ !function(){/* Copyright (C) 2006 Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ window.PR_SHOULD_USE_CONTINUATION=!0; (function(){function T(a){function d(e){var b=e.charCodeAt(0);if(92!==b)return b;var a=e.charAt(1);return(b=w[a])?b:"0"<=a&&"7">=a?parseInt(e.substring(1),8):"u"===a||"x"===a?parseInt(e.substring(2),16):e.charCodeAt(1)}function f(e){if(32>e)return(16>e?"\\x0":"\\x")+e.toString(16);e=String.fromCharCode(e);return"\\"===e||"-"===e||"]"===e||"^"===e?"\\"+e:e}function b(e){var b=e.substring(1,e.length-1).match(/\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\s\S]|-|[^-\\]/g);e= [];var a="^"===b[0],c=["["];a&&c.push("^");for(var a=a?1:0,g=b.length;ak||122k||90k||122h[0]&&(h[1]+1>h[0]&&c.push("-"),c.push(f(h[1])));c.push("]");return c.join("")}function v(e){for(var a=e.source.match(/(?:\[(?:[^\x5C\x5D]|\\[\s\S])*\]|\\u[A-Fa-f0-9]{4}|\\x[A-Fa-f0-9]{2}|\\[0-9]+|\\[^ux0-9]|\(\?[:!=]|[\(\)\^]|[^\x5B\x5C\(\)\^]+)/g),c=a.length,d=[],g=0,h=0;g/,null])):d.push(["com",/^#[^\r\n]*/,null,"#"]));a.cStyleComments&&(f.push(["com",/^\/\/[^\r\n]*/,null]),f.push(["com",/^\/\*[\s\S]*?(?:\*\/|$)/,null]));if(b=a.regexLiterals){var v=(b=1|\\/=?|::?|<>?>?=?|,|;|\\?|@|\\[|~|{|\\^\\^?=?|\\|\\|?=?|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*("+ ("/(?=[^/*"+b+"])(?:[^/\\x5B\\x5C"+b+"]|\\x5C"+v+"|\\x5B(?:[^\\x5C\\x5D"+b+"]|\\x5C"+v+")*(?:\\x5D|$))+/")+")")])}(b=a.types)&&f.push(["typ",b]);b=(""+a.keywords).replace(/^ | $/g,"");b.length&&f.push(["kwd",new RegExp("^(?:"+b.replace(/[\s,]+/g,"|")+")\\b"),null]);d.push(["pln",/^\s+/,null," \r\n\t\u00a0"]);b="^.[^\\s\\w.$@'\"`/\\\\]*";a.regexLiterals&&(b+="(?!s*/)");f.push(["lit",/^@[a-z_$][a-z_$@0-9]*/i,null],["typ",/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],["pln",/^[a-z_$][a-z_$@0-9]*/i, null],["lit",/^(?:0x[a-f0-9]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+\-]?\d+)?)[a-z]*/i,null,"0123456789"],["pln",/^\\[\s\S]?/,null],["pun",new RegExp(b),null]);return G(d,f)}function L(a,d,f){function b(a){var c=a.nodeType;if(1==c&&!A.test(a.className))if("br"===a.nodeName)v(a),a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)b(a);else if((3==c||4==c)&&f){var d=a.nodeValue,q=d.match(n);q&&(c=d.substring(0,q.index),a.nodeValue=c,(d=d.substring(q.index+q[0].length))&& a.parentNode.insertBefore(l.createTextNode(d),a.nextSibling),v(a),c||a.parentNode.removeChild(a))}}function v(a){function b(a,c){var d=c?a.cloneNode(!1):a,k=a.parentNode;if(k){var k=b(k,1),e=a.nextSibling;k.appendChild(d);for(var f=e;f;f=e)e=f.nextSibling,k.appendChild(f)}return d}for(;!a.nextSibling;)if(a=a.parentNode,!a)return;a=b(a.nextSibling,0);for(var d;(d=a.parentNode)&&1===d.nodeType;)a=d;c.push(a)}for(var A=/(?:^|\s)nocode(?:\s|$)/,n=/\r\n?|\n/,l=a.ownerDocument,m=l.createElement("li");a.firstChild;)m.appendChild(a.firstChild); for(var c=[m],p=0;p=+v[1],d=/\n/g,A=a.a,n=A.length,f=0,l=a.c,m=l.length,b=0,c=a.g,p=c.length,w=0;c[p]=n;var r,e;for(e=r=0;e=h&&(b+=2);f>=k&&(w+=2)}}finally{g&&(g.style.display=a)}}catch(x){E.console&&console.log(x&&x.stack||x)}}var E=window,C=["break,continue,do,else,for,if,return,while"], F=[[C,"auto,case,char,const,default,double,enum,extern,float,goto,inline,int,long,register,restrict,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],H=[F,"alignas,alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,delegate,dynamic_cast,explicit,export,friend,generic,late_check,mutable,namespace,noexcept,noreturn,nullptr,property,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"], O=[F,"abstract,assert,boolean,byte,extends,finally,final,implements,import,instanceof,interface,null,native,package,strictfp,super,synchronized,throws,transient"],P=[F,"abstract,add,alias,as,ascending,async,await,base,bool,by,byte,checked,decimal,delegate,descending,dynamic,event,finally,fixed,foreach,from,get,global,group,implicit,in,interface,internal,into,is,join,let,lock,null,object,out,override,orderby,params,partial,readonly,ref,remove,sbyte,sealed,select,set,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,value,var,virtual,where,yield"], F=[F,"abstract,async,await,constructor,debugger,enum,eval,export,function,get,implements,instanceof,interface,let,null,set,undefined,var,with,yield,Infinity,NaN"],Q=[C,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],R=[C,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],C=[C,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"], S=/^(DIR|FILE|array|vector|(de|priority_)?queue|(forward_)?list|stack|(const_)?(reverse_)?iterator|(unordered_)?(multi)?(set|map)|bitset|u?(int|float)\d*)\b/,W=/\S/,X=y({keywords:[H,P,O,F,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",Q,R,C],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),I={};t(X,["default-code"]);t(G([],[["pln",/^[^]*(?:>|$)/],["com",/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),"default-markup htm html mxml xhtml xml xsl".split(" "));t(G([["pln",/^[\s]+/,null," \t\r\n"],["atv",/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null, "\"'"]],[["tag",/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],["pun",/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);t(G([],[["atv",/^[\s\S]+/]]),["uq.val"]);t(y({keywords:H, hashComments:!0,cStyleComments:!0,types:S}),"c cc cpp cxx cyc m".split(" "));t(y({keywords:"null,true,false"}),["json"]);t(y({keywords:P,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:S}),["cs"]);t(y({keywords:O,cStyleComments:!0}),["java"]);t(y({keywords:C,hashComments:!0,multiLineStrings:!0}),["bash","bsh","csh","sh"]);t(y({keywords:Q,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),["cv","py","python"]);t(y({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END", hashComments:!0,multiLineStrings:!0,regexLiterals:2}),["perl","pl","pm"]);t(y({keywords:R,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb","ruby"]);t(y({keywords:F,cStyleComments:!0,regexLiterals:!0}),["javascript","js","ts","typescript"]);t(y({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,throw,true,try,unless,until,when,while,yes",hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0, regexLiterals:!0}),["coffee"]);t(G([],[["str",/^[\s\S]+/]]),["regex"]);var Y=E.PR={createSimpleLexer:G,registerLangHandler:t,sourceDecorator:y,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ",prettyPrintOne:E.prettyPrintOne=function(a,d,f){f=f||!1;d=d||null;var b=document.createElement("div");b.innerHTML="
"+a+"
"; b=b.firstChild;f&&L(b,f,!0);M({j:d,m:f,h:b,l:1,a:null,i:null,c:null,g:null});return b.innerHTML},prettyPrint:E.prettyPrint=function(a,d){function f(){for(var b=E.PR_SHOULD_USE_CONTINUATION?c.now()+250:Infinity;p RxJS ================================================ FILE: apps/rxjs.dev/src/karma.conf.js ================================================ // Karma configuration file, see link for more information // https://karma-runner.github.io/1.0/config/configuration-file.html module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular-devkit/build-angular'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular-devkit/build-angular/plugins/karma') ], client: { clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/site'), reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, reporters: ['progress', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], browserNoActivityTimeout: 60000, singleRun: false, restartOnFileChange: true }); }; ================================================ FILE: apps/rxjs.dev/src/main.ts ================================================ import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModule(AppModule); ================================================ FILE: apps/rxjs.dev/src/noop-worker-basic.js ================================================ /** * A simple, no-op service worker that takes immediate control. * Use this file if the active service worker has a bug and we * want to deactivate the worker on client browsers while we * investigate the problem. * * To activate this service worker file, rename it to `worker-basic.min.js` * and deploy to the hosting. When the original worker files cache * expires, this one will take its place. (Browsers ensure that the expiry * time is never longer than 24 hours, but the default expiry time on Firebase * is 60 mins). */ // Skip over the "waiting" lifecycle state, to ensure that our // new service worker is activated immediately, even if there's // another tab open controlled by our older service worker code. self.addEventListener('install', function(event) { event.waitUntil(self.skipWaiting()); }); // Get a list of all the current open windows/tabs under // our service worker's control, and force them to reload. // This can "unbreak" any open windows/tabs as soon as the new // service worker activates, rather than users having to manually reload. self.addEventListener('activate', function(event) { event.waitUntil(self.clients.claim()); }); ================================================ FILE: apps/rxjs.dev/src/polyfills.ts ================================================ /** * This file includes polyfills needed by Angular and is loaded before the app. * You can add your own extra polyfills to this file. * * This file is divided into 2 sections: * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. * 2. Application imports. Files imported after ZoneJS that should be loaded before your main * file. * * The current setup is for so-called "evergreen" browsers; the last versions of browsers that * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html */ /*************************************************************************************************** * BROWSER POLYFILLS */ /** HACK: force import of environment.ts/environment.prod.ts to load env specific polyfills */ import './environments/environment'; /*************************************************************************************************** * Zone JS is required by Angular itself. */ import 'zone.js'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */ /** * Date, currency, decimal and percent pipes. * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 */ // import 'intl'; // Run `npm install --save intl`. ================================================ FILE: apps/rxjs.dev/src/pwa-manifest.json ================================================ { "short_name": "RxJS", "name": "RxJS", "icons": [ { "src": "assets/images/favicons/apple-touch-icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "assets/images/favicons/favicon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "assets/images/favicons/favicon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "assets/images/favicons/favicon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "assets/images/favicons/favicon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "assets/images/favicons/favicon-152x152.png", "sizes": "152x152", "type": "image/png" }, { "src": "assets/images/favicons/favicon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "assets/images/favicons/favicon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "assets/images/favicons/favicon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "start_url": "/?utm_source=homescreen", "background_color": "#ffffff", "theme_color": "#d81b60", "display": "standalone" } ================================================ FILE: apps/rxjs.dev/src/styles/0-base/_base-dir.scss ================================================ /* ============================== BASE STYLES ============================== */ @import 'typography'; ================================================ FILE: apps/rxjs.dev/src/styles/0-base/_typography.scss ================================================ body { font-family: $main-font; margin: 0; color: $darkgray; font-size: 14px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } h1 { display: inline-block; font-size: 24px; font-weight: 500; margin: 8px 0px; @media screen and (max-width: 600px) { margin-top: 0; } } h2 { font-size: 22px; font-weight: 500; margin: 32px 0px 24px; clear: both; } h3 { font-size: 20px; font-weight: 400; margin: 24px 0px; clear: both; } h4 { font-size: 18px; font-weight: 400; margin: 8px 0px; clear: both; } h5 { font-size: 16px; font-weight: 500; margin: 8px 0px; clear: both; } h6 { color: $mediumgray; font-size: 16px; font-weight: 500; margin: 8px 0px; clear: both; } h2, h3, h4, h5, h6 { @media screen and (max-width: 600px) { margin: 8px 0; } } .mat-tab-body-wrapper h2 { margin-top: 0; } p, ol, ul, ol, li, input, a { font-size: 14px; line-height: 24px; letter-spacing: 0.30px; font-weight: 400; & > em { letter-spacing: 0.30px; } } ol { li, p { margin: 4px 0; } } li p { margin: 0; } a { text-decoration: none; } .app-toolbar a { font-size: 16px; font-weight: 400; color: white; font-family: $main-font; text-transform: uppercase; padding: 21px 0; } strong { font-weight: 600; } table { border-collapse: collapse; border-radius: 2px; border-spacing: 0; margin: 0 0 32px 0; } table tbody th { max-width: 100px; padding: 13px 32px; text-align: left; } td { font-weight: 400; padding: 8px 30px; letter-spacing: 0.30px; p { margin: 0; } } th { font-size: 16px; font-weight: 500; padding: 13px 32px; text-align: left; } p > code, li > code, td > code, th > code { font-family: $code-font; font-size: 85%; color: $darkgray; letter-spacing: 0; line-height: 1; padding: 2px 0; background-color: $backgroundgray; border-radius: 4px; } code { font-family: $code-font; font-size: 90%; } .sidenav-content a { color: $pink; &:hover { color: $mediumgray; } } .informal { display: block; padding-left: 1.3em; border-left: 5px solid #dfdfdf; font-size: 1.1em; font-style: italic; margin: 22px 0; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_api-page.scss ================================================ .api-section { position: relative; pre { white-space: pre-wrap; } table.api-table { min-width: 680px; thead th { color: white; font-size: 16px; background-color: $pink; border-radius: 4px 4px 0 0; text-transform: none; padding: 8px 24px; } tbody { pre { white-space: normal; margin: 4px; padding: 4px 16px; } td, th { padding: 0; } th { max-width: 150px; } } } } .api-body { max-width: 1200px; table { th { text-transform: none; font-size: 16px; font-weight: bold; } tr { border-bottom: 1px solid $lightgray; } td { vertical-align: middle; } hr { margin: 16px 0; } tr:last-child { border-bottom: none; } &.item-table { td { padding: 32px; } } &.list-table { td { padding: 16px 24px; } } } /* used to target the short description */ > p:nth-child(2) { border-left: 5px solid $pink; font-size: 1rem; line-height: 1.25; padding-left: 0.5rem; } .export-list { a { &.deprecated { text-decoration: line-through; } } } } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_content-layout.scss ================================================ aio-shell.page-docs { .sidenav-content { // padding: 6rem 3rem 3rem 3rem; // THIS CAUSES THE TOP NAV TOOLBAR TO JUMP BETWEEN DOCS AND OTHER PAGES margin: auto; } } .sidenav-content { min-height: 100vh; padding: 80px 3rem 1rem; } @media (max-width: 600px) { aio-menu { display: none; } .sidenav-content { min-height: 450px; padding: 80px 1rem 1rem; } } .sidenav-container { width: 100%; height: 100vh; } .sidenav-content button { min-width: 50px; } #guide-change-log h2::before { content: ""; display: block; height: 1px; margin: 24px 0px; background: $lightgray; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_doc-viewer.scss ================================================ .no-animations aio-doc-viewer > * { // Disable view transition animations. transition: none !important; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_footer.scss ================================================ footer { position: relative; line-height: 24px; flex: 1; padding: 48px; z-index: 0; background-color: $pink; color: $offwhite; font-weight: 300; aio-footer { position: relative; z-index: 0; } .footer-block { margin: 0 24px; vertical-align: top; } a { color: $offwhite; font-weight: 300; text-decoration: none; z-index: 20; position: relative; &:hover { text-decoration: underline; } &:visited { text-decoration: none; } } a.action { cursor: pointer; } h3 { font-size: 16px; text-transform: uppercase; font-weight: 400; margin: 0 0 16px; } p { text-align: center; margin: 10px 0px 5px; @media (max-width: 480px) { text-align: left; } } div.grid-fluid { display: -ms-flexbox; display: -webkit-flex; display: flex; justify-content: center; text-align: left; margin: 0 0 40px; ul { list-style-position: inside; padding: 0px; margin: 0px; li { list-style-type: none; padding: 0px; text-align: left; } } @media (max-width: 480px) { flex-direction: column; .footer-block { margin: 8px 24px; } } } @media (max-width: 700px) { h3 { font-size: 110%; } } @media (max-width: 600px) { h3 { font-size: 100%; } } } footer::after { content: ''; position: absolute; z-index: -1; top: 0; bottom: 0; left: 0; right: 0; background-size: 320px auto; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_layout-global.scss ================================================ html, body { height: 100%; } body { background-color: $offwhite; } .clearfix { content: ""; display: table; clear: both; } .clear { clear: both; } .l-clearfix:after, .clearfix:after { content: ""; display: table; clear: both; } .is-visible { display: block!important; } .l-flex-wrap { display: flex; flex-wrap: wrap; } .flex-center { display: flex; justify-content: center; } .center { text-align: center; } .visually-hidden { position: absolute !important; top: -9999px !important; left: -9999px !important; } .text-uppercase { text-transform: uppercase; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_layouts-dir.scss ================================================ /* ============================== LAYOUT STYLES ============================== */ @import 'api-page'; @import 'content-layout'; @import 'doc-viewer'; @import 'footer'; @import 'layout-global'; @import 'marketing-layout'; @import 'not-found'; @import 'sidenav'; @import 'table-of-contents'; @import 'top-menu'; @import 'print-layout'; ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_marketing-layout.scss ================================================ .hero { display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; box-sizing: border-box; padding: 48px 48px 32px 48px; overflow: hidden; .hero-title { display: inline-block; font-size: 28px; font-weight: 400; line-height: 48px; margin: 0 8px 0 0; text-transform: uppercase; &.is-standard-case { text-transform: none; } } } section#intro { display: flex; align-items: center; position: relative; width: 900px; height: 480px; margin: 0 auto; padding: 0; color: white; @media (max-width: 780px) { flex-direction: column; justify-content: center; width: 100%; max-width: 100vw; padding: 70px 0 32px; button { margin: 0; height: 60px; } } .homepage-container { width: 100%; max-width: 1040px; margin: 0 auto; margin-top: -7%; padding-top: 0; padding-bottom: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; .hero-cta + .hero-cta { margin-top: 15px; } @media (max-width: 780px) { display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 100%; padding: 0; } } .headline-container { margin: 32px 0; text-align: center; } .hero-headline { font-size: 40px; line-height: 64px; font-weight: 500; color: $pink; &:after { display: none; } @media (max-width: 780px) { text-align: center; } @media (max-width: 575px) { font-size: 32px; line-height: 50px; } } .hero-subheadline { font-size: 22px; line-height: 32px; color: $accentgrey; } .hero-logo { display: flex; width: 400px; @media (max-width: 780px) { justify-content: center; } img { width: 400px; height: 400px; margin-bottom: 8px; padding: 0; filter: drop-shadow(0 2px 2px rgba($black, 0.24)); @media (max-width: 780px) { width: 250px; height: 250px; } } } } .announcement-bar { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-around; align-items: center; max-width: 50vw; margin: 0 auto; padding: 16px; background-color: $white; border-radius: 4px; box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); box-sizing: border-box; transition: all 0.3s ease-in; @media (max-width: 992px) { flex-direction: column; text-align: center; padding: 32px 16px; } @media (max-width: 768px) { width: 100%; max-width: none; } & > * { margin: 8px; } img { filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.26)); } .button { display: flex; justify-content: center; align-items: center; height: 40px; min-width: 160px; font-size: 16px; color: $white; background-color: $pink; border-radius: 48px; box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26); box-sizing: border-box; cursor: pointer; &:hover { color: rgba($white, 0.7); } } .material-icons { display: none; right: 0; position: static; transition: all 0.3s ease-in; font-size: 16px; } p { strong { font-weight: 700; } font-size: 16px; margin: 8px; text-align: center; } } // ANGULAR LINE .background-sky { background-color: $pink; background: $pink; color: $white; } .home-row .card { @include card(70%, auto); display: flex; flex-direction: row; align-items: center; justify-content: center; position: relative; width: 70%; min-width: 270px; text-align: center; height: auto; margin: auto; padding: 24px; box-shadow: 0 6px 6px rgba(10, 16, 20, 0.15), 0 0 52px rgba(10, 16, 20, 0.12); @media (max-width: 600px) { margin: 16px auto 0; h2 { margin: 0; } img { max-width: none; height: 70px; } } @media (max-width: 1300px) { img { height: 70px; max-width: none; } } img { margin: 16px; } .card-text-container { margin: 0 16px; p { text-align: left; color: $darkgray; margin: 0; padding: 8px 0; } } &:hover { h2 { color: $pink; } } ul { list-style-type: none; } } .text { color: $darkgray; } .button.hero-cta { display: flex; align-items: center; justify-content: center; width: 184px; height: 40px; padding: 0 24px; font-size: 18px; font-weight: 600; line-height: 40px; background-color: $white; border-radius: 48px; box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26); box-sizing: border-box; cursor: pointer; &:hover { opacity: 0.9; } } aio-shell { &.page-home { section { padding: 0; } } &.page-home, &.page-resources, &.page-events, &.page-contribute { article { padding: 32px; @media (max-width: 800px) { padding: 24px; } } } &.page-features { article { padding: 0 3rem; } } &.page-home, &.page-resources, &.page-events, &.page-features { .content img { @media (max-width: 1300px) { max-width: none; } } .feature-section img { max-width: 70px; } @media (max-width: 600px) { mat-sidenav-container.sidenav-container { padding-top: 0; } } } .cta-bar .hero-cta { color: $pink; } } .cta-bar.announcement-bar { background: none; box-shadow: none; } .text-headline { font-size: 20px; font-weight: 500; color: $pink; margin-top: 10px; text-transform: uppercase; } aio-shell:not(.view-SideNav) { mat-sidenav-container.sidenav-container { max-width: none; } } div[layout='row'] { display: flex; justify-content: center; align-items: center; box-sizing: border-box; @media (max-width: 480px) { display: block; } } .layout-row { flex-direction: row; } .home-rows { overflow: hidden; @media (max-width: 600px) { margin: 0; } } .background-superhero-paper { background-size: 100%; background-blend-mode: multiply; } .home-row { max-width: 920px; margin: 32px auto; .promo-img-container, .text-container { max-width: 50%; @media (max-width: 480px) { max-width: 100%; text-align: center; &:nth-child(even) { flex-direction: column-reverse; } } } .text-block { padding-right: 15%; @media (max-width: 600px) { padding: 0; } } .promo-img-container { img { max-width: 90% !important; } p { margin: 0 20px; } img { max-width: 90%; @media (max-width: 599px) { max-width: 100%; float: initial !important; } } } } .marketing-banner { background-color: lighten($pink, 10); margin-top: 64px; padding: 32px; @media (max-width: 600px) { margin-top: 56px; padding: 18px; } .banner-headline { text-transform: uppercase; font-size: 24px; font-weight: 300; color: white; margin: 0; -webkit-margin-before: 0; -webkit-margin-after: 0; @media (max-width: 600px) { font-size: 18px; font-weight: 400; } &:after { display: none; } } } .page-features .marketing-banner { margin-bottom: 20px; } .blm-background { background-color: black; color: white; display: flex; min-height: calc(100vh - 64px); } .text-container.blm-container { text-align: center; line-height: inherit; max-width: inherit; } .blm-text { font-size: 2rem; line-height: inherit; } .blm-list-item { margin: 10px 0; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_not-found.scss ================================================ #file-not-found { padding: 3rem 3rem 3rem; } .nf-container { align-items: center; padding: 32px; } .nf-response { margin: 32px; height: 100%; flex-direction: column; h1 { font-size: 48px; color: $pink; text-transform: uppercase; margin: 8px 0; } } .nf-icon.material-icons { color: $pink; font-size: 100px; position: static; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_print-layout.scss ================================================ @media print { // General Adjustments * { box-shadow: none !important; } h1 { height: 40px !important; color: $darkgray !important; } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } ul, ol, img, code-example, table, tr, .alert, .l-subsection, .feature { page-break-inside: avoid; } table tbody tr:last-child td { border-bottom: 1px solid $lightgray !important; } img { max-width: 100% !important; } p { widows: 4; } p > code, li > code, table code { color: $pink !important; } // No Print Class .no-print { display: none !important; } // Custom No Print for Sidenav Menu mat-sidenav.sidenav.mat-sidenav { display: none !important; } // Custom No Print Element Adjustments .mat-sidenav-content { margin: 0 !important; } mat-sidenav-container.sidenav-container { min-width: 100vw; } .sidenav-content { overflow: visible; } .filetree { max-width: 100%; } aio-code code { border: none !important; } code-example { pre.lang-bash code span { color: $mediumgray !important; } pre.lang-sh code span { color: $darkgray !important; } header { border: 0.5px solid $lightgray; color: $darkgray; } } .content code { border: 0.5px solid $lightgray; } .mat-tab-labels { div.mat-tab-label { &:not(.mat-tab-label-active) span { font-style: italic; } &.mat-tab-label-active span { font-weight: bold; } } } .api-header label { color: $darkgray !important; font-weight: bold !important; margin: 2px !important; padding: 0 !important; display: block !important; } .feature-section img { max-width: 70px !important; } } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_sidenav.scss ================================================ // Disable sidenav animations for the initial render. .starting.mat-drawer-transition .mat-drawer-content { transition: none; } aio-nav-menu { display: block; margin: 0 auto; font-size: 13px; ul, a { padding: 0; margin: 0; } &:first-child { margin-top: 16px; } aio-nav-item div a { padding-left: 6px; } } mat-sidenav.mat-sidenav.sidenav { position: fixed; top: 64px; bottom: 0; left: 0; padding: 0; min-width: 260px; background-color: $offwhite; box-shadow: 6px 0 6px rgba(0,0,0,0.10); &.collapsed { top: 56px; } } mat-sidenav-container.sidenav-container { min-height: 100%; height: auto !important; max-width: 100%; margin: 0; transform: none; &.has-floating-toc { max-width: 82%; } } mat-sidenav-container div.mat-sidenav-content { height: auto; } .vertical-menu-item { box-sizing: border-box; color: $darkgray; cursor: pointer; display: block; max-width: 260px; overflow-wrap: break-word; padding-top: 4px; padding-bottom: 4px; text-decoration: none; text-align: left; width: 93%; word-wrap: break-word; &:hover { background-color: $lightgray; color: $pink; text-shadow: 0 0 5px #ffffff; } &:focus { outline: $accentgrey auto 2px; } //icons _within_ nav .mat-icon { position: absolute; top: 0; right: 0; margin: 4px; } } .vertical-menu-item.selected { color: $pink; } button.vertical-menu-item { border: none; background-color: transparent; margin-right: 0; padding-left: 6px; padding-top: 8px; padding-bottom: 10px; } .heading { color: $darkgray; cursor: pointer; position: relative; text-transform: uppercase; } .heading-children.expanded { visibility: visible; opacity: 1; max-height: 4000px; // Arbitrary max-height. Can increase if needed. Must have measurement to transition height. transition: visibility 500ms, opacity 500ms, max-height 500ms; -webkit-transition-timing-function: ease-in-out; transition-timing-function: ease-in-out; } .heading-children.collapsed { visibility: hidden; opacity: 0; max-height: 1px; // Must have measurement to transition height. transition: visibility 275ms, opacity 275ms, max-height 280ms; -webkit-transition-timing-function: ease-out; transition-timing-function: ease-out; } .no-animations { .heading-children.expanded, .heading-children.collapsed { transition: none! important; } } .level-1 { font-family: $main-font; font-size: 14px; font-weight: 400; margin-left: 14px; transition: background-color 0.2s; text-transform: uppercase; } .level-2 { color: $mediumgray; font-family: $main-font; font-size: 14px; font-weight: 400; margin-left: 12px; text-transform: none; } .level-3 { color: $mediumgray; font-family: $main-font; font-size: 14px; margin-left: 10px; } .level-1.expanded .mat-icon, .level-2.expanded .mat-icon { @include rotate(90deg); } .level-1:not(.expanded) .mat-icon, .level-2:not(.expanded) .mat-icon { @include rotate(0deg); } aio-nav-menu.top-menu { padding: 24px 0 0; aio-nav-item:last-child div { border-bottom: 1px solid rgba($mediumgray, 0.5); } aio-nav-item:first-child div { border-top: 1px solid rgba($mediumgray, 0.5); } } // Angular Version Selector mat-sidenav .doc-version { padding: 8px; border-top: 1px solid $lightgray; select { outline: none; width: 100%; background: rgba($lightgray, 0.5); height: 32px; border: 1px solid $lightgray; color: $darkgray; option { font-family: $main-font; font-size: 14px; } } } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_table-of-contents.scss ================================================ nav#main-table-of-contents { width: 200px; height: 900px; position: fixed; right: 0; top: 50px; bottom: 100px; margin-left: 32px; background-color: $pink; } ================================================ FILE: apps/rxjs.dev/src/styles/1-layouts/_top-menu.scss ================================================ // VARIABLES $hamburgerShownMargin: 0; $hamburgerHiddenMargin: 0 24px 0 -88px; // DOCS PAGE / STANDARD: TOPNAV TOOLBAR FIXED mat-toolbar.mat-toolbar { position: fixed; top: 0; right: 0; left: 0; z-index: 10; box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.3); mat-toolbar-row { padding: 0 16px 0 0; } mat-icon { color: $white; } } // HOME PAGE OVERRIDE: TOPNAV TOOLBAR aio-shell.page-home mat-toolbar.mat-toolbar { background-color: $pink; @media (min-width: 481px) { &:not(.transitioning) { background-color: $pink; transition: background-color 0.2s linear; } } } // MARKETING PAGES OVERRIDE: TOPNAV TOOLBAR AND HAMBURGER aio-shell.page-home mat-toolbar.mat-toolbar, aio-shell.page-features mat-toolbar.mat-toolbar, aio-shell.page-events mat-toolbar.mat-toolbar { box-shadow: none; // FIXED TOPNAV TOOLBAR FOR SMALL MOBILE @media (min-width: 481px) { position: absolute; } } // DOCS PAGES OVERRIDE: HAMBURGER aio-shell.folder-api mat-toolbar.mat-toolbar, aio-shell.folder-docs mat-toolbar.mat-toolbar, aio-shell.folder-guide mat-toolbar.mat-toolbar, aio-shell.folder-tutorial mat-toolbar.mat-toolbar { @media (min-width: 992px) { .hamburger.mat-button { // Hamburger shown on non-marketing pages on large screens. margin: $hamburgerShownMargin; } } } // HAMBURGER BUTTON .hamburger.mat-button { height: 100%; margin: $hamburgerShownMargin; padding: 0; &:not(.starting) { transition-duration: 0.4s; transition-property: color, margin; transition-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1); } @media (min-width: 992px) { // Hamburger hidden by default on large screens. // (Will be shown per doc.) margin: $hamburgerHiddenMargin; } &:hover { color: $offwhite; } & .mat-icon { color: white; position: inherit; } } // HOME NAV-LINK .nav-link.home img { position: relative; margin-top: -21px; margin-right: 20px; top: 12px; height: 40px; @media (max-width: 992px) { &:hover { transform: scale(1.1); } } } // TOP MENU aio-top-menu { display: flex; flex-direction: row; align-items: center; width: 80%; ul { display: flex; flex-direction: row; align-items: center; list-style-position: inside; padding: 0px; margin: 0px; li { padding-bottom: 2px; list-style-type: none; cursor: pointer; &:hover { opacity: 0.7; } &:focus { background-color: $accentgrey; outline: none; } } } a.nav-link { margin: 0; padding: 24px 16px; cursor: pointer; &:focus { background: rgba($white, 0.15); border-radius: 4px; outline: none; padding: 8px 16px; } } } // SEARCH BOX aio-search-box.search-container { display: flex; justify-content: flex-end; align-items: center; width: 100%; min-width: 150px; height: 100%; input { color: $darkgray; border: none; border-radius: 100px; background-color: $offwhite; padding: 5px 16px; margin-left: 8px; width: 180px; max-width: 240px; height: 50%; -webkit-appearance: none; &:focus { outline: none; } @include bp(big) { transition: width 0.4s ease-in-out; &:focus { width: 50%; } } @media (max-width: 480px) { width: 150px; } } } // EXTERNAL LINK ICONS .app-toolbar { .toolbar-external-icons-container { display: flex; flex-direction: row; height: 100%; a { display: flex; align-items: center; margin-left: 16px; @media screen and (max-width: 480px) { margin-left: 8px; } &:hover { opacity: 0.8; } img { height: 24px; } } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_alert.scss ================================================ .alert { padding: 16px; margin: 24px 0px; font-size: 14px; color: $darkgray; width: 100%; box-sizing: border-box; &.is-critical { border-left: 10px solid $pink; background-color: rgba($pink, 0.05); h1, h2, h3, h4, h5, h6 { color: $pink; } } &.is-important { border-left: 10px solid $orange; background-color: rgba($orange, 0.05); h1, h2, h3, h4, h5, h6 { color: $orange; } } &.is-helpful { border-left: 10px solid $pink; background-color: rgba($pink, 0.05); h1, h2, h3, h4, h5, h6 { color: $pink; } } > * { margin: 0; padding: 8px 16px; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_api-list.scss ================================================ /* API EDIT ICON */ #api { .api-filter .material-icons { right: 48px; } } /* API LIST STYLES */ aio-api-list { div.form-search i.material-icons { width: 20px; pointer-events: none; } .form-search input { width: 182px; } .api-list-container { display: flex; flex-direction: column; margin: 0 auto; h2 { margin-top: 16px; } } } .api-filter { display: flex; margin: 0 auto; @media (max-width: 600px) { flex-direction: column; margin: 16px auto; } .form-select-menu, .form-search { margin: 8px; } } /* LAYOUT */ .docs-content { position: relative; } .l-content-small { padding: 16px; max-width: 1100px; margin: 0; @media handheld and (max-width: $phone-breakpoint), screen and (max-device-width: $phone-breakpoint), screen and (max-width: $tablet-breakpoint) { padding: 24px 0 0; } } /* SEARCH BAR */ .form-search { position: relative; input { box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); box-sizing: border-box; border: 1px solid $white; color: $blue-600; font-size: 16px; height: 32px; line-height: 32px; outline: none; padding: 0 16px 0 32px; transition: all .2s; // PLACEHOLDER TEXT &::-webkit-input-placeholder { /* Chrome/Opera/Safari */ color: $blue-grey-100; font-size: 14px; } &::-moz-placeholder { /* Firefox 19+ */ color: $blue-grey-100; font-size: 14px; } &:-ms-input-placeholder { /* IE 10+ */ color: $blue-grey-100; font-size: 14px; } &:-moz-placeholder { /* Firefox 18- */ color: $blue-grey-100; font-size: 14px; } &:focus { border: 1px solid $blue-400; box-shadow: 0 2px 2px rgba($blue-400, 0.24), 0 0 2px rgba($blue-400, 0.12); } } .material-icons { color: $blue-grey-100; font-size: 20px; left: 8px; position: absolute; top: 6px; z-index: $layer-1; } } /* API SYMBOLS */ /* SYMBOL CLASS */ .symbol { border-radius: 2px; box-shadow: 0 1px 2px rgba($black, .24); color: $white; display: inline-block; font-size: 10px; font-weight: 600; line-height: 16px; text-align: center; width: 16px; // SYMBOL TYPES // Symbol mapping variables in *constants* @each $name, $symbol in $api-symbols { &.#{$name} { background: map-get($symbol, background); &:before { content: map-get($symbol, content); } } } } /* API HOMEE PAGE */ /* API FILTER MENU */ .api-filter { aio-select { width: 200px; .symbol { margin-right: 8px; } } .form-search { float: left; } } /* API CLASS LIST */ .docs-content .api-list { list-style: none; margin: 0 0 32px -8px; padding: 0; overflow: hidden; @media screen and (max-width: 600px) { margin: 0 0 0 -8px; } li { font-size: 14px; margin: 8px 0; line-height: 14px; padding: 0; float: left; width: 33%; overflow: hidden; min-width: 220px; text-overflow: ellipsis; white-space: nowrap; .symbol { margin-right: 8px; } a { color: $blue-grey-600; display: inline-block; line-height: 16px; padding: 0 16px 0; text-decoration: none; transition: all .3s; overflow: hidden; text-overflow: ellipsis; &:hover { background: $blue-grey-50; color: $blue-500; } } .stability { &.deprecated { text-decoration: line-through; } &.experimental { font-style: italic; } } } } .docs-content .h2-api-docs, .docs-content .h2-api-docs:first-of-type { font-size: 18px; line-height: 24px; margin-top: 0; } .code-links { a { code, .api-doc-code { color: #1E88E5 !important; } } } .openParens { margin-top: 15px; } .endParens { margin-bottom: 20px !important; } p { &.selector { margin: 0; } &.location-badge { margin: 0 0 16px 16px !important; } .api-doc-code { border-bottom: 0; :hover { border-bottom: none; } } } .row-margin { margin-bottom: 36px; h2 { line-height: 28px; } } .code-margin { margin-bottom: 8px; } .no-bg { background: none; padding: 0; } .no-bg-with-indent { padding-top: 0; padding-bottom: 0; padding-left: 16px; margin-top: 6px; margin-bottom: 0; background: none; } .code-background { padding: 0 5px 0; span.pln { color: #1E88E5 !important; } } .code-anchor { cursor: pointer; &:hover { text-decoration: underline; } } .api-doc-code { font-size: 14px; color: #1a2326; // the last .pln (white space) creates additional spacing between sections of the api doc. Remove it. &.no-pln { .pln:last-child { display: none; } } } @media screen and (max-width: 600px) { .docs-content { // Overrides display flex from angular material. // This was added because Safari doesn't play nice with layout="column". // Look of API doc in Chrome and Firefox remains the same, and is fixed for Safari. .layout-xs-column { display: block !important; } } .api-doc-code { font-size: 12px; } p.location-badge { position: relative; font-size: 11px; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_api-pages.scss ================================================ .page-actions { float: right; .material-icons { border-radius: 4px; padding: 4px; font-size: 20px; &:hover { background-color: $mist; } } } .api-header { display: flex; align-items: center; @media screen and (max-width: 600px) { flex-direction: column; align-items: flex-start; } > h1 { margin-right: 1rem; } } .api-body { .class-overview { position: relative; code-example { clear: left; } } .description img { border: 1px solid #dfdfdf; max-width: 100%; width: 100%; } .method-table { h3 { margin: 6px 0; font-weight: bold; } h4 { font-size: 14px; font-weight: bold; margin-top: 12px; } } .api-heading { padding: 5px 0; font-size: 16px; font-weight: bold; } .short-description { margin: 6px 0 0 10px; } .properties-table { font-size: 14px; thead th { &:nth-child(1) { width: 20%; } &:nth-child(2) { width: 20%; } } } .parameters-table { margin-top: 0; font-size: 14px; td:nth-child(1) { width: 20%; } } details.overloads { margin-left: -8px; summary { height: inherit; padding: 8px 12px; h4 { margin: 0; clear: left; } } } .api-section aio-code { background-color: rgba(241, 241, 241, 0.2); } .from-constructor, .read-only-property { font-style: italic; color: $pink; } } .deprecated-api-item { text-decoration: line-through; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_buttons.scss ================================================ /* Button Styles */ .button, a.button.mat-button { display: inline-block; line-height: 32px; padding: 0px 16px; font-size: 14px; font-weight: 400; border-radius: 3px; text-decoration: none; text-transform: uppercase; overflow: hidden; border: none; // SIZES &.button-small { font-size: 12px; line-height: 24px; padding: 0px 8px; } &.button-large { font-size: 15px; line-height: 48px; padding: 0px 24px; } &.button-x-large { font-size: 16px; line-height: 56px; padding: 0px 24px; } // COLORS &.button-secondary { background: $mediumgray; color: rgba($white, .87); } &.button-plain { background: $white; color: rgba($darkgray, .87); } &.button-subtle { background: $mediumgray; color: darken($offwhite, 10%); } &.button-navy { background: $pink; color: rgba($white, .87); } &.button-banner { background: $darkgray; color: rgba($white, .87); } // &.button-shield, // &.button-shield.mat-button { // background-color: $pink; // background: $pink url('assets/images/logos/angular/angular_whiteTransparent.svg') 24px 13px no-repeat; // color: rgba($white, .87); // padding-left: 54px; // background-size: 22px 22px; // @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2) { // background: $pink url('assets/images/logos/angular/angular_whiteTransparent.svg') 24px 13px no-repeat; // background-size: 22px 22px; // } // } } .cta-bar { text-align: center; .button { margin: 0px 8px; box-shadow: 0 2px 5px 0 rgba(0,0,0,.26); transition: all .2s ease-in-out; &:hover { transform: scale(1.1); color: $offwhite; } } } a.filter-button { width: 140px; font-size: 14px; padding: 0px 16px; margin: 8px; line-height: 48px; border: 2px solid $pink; border-radius: 4px; &:hover { background-color: $pink; color: white; } } [mat-button], [mat-raised-button], [mat-button], [mat-raised-button] { text-transform: uppercase; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_callout.scss ================================================ .callout { @extend .alert; padding: 0px; border-left: none !important; border-radius: 4px; header { color: $white; line-height: 24px; font-weight: 500; text-transform: uppercase; border-radius: 4px 4px 0 0; } p { padding: 16px; margin: 0px; font-size: 14px; } &.is-critical { border-color: $brightred; background: rgba($brightred, 0.05); header { background: $brightred; } } &.is-important { border-color: $orange; background: rgba($orange, 0.05); header { background: $amber-700; } } &.is-helpful { border-color: $pink; background: rgba($pink, 0.05); header { background: $pink; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_card.scss ================================================ .card-container { display: flex; flex-direction: row; flex-wrap: wrap; margin: 24px 0; } .docs-card { @include card(194px, 30%); max-width: 340px; min-width: 262px; margin: 24px 8px; padding-bottom: 48px; position: relative; &:hover { text-decoration: none; section { color: $pink; } p { color: $darkgray; padding: 0 16px; } .card-footer { line-height: 32px; padding: 8px 16px; background-color: rgba($pink, 0.1); color: $pink; } } section { color: $darkgray; font-size: 20px; line-height: 24px; margin: 0; padding: 32px 0 24px; text-transform: none; text-align: center; } p { color: $darkgray; font-size: 13px; line-height: 24px; padding: 0 16px; margin: 0; text-align: center; } .card-footer { bottom: 0; border-top: 0.5px solid $lightgray; box-sizing: border-box; line-height: 48px; left: 0; position: absolute; right: 0; text-align: right; color: $mediumgray; a { color: $mediumgray; font-size: 13px; } } .card-footer.center { text-align: center; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_code.scss ================================================ code-example, code-tabs { clear: both; display: block; } code-example, code-tabs mat-tab-body { &:not(.no-box) { background-color: rgba($backgroundgray, 0.2); border: 0.5px solid $lightgray; border-radius: 5px; color: $darkgray; margin: 16px auto; } &.no-box { pre { margin: 0; } code { background-color: transparent; } } code { overflow: auto; } } // TERMINAL / SHELL TEXT STYLES code-example.code-shell, code-example[language=sh], code-example[language=bash] { background-color: $darkgray; } code-example header { background-color: $accentgrey; border-radius: 5px 5px 0 0; color: $offwhite; font-size: 16px; padding: 8px 16px; } code-example.avoid header, code-example.avoidFile header { border: 2px solid $anti-pattern; background: $anti-pattern; } code-example.avoid, code-example.avoidFile, code-tabs.avoid mat-tab-body, code-tabs.avoidFile mat-tab-body { border: 0.5px solid $anti-pattern; } code-tabs div .mat-tab-body-content { height: auto; } code-tabs .mat-tab-body-wrapper mat-tab-body .mat-tab-body { overflow-y: hidden; } code-tabs mat-tab-body-content .fadeIn { animation: opacity 2s ease-in; } aio-code pre { display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; align-items: center; code span { line-height: 24px; } } .code-missing { color: $darkred; } .copy-button, .edit-button { position: absolute; top: -8px; right: -32px; color: $blue-grey-200; background-color: transparent; border: none; cursor: pointer; &:hover { color: $mediumgray; } } .edit-button { right: 0; } .lang-sh .copy-button, .lang-bash .copy-button { color: $mediumgray; &:hover { color: $lightgray; } } .code-tab-group .mat-tab-label { white-space: nowrap; } .code-tab-group .mat-tab-body-content { height: auto; transform: none; } [role="tabpanel"] { transition: none; } .sidenav-content code a { color: inherit; font-size: inherit; } /* PRETTY PRINTING STYLES for prettify.js. */ .prettyprint { position: relative; } /* Specify class=linenums on a pre to get line numbering */ ol.linenums { margin: 0; font-family: $main-font; color: #B3B6B7; li { margin: 0; font-family: $code-font; font-size: 90%; line-height: 24px; } } /* The following class|color styles are derived from https://github.com/google/code-prettify/blob/master/src/prettify.css*/ /* SPAN elements with the classes below are added by prettyprint. */ .pln { color: #000 } /* plain text */ @media screen { .str { color: #800 } /* string content */ .kwd { color: #00f } /* a keyword */ .com { color: #060 } /* a comment */ .typ { color: #de0000 } /* a type name */ .lit { color: #0074af } /* a literal value */ /* punctuation, lisp open bracket, lisp close bracket */ .pun, .opn, .clo { color: #660 } .tag { color: #008 } /* a markup tag name */ .atn { color: #606 } /* a markup attribute name */ .atv { color: #800 } /* a markup attribute value */ .dec, .var { color: #606 } /* a declaration; a variable name */ .fun { color: #de0000 } /* a function name */ } /* Use higher contrast and text-weight for printable form. */ @media print, projection { .str { color: #060 } .kwd { color: #006; font-weight: bold } .com { color: #600; font-style: italic } .typ { color: #404; font-weight: bold } .lit { color: #044 } .pun, .opn, .clo { color: #440 } .tag { color: #006; font-weight: bold } .atn { color: #404 } .atv { color: #060 } } /* SHELL / TERMINAL CODE BLOCKS */ code-example.code-shell, code-example[language=sh], code-example[language=bash] { & .pnk, .blk,.pln, .otl, .kwd, .typ, .tag, .str, .atv, .atn, .com, .lit, .pun, .dec { color: $codegreen; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_contribute.scss ================================================ .contribute-container { h2 { margin: 0; } .l-sub-section { width: 90%; margin-bottom: 20px; &:last-child { margin-bottom: 0; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_contributor.scss ================================================ aio-contributor-list { @media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) { .grid-fluid{ width: auto; } } @media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) { .grid-fluid{ margin-left: 20px; margin-right: 20px; float: none; display: block; width: auto; } } } .group-buttons { margin: 32px auto; a { &.selected { background-color: $pink; color: white; } } } .contributor-group { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: center; } aio-contributor { background: $white; margin: 8px; position: relative; cursor: pointer; border-radius: 4px; box-shadow: 0 2px 2px rgba(10, 16, 20, 0.24), 0 0 2px rgba(10, 16, 20, 0.12); transition: all .3s; perspective: 800px; &:hover { transform: translate3d(0,-3px,0); box-shadow: 0 8px 8px rgba(10, 16, 20, 0.24), 0 0 8px rgba(10, 16, 20, 0.12); .contributor-image { transform: scale(1.05); } .contributor-info { opacity: 1; } } .contributor-info { background: rgba($darkgray, 0.5); height: 168px; width: 168px; display: flex; flex-direction: row; justify-content: center; align-items: center; text-align: center; opacity: 0; border-radius: 50%; [mat-button] { color: $white; font-size: 14px; font-weight: 500; margin: 8px; padding: 0; &:hover { color: $lightgray; } &.icon { min-width: 20px; width: 20px; .fa-2x { font-size: 20px; } } } } div.contributor-card { width: 250px; height: 270px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; position: relative; overflow: hidden; border-radius: 4px; transform-style:preserve-3d; transition:transform ease 500ms; h3 { margin: 8px 0; } .card-front, .card-back { width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; box-sizing: border-box; } .card-front { justify-content: center; } .card-back { height: 100%; display: flex; flex-direction: column; justify-content: center; padding: 16px 24px; transform:rotateY(180deg); section { display: none; } p { margin: 8px 0; font-size: 12px; line-height: 14px; text-align: left; } } &.flipped { transform:rotateY(180deg); .card-front { display: none; } } } .contributor-image { display: flex; justify-content: center; border-radius: 50%; align-items: center; height: 168px; width: 168px; background-size: cover; background-position: center; margin: 8px auto; border: 2px solid $lightgray; transition: all .2s ease-in-out; } section { font-size: 14px; font-weight: 500; padding: 8px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; } p { cursor: pointer; font-size: 14px; line-height: 18px; margin: 8px 16px; text-overflow: ellipsis; overflow: scroll; font-weight: 400; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_deploy-theme.scss ================================================ aio-shell.mode-archive { .mat-toolbar.mat-primary, footer { background: linear-gradient(145deg,#263238,#78909C); } .vertical-menu-item { &.selected, &:hover { color: #263238; } } .toc-inner ul.toc-list li.active a { color: #263238; &:before { background-color: #263238; } } .toc-inner ul.toc-list li:hover a { color: #263238; } } aio-shell.mode-next { .mat-toolbar.mat-primary, footer { background: linear-gradient(145deg,#DD0031,#C3002F); } .vertical-menu-item { &.selected, &:hover { color: #DD0031; } } .toc-inner ul.toc-list li.active a { color: #DD0031; &:before { background-color: #DD0031; } } .toc-inner ul.toc-list li:hover a { color: #DD0031; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_details.scss ================================================ /* * General styling to make detail/summary tags look a bit more material * To get the best out of it you should structure your usage like this: * * ``` *
* Some title *
* Some content *
*
* */ summary { cursor: pointer; font-size: 16px; position: relative; padding: 16px 24px; color: $black; height: 16px; display: block; // Remove the built in details marker in FF &::-webkit-details-marker { display: none; // Remove the built in details marker in webkit } &::before { content: '\E5CE'; // See https://material.io/icons/#ic_expand_less font-family: 'Material Icons'; font-size: 24px; -webkit-font-smoothing: antialiased; @include rotate(0deg); // We will rotate 180 degrees when details is open float: right; } } details { box-shadow: 0 1px 4px 0 rgba($black, 0.37); .detail-contents { padding: 16px 24px; } &[open] > summary::before { @include rotate(180deg); // Rotate the icon } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_edit-page-cta.scss ================================================ .edit-page-cta { font-weight: 400; font-size: 14px; color: $pink; text-align: right; margin-right: 32px; display: block; position: absolute; right: 0; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_features.scss ================================================ // FEATURES MARKETING PAGE SPECIFIC STYLES .feature-section { margin: 0 0 32px; .feature-header, .text-headline { text-align: center; } .feature-header img { margin: 16px; } .feature-title { font-size: 16px; font-weight: 500; margin: 8px 0px; clear: both; } .feature-row { display: flex; flex-wrap: wrap; @media (max-width: 600px) { flex-direction: column; } .feature { max-width: 300px; margin: 0 16px; @media (max-width: 768px) { max-width: 100%; } } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_filetree.scss ================================================ .filetree { background: $offwhite; border: 4px solid $lightgray; border-radius: 4px; margin: 0 0 24px 0; padding: 16px 32px; .file { display: block; font-family: $main-font; letter-spacing: 0.3px; line-height: 32px; color: $darkgray; } .children { padding-left: 24px; position: relative; overflow: hidden; .file { position: relative; &:before { content: ''; left: -18px; bottom: 16px; width: 16px; height: 9999px; position: absolute; border-width: 0 0 1px 1px; border-style: solid; border-color: $lightgray; border-radius: 0 0 0 3px; } } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_heading-anchors.scss ================================================ .sidenav-content { h1, h2, h3, h4, h5, h6 { .header-link { color: $mediumgray; margin: 0 4px; text-decoration: none; user-select: none; visibility: hidden; display: inline-block; vertical-align: text-top; } &:hover .header-link { visibility: visible; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_hr.scss ================================================ hr { border: none; background: $lightgray; height: 1px; } .hr-margin { display: block; height: 1px; border: 0; margin-top: 16px; margin-bottom: 16px; padding: 0; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_images.scss ================================================ .content { img { &.right { clear: both; float: right; margin-left: 20px; margin-bottom: 20px; } &.left { clear: both; float: left; margin-right: 20px; margin-bottom: 20px; } @media (max-width: 1300px) { max-width: 100%; height: auto; } @media (max-width: 600px) { float: none !important; &.right { margin-left: 0; } &.left { margin-right: 0; } } } figure { border-radius: 4px; background: $white; padding: 20px; display: inline-block; box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, .2); margin: 0 0 14px 0; img { border-radius: 4px; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_label.scss ================================================ label.raised, .api-header label { border-radius: 4px; padding: 4px 16px; display: inline; font-size: 14px; color: white; margin-right: 8px; font-weight: 500; text-transform: uppercase; @media screen and (max-width: 600px) { display: block; margin: 8px 0; } &.page-label { display: flex; flex-direction: row; justify-content: center; align-items: center; background-color: $mist; color: $mediumgray; margin-bottom: 8px; width: 140px; .material-icons { margin-right: 8px; } } &.property-type-label { font-size: 12px; background-color: $darkgray; color: $white; text-transform: none; } } .api-header { margin-right: 10px; label { // The API badges should be a little smaller padding: 2px 10px; font-size: 12px; @media screen and (max-width: 600px) { margin: 4px 0; } &.api-status-label { background-color: $mediumgray; &.impure-pipe { background-color: $brightred; } &.operator { background-color: $brightred; } } &.api-type-label { background-color: $accentgrey; @each $name, $symbol in $api-symbols { &.#{$name} { background: map-get($symbol, background); } } } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_modules-dir.scss ================================================ /* ============================== MODULE STYLES ============================== */ @import 'alert'; @import 'api-pages'; @import 'api-list'; @import 'buttons'; @import 'callout'; @import 'card'; @import 'code'; @import 'contribute'; @import 'contributor'; @import 'details'; @import 'edit-page-cta'; @import 'features'; @import 'filetree'; @import 'heading-anchors'; @import 'hr'; @import 'images'; @import 'progress-bar'; @import 'table'; @import 'presskit'; @import 'resources'; @import 'scrollbar'; @import 'search-results'; @import 'subsection'; @import 'toc'; @import 'select-menu'; @import 'deploy-theme'; @import 'notification'; @import 'label'; ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_notification.scss ================================================ $notificationHeight: 56px; // we need to override some of the toolbar styling .mat-toolbar mat-toolbar-row.notification-container { padding: 0; height: auto; overflow: hidden; } aio-notification { background: $darkgray; display: flex; position: relative; align-items: center; width: 100%; height: $notificationHeight; justify-content: center; @media (max-width: 430px) { justify-content: flex-start; padding-left: 10px; } .close-button { position: absolute; top: 0; right: 0; width: $notificationHeight; height: $notificationHeight; background: $darkgray; } .content { display: flex; max-width: calc(100% - #{$notificationHeight}); text-transform: none; padding: 0; .icon { margin-right: 10px; @media (max-width: 464px) { display: none; } } .message { overflow: hidden; text-overflow: ellipsis; } .action-button { margin-left: 10px; background: $pink; border-radius: 15px; text-transform: uppercase; padding: 6px 10px; font-size: 12px; @media (max-width: 780px) { display: none; } } } } // Here are all the hacks to make the content and sidebars the right height // when the notification is visible .aio-notification-show { .sidenav-content { padding-top: 80px + $notificationHeight; } mat-sidenav.mat-sidenav.sidenav { top: 56px + $notificationHeight; @media (max-width: 600px) { top: 56px + $notificationHeight; } } .toc-container { top: 76px + $notificationHeight; } .search-results { padding-top: 68px + $notificationHeight; } &.page-home, &.page-resources, &.page-events, &.page-features, &.page-presskit, &.page-contribute { section { padding-top: $notificationHeight; } } } // Animate the content when the notification bar is dismissed // this should be kept in sync with the animation durations in // - aio/src/app/layout/notification/notification.component.ts // - aio/src/app/app.component.ts : notificationDismissed() .aio-notification-animating { .sidenav-content { transition: padding-top 250ms ease; } mat-sidenav.mat-sidenav.sidenav, .toc-container { transition: top 250ms ease; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_presskit.scss ================================================ .presskit-container { padding: 0 32px 32px 32px; h2 { color: #37474F; } .l-space-left-3 { margin-left: 3 * 8px; } .cc-by-anchor { text-decoration: underline; color: grey !important; } .presskit-row { margin: 48px 0; width: 100%; .presskit-inner { display: flex; align-items: center; @media(max-width: 599px) { flex-direction: column; } h3 { font-weight: 500; margin-top: 0; margin-bottom: 0; color: #455A64; @media(max-width: 599px) { padding-bottom: 16px; } } .transparent-img-bg { margin-top: 10px; border-radius: 4px; width: 128px; height: 128px; background-color: #34474F; } ul { padding: 0; list-style-type: none; @media(max-width: 599px) { padding: 0 !important; margin: 0 !important; } li { margin: 0 0 8px 0; } } } .presskit-image-container { @media(max-width: 599px) { text-align: center; } img { width: 128px; height: 128px; margin-bottom: 8px * 2; } } } .presskit-row:first-child { margin-top: 0; @media(max-width: 599px) { margin-top: 48px; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_progress-bar.scss ================================================ .progress-bar-container { height: 2px; overflow: hidden; position: fixed; top: 0; width: 100vw; z-index: 11; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_resources.scss ================================================ .showcase { width: 80%; } .c-resource-nav { width: 20%; } .resources-container { position: relative; } .grid-fixed:after, .grid-fixed:before { content: '.'; clear: both; display: block; overflow: hidden; visibility: hidden; font-size: 0; line-height: 0; width: 0; height: 0; } @media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) { .grid-fixed { width: auto; } } @media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) { .grid-fixed .c3, .grid-fixed .c8 { margin-left: 20px; margin-right: 20px; float: none; display: block; width: auto; } } @media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 480px) { .grid-fixed .c3, .grid-fixed .c8 { margin-left: 0px; margin-right: 0px; float: none; display: block; width: auto; } } @media handheld and (max-width: 900px), screen and (max-width: 900px) { /* line 6, ../scss/_responsive.scss */ .grid-fixed{ margin: 0 auto; *zoom: 1; } .grid-fixed:after, .grid-fixed:before, { content: '.'; clear: both; display: block; overflow: hidden; visibility: hidden; font-size: 0; line-height: 0; width: 0; height: 0; } } @media handheld and (max-width: 480px), screen and (max-width: 480px) { /* line 6, ../scss/_responsive.scss */ .grid-fixed { margin: 0 auto; *zoom: 1; } .grid-fixed:after, .grid-fixed:before { content: '.'; clear: both; display: block; overflow: hidden; visibility: hidden; font-size: 0; line-height: 0; width: 0; height: 0; } } aio-resource-list { .shadow-1 { transition: box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 1px 4px 0 rgba($black, 0.37); } .showcase { margin-bottom: 8px * 6; border-radius: 4px; } .c-resource { h4 { margin: 0; line-height: 24px; } p { margin: 0; } } .c-resource-nav { position: fixed; top: 142px; right: 32px; width: 8px * 20; z-index: 1; background-color: #fff; border-radius: 2px; a { color: #373E41; text-decoration: none; } .category { padding: 10px 0; .category-link { display: block; margin: 2px 0; padding: 3px 14px; font-size: 18px !important; &:hover { background: #edf0f2; color: #2B85E7; } } } .subcategory { .subcategory-link { display: block; margin: 2px 0; padding: 4px 14px; &:hover { background: #edf0f2; color: #2B85E7; } } } } .h-anchor-offset { display: block; position: relative; top: -20px; visibility: hidden; } .l-flex--column { display: flex; flex-direction: column; } .c-resource-header { margin-bottom: 16px; } .c-contribute { margin-bottom: 24px; } .c-resource-header h2 { margin: 0; } .subcategory-title { padding: 16px 23px; margin: 0; background-color: $mist; color: #373E41; } .h-capitalize { text-transform: capitalize; } .h-hide { display: none; } .resource-row-link { color: #1a2326; border: transparent solid 1px; margin: 0; padding: 16px 23px 16px 23px; position: relative; text-decoration: none; transition: all .3s; } .resource-row-link:hover { color: #1a2326; text-decoration: none; border-color: #2B85E7; border-radius: 4px; box-shadow: 0 8px 8px rgba(1, 67, 163, .24), 0 0 8px rgba(1, 67, 163, .12), 0 6px 18px rgba(43, 133, 231, .12); transform: translateY(-2px); } @media(max-width: 900px) { .c-resource-nav { display: none; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_scrollbar.scss ================================================ body::-webkit-scrollbar, mat-sidenav.sidenav::-webkit-scrollbar, .mat-sidenav-content::-webkit-scrollbar { height: 6px; width: 6px; } body::-webkit-scrollbar-track, mat-sidenav.sidenav::-webkit-scrollbar-track, .mat-sidenav-content::-webkit-scrollbar-track { -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); } body::-webkit-scrollbar-thumb, mat-sidenav.sidenav::-webkit-scrollbar-thumb, .mat-sidenav-content::-webkit-scrollbar-thumb { background-color: $mediumgray; outline: 1px solid $darkgray; } .search-results::-webkit-scrollbar, .toc-container::-webkit-scrollbar { height: 4px; width: 4px; } .search-results::-webkit-scrollbar-track, .toc-container::-webkit-scrollbar-track { -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); } .search-results::-webkit-scrollbar-thumb, .toc-container::-webkit-scrollbar-thumb { background-color: $mediumgray; outline: 1px solid slategrey; } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_search-results.scss ================================================ aio-search-results { z-index: 10; } .search-results { display: flex; flex-direction: row; justify-content: space-around; overflow: auto; padding: 68px 32px 0; color: $offwhite; width: auto; max-height: 95vh; position: fixed; top: 0; left: 0; right: 0; z-index: 5; background-color: $darkgray; box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.3); box-sizing: border-box; @media (max-width: 480px) { display: block; .search-area { display: block; margin: 16px 16px; } } } aio-search-results.embedded .search-results { padding: 0; color: inherit; width: auto; max-height: 100%; position: relative; background-color: inherit; box-shadow: none; box-sizing: border-box; .search-area a { color: lighten($darkgray, 10); &:hover { color: $accentgrey; } } } .search-area { display: flex; flex-direction: column; margin: 16px 16px; height: 100%; h3 { font-size: 16px; font-weight: 400; margin: 10px 0px 5px; text-transform: uppercase; } ul { margin: 0; padding: 0; li { display: inline-block; list-style: none; min-width: 270px; padding: .5em 1.5em; } } a { font-size: 14px; color: $lightgray; text-decoration: none; font-weight: normal; &:hover { color: $white; } &:visited { text-decoration: none; } span.symbol { margin-right: 8px; } } .priority-pages { padding: 0.5rem 0; a { font-weight: bold; } } @include bp(tiny) { display: block; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_select-menu.scss ================================================ /* SELECT MENU */ .form-select-menu { position: relative; } .form-select-button { background: $white; box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); box-sizing: border-box; border: 1px solid $white; color: $blue-grey-600; font-size: 12px; font-weight: 400; height: 32px; line-height: 32px; outline: none; padding: 0 16px; text-align: left; width: 100%; cursor: pointer; strong { font-weight: 600; margin-right: 8px; text-transform: uppercase; } &:focus { border: 1px solid $blue-400; box-shadow: 0 2px 2px rgba($blue-400, 0.24), 0 0 2px rgba($blue-400, 0.12); } } .form-select-dropdown { background: $white; box-shadow: 0 16px 16px rgba($black, 0.24), 0 0 16px rgba($black, 0.12); border-radius: 4px; list-style-type: none; margin: 0; padding: 0; position: absolute; top: 0; width: 100%; z-index: $layer-2; li { cursor: pointer; font-size: 14px; line-height: 32px; margin: 0; padding: 0 16px 0 40px; position: relative; transition: all .2s; &:hover { background: $blue-grey-50; color: $blue-500; } &.selected { background-color: $blue-grey-100; } .symbol { left: 16px; position: absolute; top: 8px; z-index: $layer-5; } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_subsection.scss ================================================ .l-sub-section { color: $darkgray; background-color: rgba($pink, 0.05); border-left: 8px solid $pink; padding: 16px; margin-bottom: 8px; display: table; clear: both; width: 100%; box-sizing: border-box; h3 { margin: 8px 0 0; } a:hover { color: $pink; text-decoration: underline; } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_table.scss ================================================ table { margin: 24px 0px; box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); border-radius: 2px; background: $offwhite; &.is-full-width { width: 100%; } &.is-fixed-layout { table-layout: fixed; } thead > { vertical-align: middle; border-color: inherit; tr { vertical-align: inherit; border-color: inherit; } tr > th { background: rgba($lightgray, 0.2); border-bottom: 1px solid $lightgray; color: $darkgray; font-size: 12px; font-weight: 500; padding: 8px 24px; text-align: left; text-transform: uppercase; line-height: 28px; } } tbody > tr { th, td { border-bottom: 1px solid $lightgray; padding: 16px; text-align: left; line-height: 24px; vertical-align: top; @media (max-width: 480px) { &:before { // content: **ADD TABLE HEADER**; display: inline-block; } } } td { letter-spacing: 0.30px; tr td:first-child { @media (max-width: 480px) { background-color: $lightgray; } } } th { background: rgba($lightgray, 0.2); border-right: 1px solid $lightgray; font-weight: 600; max-width: 100px; } &:last-child td { border: none; @media (max-width: 480px) { border-bottom: 1px solid $lightgray; } } } } #cheatsheet { table tbody td { overflow: auto; } @media only screen and (max-width: 990px) { /* Force table to not be like tables anymore */ table, thead, tbody, tfoot, tr, th, td { display: block; position: relative; max-width: 100%; code { padding: 0; background-color: inherit; } } th { border-right: none; } th, td { &:not(:last-child) { border-bottom: none; padding-bottom: 0px; } } } } ================================================ FILE: apps/rxjs.dev/src/styles/2-modules/_toc.scss ================================================ .toc-container { width: 18%; position: fixed; top: 76px; right: 0; bottom: 12px; overflow-y: auto; overflow-x: hidden; } aio-toc.embedded { @media (min-width: 801px) { display: none; } .toc-inner { padding: 12px 0 0 0; .toc-heading { margin: 0 0 8px; } } } .toc-inner { font-size: 13px; overflow-y: visible; padding: 4px 0 0 10px; .toc-heading, .toc-list .h1 { font-size: 115%; } .toc-heading { font-weight: 500; margin: 0 0 16px 8px; padding: 0; } .toc-heading.secondary { position: relative; top: -8px; &:hover { color: $accentgrey; } } button.toc-heading, button.toc-more-items { cursor: pointer; display: inline-block; background: 0; background-color: transparent; border: none; box-shadow: none; padding: 0; text-align: start; &.embedded:focus { outline: none; background: $lightgray; } } button.toc-heading { mat-icon.rotating-icon { height: 18px; width: 18px; position: relative; left: -4px; top: 5px; } &:hover:not(.embedded) { color: $accentgrey; } } button.toc-more-items { color: $mediumgray; top: 10px; position: relative; &:hover { color: $accentgrey; } } button.toc-more-items::after { content: 'expand_less'; } button.toc-more-items.collapsed::after { content: 'more_horiz'; } .mat-icon.collapsed { @include rotate(0deg); } .mat-icon:not(.collapsed) { @include rotate(90deg); // margin: 4px; } ul.toc-list { list-style-type: none; margin: 0; padding: 0 8px 0 0; @media (max-width: 800px) { width: auto; } li { box-sizing: border-box; font-size: 12px; line-height: 16px; padding: 3px 0 3px 12px; position: relative; transition: all 0.3s ease-in-out; &.h1:after { content: ''; display: block; height: 1px; width: 40%; margin: 7px 0 4px 0; background: #DBDBDB; clear: both; } &.h3 { padding-left: 24px; } a { font-size: inherit; color: lighten($darkgray, 10); display:table-cell; overflow: visible; font-size: 12px; display: table-cell; } &:hover a { color: $accentgrey; } &.active { a { color: $pink; font-weight: 500; &:before { content: ''; border-radius: 50%; left: -3px; top: 12px; background: $pink; position: absolute; width: 6px; height: 6px; } } } } &:not(.embedded) li { &:before { border-left: 1px solid $lightgray; bottom: 0; content: ''; left: 0; position: absolute; top: 0; } &:first-child:before { top: 13px; } &:last-child:before { bottom: calc(100% - 14px); } &:not(.active):hover a:before { content: ''; border-radius: 50%; left: -3px; top: 12px; background: $lightgray; position: absolute; width: 6px; height: 6px; } } } } aio-toc.embedded > div.collapsed li.secondary { display: none; } ================================================ FILE: apps/rxjs.dev/src/styles/_constants.scss ================================================ // TYPOGRAPHY $main-font: "Roboto","Helvetica Neue Light","Helvetica Neue",Helvetica,Arial,"Lucida Grande",sans-serif; $code-font: "Droid Sans Mono", monospace; // Z-LAYER $layer-1: 1; $layer-2: 2; $layer-3: 3; $layer-4: 4; $layer-5: 5; // COLOR PALETTE $pink: #d81b60; $accentgrey: #62757f; $brightred: #DD0031; $darkred: #C3002F; $white: #FFFFFF; $offwhite: #FAFAFA; $backgroundgray: #F1F1F1; $lightgray: #DBDBDB; $mist: #ECEFF1; $mediumgray: #6e6e6e; $darkgray: #333; $black: #0A1014; $orange: #FF9800; $anti-pattern: $brightred; // API & CODE COLORS $amber-700: #FFA000; $blue-400: #42A5F5; $blue-500: #2196F3; $blue-600: #1E88E5; $blue-800: #1565C0; $blue-900: #0D47A1; $blue-grey-50: #ECEFF1; $blue-grey-100: #CFD8DC; $blue-grey-200: #B0BEC5; $blue-grey-300: #90A4AE; $blue-grey-400: #78909C; $blue-grey-500: #607D8B; $blue-grey-600: #546E7A; $blue-grey-700: #455A64; $blue-grey-800: #37474F; $blue-grey-900: #263238; $codegreen: #17ff0b; $green-500: #4CAF50; $green-800: #2E7D32; $light-green-600: #7CB342; $pink-600: #D81B60; $purple-600: #8E24AA; $teal-500: #009688; $lightgrey: #F5F6F7; // GRADIENTS $bluegradient: linear-gradient(145deg,#0D47A1,#42A5F5); $redgradient: linear-gradient(145deg,$darkred,$brightred); // API LABEL COLOR AND SYMBOLS MAP $api-symbols: ( all: ( content: ' ', background: $white ), decorator: ( content: '@', background: $blue-800 ), directive: ( content: 'D', background: $pink-600 ), pipe: ( content: 'P', background: $blue-grey-600 ), class: ( content: 'C', background: $blue-500 ), interface: ( content: 'I', background: $teal-500 ), function: ( content: 'F', background: $green-800 ), enum: ( content: 'E', background: $amber-700 ), const: ( content: 'K', background: $mediumgray ), let: ( content: 'K', background: $mediumgray ), var: ( content: 'K', background: $mediumgray ), type-alias: ( content: 'T', background: $light-green-600 ), module: ( content: 'Pk', background: $purple-600 ) ); // OTHER $small-breakpoint-width: 840px; $phone-breakpoint: 480px; $tablet-breakpoint: 800px; ================================================ FILE: apps/rxjs.dev/src/styles/_mixins.scss ================================================ /************************************ Media queries To use these, put this snippet in the appropriate selector: @include bp(tiny) { background-color: purple; } Replace "tiny" with "medium" or "big" as necessary. *************************************/ @mixin bp($point) { $bp-xsmall: "(min-width: 320px)"; $bp-teeny: "(min-width: 480px)"; $bp-tiny: "(min-width: 600px)"; $bp-small: "(min-width: 650px)"; $bp-medium: "(min-width: 800px)"; $bp-big: "(min-width: 1000px)"; @if $point == big { @media #{$bp-big} { @content; } } @else if $point == medium { @media #{$bp-medium} { @content; } } @else if $point == small { @media #{$bp-small} { @content; } } @else if $point == tiny { @media #{$bp-tiny} { @content; } } @else if $point == teeny { @media #{$bp-teeny} { @content; } } @else if $point == xsmall { @media #{$bp-xsmall} { @content; } } } @mixin rotate($degrees) { -moz-transform: rotate($degrees); -webkit-transform: rotate($degrees); -o-transform: rotate($degrees); -ms-transform: rotate($degrees); transform: rotate($degrees); transition: transform 150ms; transition-timing-function: ease-in-out; } @mixin codeblock($bgcolor) { background-color: $bgcolor; border-radius: 5px; padding: 20px; margin: 0 auto; // was 24 border: 0.5px solid $lightgray; } // INFO CARD SKELETON @mixin card($height, $width) { height: $height; width: $width; background-color: $white; border-radius: 4px; box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12); box-sizing: border-box; transition: box-shadow .5s; &:hover { box-shadow: 0 8px 8px rgba($black, 0.24), 0 0 8px rgba($black, 0.12); text-decoration: none; } } ================================================ FILE: apps/rxjs.dev/src/styles/_typography-theme.scss ================================================ @use '@angular/material' as mat; @mixin docs-site-typography-theme($theme) { $primary: map-get($theme, primary); $accent: map-get($theme, accent); $warn: map-get($theme, warn); $background: map-get($theme, background); $foreground: map-get($theme, foreground); .docs-component-viewer-tabbed-content, .docs-guide-content { h1 { color: mat.get-color-from-palette($primary, 800); background: rgba(mat.get-color-from-palette($foreground, secondary-text), .03); } h3, h2, h4, h5, p, ol, li { color: mat.get-color-from-palette($foreground, secondary-text); } a { color: mat.get-color-from-palette($primary); } .nav-link:visited { text-decoration: none; } table { box-shadow: 0 2px 2px rgba(0,0,0,0.24), 0 0 2px rgba(0,0,0,0.12); } table > tbody > tr > th { border: 1px solid rgba(mat.get-color-from-palette($foreground, secondary-text), .03); } td { color: mat.get-color-from-palette($foreground, secondary-text); border: 1px solid rgba(mat.get-color-from-palette($foreground, secondary-text), .03); } th { color: mat.get-color-from-palette($foreground, secondary-text); background: rgba(mat.get-color-from-palette($foreground, secondary-text), .03); } } } ================================================ FILE: apps/rxjs.dev/src/styles/main.scss ================================================ // import global themes @import './rxjs-theme'; // import global variables @import './constants'; // import global mixins @import './mixins'; // import directories @import './0-base/base-dir'; @import './1-layouts/layouts-dir'; @import './2-modules/modules-dir'; ================================================ FILE: apps/rxjs.dev/src/styles/rxjs-theme.scss ================================================ @use '@angular/material' as mat; // Plus imports for other components in your app. // Include the base styles for Angular Material core. We include this here so that you only // have to load a single css file for Angular Material in your app. @include mat.core(); // Define the palettes for your theme using the Material Design palettes available in palette.scss // (imported above). For each palette, you can optionally specify a default, lighter, and darker // hue. $rxjs-primary: mat.define-palette(mat.$pink-palette, 700, 600, 800); $rxjs-accent: mat.define-palette(mat.$red-palette, 700, 600, 800); // The warn palette is optional (defaults to red). $rxjs-warn: mat.define-palette(mat.$red-palette); // Create the theme object (a Sass map containing all of the palettes). $rxjs-theme: mat.define-light-theme($rxjs-primary, $rxjs-accent, $rxjs-warn); // Include theme styles for core and each component used in your app. // Alternatively, you can import and @include the theme mixins for each component // that you are using. @include mat.all-component-themes($rxjs-theme); ================================================ FILE: apps/rxjs.dev/src/styles.scss ================================================ /* You can add global styles to this file, and also import other style files */ @import './styles/rxjs-theme'; @import './styles/main.scss'; ================================================ FILE: apps/rxjs.dev/src/test.ts ================================================ // This file is required by karma.conf.js and loads recursively all the .spec and framework files import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; declare const require: any; // Reflect.metadata polyfill is only needed in the JIT mode which we use only for unit tests import 'core-js/es/reflect'; // import 'core-js/es7/reflect'; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { teardown: { destroyAfterEach: false }, }); // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. context.keys().map(context); ================================================ FILE: apps/rxjs.dev/src/testing/doc-viewer-utils.ts ================================================ import { Component, NgModule, ViewChild, Injectable } from '@angular/core'; import { Title, Meta } from '@angular/platform-browser'; import { Observable } from 'rxjs'; import { DocumentContents } from 'app/documents/document.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { Logger } from 'app/shared/logger.service'; import { TocService } from 'app/shared/toc.service'; import { MockLogger } from 'testing/logger.service'; import { ElementsLoader } from 'app/custom-elements/elements-loader'; //////////////////////////////////////////////////////////////////////////////////////////////////// /// `TestDocViewerComponent` (for exposing internal `DocViewerComponent` methods as public). /// /// Only used for type-casting; the actual implementation is irrelevant. /// //////////////////////////////////////////////////////////////////////////////////////////////////// export class TestDocViewerComponent extends DocViewerComponent { override currViewContainer: HTMLElement; override nextViewContainer: HTMLElement; override prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { return null as any; } override render(doc: DocumentContents): Observable { return null as any; } override swapViews(onInsertedCb?: () => void): Observable { return null as any; } } //////////////////////////////////////////////////////////////////////////////////////////////////// /// `TestModule` and `TestParentComponent`. /// //////////////////////////////////////////////////////////////////////////////////////////////////// // Test parent component. @Component({ selector: 'aio-test', template: 'Test Component', }) export class TestParentComponent { currentDoc?: DocumentContents|null; @ViewChild(DocViewerComponent) docViewer: DocViewerComponent; } // Mock services. @Injectable() export class MockTitle { setTitle = jasmine.createSpy('Title#reset'); } @Injectable() export class MockMeta { addTag = jasmine.createSpy('Meta#addTag'); removeTag = jasmine.createSpy('Meta#removeTag'); } @Injectable() export class MockTocService { genToc = jasmine.createSpy('TocService#genToc'); reset = jasmine.createSpy('TocService#reset'); } @Injectable() export class MockElementsLoader { loadContainedCustomElements = jasmine.createSpy('MockElementsLoader#loadContainedCustomElements'); } @NgModule({ declarations: [ DocViewerComponent, TestParentComponent, ], providers: [ { provide: Logger, useClass: MockLogger }, { provide: Title, useClass: MockTitle }, { provide: Meta, useClass: MockMeta }, { provide: TocService, useClass: MockTocService }, { provide: ElementsLoader, useClass: MockElementsLoader }, ], }) export class TestModule { } //////////////////////////////////////////////////////////////////////////////////////////////////// /// An observable with spies to test subscribing/unsubscribing. /// //////////////////////////////////////////////////////////////////////////////////////////////////// export class ObservableWithSubscriptionSpies extends Observable { unsubscribeSpies: jasmine.Spy[] = []; subscribeSpy = spyOn(this as any, 'subscribe').and.callFake((...args: any[]) => { const subscription = super.subscribe(...args); const unsubscribeSpy = spyOn(subscription, 'unsubscribe').and.callThrough(); this.unsubscribeSpies.push(unsubscribeSpy); return subscription; }); constructor(subscriber = () => undefined) { super(subscriber); } } ================================================ FILE: apps/rxjs.dev/src/testing/location.service.ts ================================================ import { BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; export class MockLocationService { urlSubject = new BehaviorSubject(this.initialUrl); currentUrl = this.urlSubject.asObservable().pipe(map(url => this.stripSlashes(url))); // strip off query and hash currentPath = this.currentUrl.pipe(map(url => url.match(/[^?#]*/)![0])); search = jasmine.createSpy('search').and.returnValue({}); setSearch = jasmine.createSpy('setSearch'); go = jasmine.createSpy('Location.go').and .callFake((url: string) => this.urlSubject.next(url)); goExternal = jasmine.createSpy('Location.goExternal'); replace = jasmine.createSpy('Location.replace'); handleAnchorClick = jasmine.createSpy('Location.handleAnchorClick') .and.returnValue(false); // prevent click from causing a browser navigation constructor(private initialUrl: string) {} private stripSlashes(url: string) { return url.replace(/^\/+/, '').replace(/\/+(\?|#|$)/, '$1'); } } ================================================ FILE: apps/rxjs.dev/src/testing/logger.service.ts ================================================ import { Injectable } from '@angular/core'; @Injectable() export class MockLogger { output: { log: any[], error: any[], warn: any[] } = { log: [], error: [], warn: [] }; log(value: any, ...rest: any[]) { this.output.log.push([value, ...rest]); } error(value: any, ...rest: any[]) { this.output.error.push([value, ...rest]); } warn(value: any, ...rest: any[]) { this.output.warn.push([value, ...rest]); } } ================================================ FILE: apps/rxjs.dev/src/testing/search.service.ts ================================================ import { Subject } from 'rxjs'; import { SearchResults } from 'app/search/interfaces'; export class MockSearchService { searchResults = new Subject(); initWorker = jasmine.createSpy('initWorker'); loadIndex = jasmine.createSpy('loadIndex'); search = jasmine.createSpy('search'); } ================================================ FILE: apps/rxjs.dev/src/typings.d.ts ================================================ /* SystemJS module definition */ declare var module: NodeModule; interface NodeModule { id: string; } ================================================ FILE: apps/rxjs.dev/tests/e2e/protractor.conf.js ================================================ // Protractor configuration file, see link for more information // https://github.com/angular/protractor/blob/master/lib/config.ts const { SpecReporter } = require('jasmine-spec-reporter'); exports.config = { allScriptsTimeout: 11000, specs: [ './*.e2e-spec.ts' ], capabilities: { browserName: 'chrome', // For Travis chromeOptions: { binary: process.env.CHROME_BIN, args: ['--no-sandbox'] } }, directConnect: true, baseUrl: 'http://localhost:4200/', framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, beforeLaunch: function() { require('ts-node').register({ project: 'tests/e2e/tsconfig.e2e.json' }); }, onPrepare() { jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); } }; ================================================ FILE: apps/rxjs.dev/tests/e2e/tsconfig.e2e.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/e2e", "baseUrl": "./", "module": "commonjs", "target": "es5", "types": [ "jasmine", "jasminewd2", "node" ] } } ================================================ FILE: apps/rxjs.dev/tests/e2e/visual-testing.e2e-spec.ts ================================================ import { browser } from 'protractor'; const Eyes = require('eyes.selenium').Eyes; const eyes = new Eyes(); describe('RxJS Docs', function() { it('shows the landing page', () => { eyes.open(browser, 'Landing Page', 'RxJS Docs'); browser.get(''); eyes.checkWindow('Landing page!'); eyes.close(); }); it('shows the overview page', () => { eyes.open(browser, 'Overview Page', 'RxJS Docs'); browser.get('/guide/overview'); eyes.checkWindow('Overview page!'); eyes.close(); }); it('shows the API page', () => { eyes.open(browser, 'API Page', 'RxJS Docs'); browser.get('/api'); eyes.checkWindow('API page!'); eyes.close(); }); it('shows the migration page', () => { eyes.open(browser, 'Migration Page', 'RxJS Docs'); browser.get('/guide/v6/migration'); eyes.checkWindow('Migration page!'); eyes.close(); }); it('shows the team page', () => { eyes.open(browser, 'Team Page', 'RxJS Docs'); browser.get('/team'); eyes.checkWindow('Team page!'); eyes.close(); }); }); ================================================ FILE: apps/rxjs.dev/tools/README.md ================================================ # AIO project tooling This document gives an overview of the tools that we use to generate the content for the RxJS website. The application that actually renders this content can be found in the `apps/rxjs.dev/src` folder. The handwritten content can be found in the `apps/rxjs.dev/content` folder. Each subfolder in this `apps/rxjs.dev/tools/` folder contains a self-contained tool and its configuration. There is a `README.md` file in each folder that describes the tool in more detail. ## transforms All the content that is rendered by the RxJS docs application, and some of its configuration files, are generated from source files by [Dgeni](https://github.com/angular/dgeni). Dgeni is a general purpose documentation generation tool. Markdown files in `apps/rxjs.dev/content`, code comments in the core Angular source files and example files are processed and transformed into files that are consumed by the RxJS docs application. Dgeni is configured by "packages", which contain services and processors. Some of these packages are installed as `node_modules` from the [dgeni-packages](https://github.com/angular/dgeni-packages) and some are specific to the RxJS project. The project specific packages are stored in the `apps/rxjs.dev/tools/transforms` folder. See the [README.md](transforms/README.md) for more details. ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseGlob.spec.ts ================================================ import { FirebaseGlob } from './FirebaseGlob'; describe('FirebaseGlob', () => { describe('test', () => { it('should match * parts', () => { testGlob('asdf/*.jpg', ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], ['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx']); }); it('should match ** parts', () => { testGlob('asdf/**.jpg', // treated like two consecutive single `*`s ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], ['asdf/a/.jpg', 'asdf/a/b.jpg', '/asdf/asdf.jpg', 'asdff/asdf.jpg', 'xxxasdf/asdf.jpg', 'asdf/asdf.jpgxxx']); }); it('should match **/ and /**/', () => { testGlob('**/*.js', ['asdf.js', 'asdf/asdf.js', 'asdf/asdf/asdfasdf_asdf.js', '/asdf/asdf.js', '/asdf/aasdf-asdf.2.1.4.js'], ['asdf/asdf.jpg', '/asdf/asdf.jpg']); testGlob('aaa/**/bbb', ['aaa/xxx/bbb', 'aaa/xxx/yyy/bbb', 'aaa/bbb'], ['/aaa/xxx/bbb', 'aaa/x/bbb/', 'aaa/bbb/ccc']); }); it('should match choice groups', () => { testGlob('aaa/*.@(bbb|ccc)', ['aaa/aaa.bbb', 'aaa/aaa_aaa.ccc'], ['/aaa/aaa.bbb', 'aaaf/aaa.bbb', 'aaa/aaa.ddd']); testGlob('aaa/*(bbb|ccc)', ['aaa/', 'aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], ['aaa/aaa', 'aaa/bbbb']); testGlob('aaa/+(bbb|ccc)', ['aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], ['aaa/', 'aaa/aaa', 'aaa/bbbb']); testGlob('aaa/?(bbb|ccc)', ['aaa/', 'aaa/bbb', 'aaa/ccc'], ['aaa/aaa', 'aaa/bbbb', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb']); }); it('should error on non-supported choice groups', () => { expect(() => new FirebaseGlob('/!(a|b)/c')) .toThrowError('Error in FirebaseGlob: "/!(a|b)/c" - "not" expansions are not supported: "!(a|b)"'); expect(() => new FirebaseGlob('/(a|b)/c')) .toThrowError('Error in FirebaseGlob: "/(a|b)/c" - unknown expansion type: "/" in "/(a|b)"'); expect(() => new FirebaseGlob('/&(a|b)/c')) .toThrowError('Error in FirebaseGlob: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"'); }); // Globs that contain params tested via the match tests below }); describe('match', () => { it('should match patterns with no parameters', () => { testMatch('/abc/def/*', { }, { '/abc/def/': {}, '/abc/def/ghi': {}, '/': undefined, '/abc': undefined, '/abc/def/ghi/jk;': undefined, }); }); it('should capture a simple named param', () => { testMatch('/:abc', { named: ['abc'] }, { '/a': {abc: 'a'}, '/abc': {abc: 'abc'}, '/': undefined, '/a/': undefined, '/a/b/': undefined, '/a/a/b': undefined, '/a/a/b/': undefined, }); testMatch('/a/:b', { named: ['b'] }, { '/a/b': {b: 'b'}, '/a/bcd': {b: 'bcd'}, '/a/': undefined, '/a/b/': undefined, '/a': undefined, '/a//': undefined, '/a/a/b': undefined, '/a/a/b/': undefined, }); }); it('should capture a named param followed by non-word chars', () => { testMatch('/a/:x-', { named: ['x'] }, { '/a/b-': {x: 'b'}, '/a/bcd-': {x: 'bcd'}, '/a/--': {x: '-'}, '/a': undefined, '/a/-': undefined, '/a/-/': undefined, '/a/': undefined, '/a/b/-': undefined, '/a/b-c': undefined, }); }); it('should capture multiple named params', () => { testMatch('/a/:b/:c', { named: ['b', 'c'] }, { '/a/b/c': {b: 'b', c: 'c'}, '/a/bcd/efg': {b: 'bcd', c: 'efg'}, '/a/b/c-': {b: 'b', c: 'c-'}, '/a/': undefined, '/a/b/': undefined, '/a/b/c/': undefined, }); testMatch('/:a/b/:c', { named: ['a', 'c'] }, { '/a/b/c': {a: 'a', c: 'c'}, '/abc/b/efg': {a: 'abc', c: 'efg'}, '/a/b/c-': {a: 'a', c: 'c-'}, '/a/': undefined, '/a/b/': undefined, '/a/b/c/': undefined, }); }); it('should capture a simple rest param', () => { testMatch('/:abc*', { rest: ['abc'] }, { '/a': {abc: 'a'}, '/a/b': {abc: 'a/b'}, '/a/bcd': {abc: 'a/bcd'}, '/a/': {abc: 'a/'}, '/a/b/': {abc: 'a/b/'}, '/a//': {abc: 'a//'}, '/a/b/c': {abc: 'a/b/c'}, '/a/b/c/': {abc: 'a/b/c/'}, }); testMatch('/a/:b*', { rest: ['b'] }, { '/a/b': {b: 'b'}, '/a/bcd': {b: 'bcd'}, '/a/': {b: ''}, '/a/b/': {b: 'b/'}, '/a': {b: undefined}, '/a//': {b: '/'}, '/a/a/b': {b: 'a/b'}, '/a/a/b/': {b: 'a/b/'}, }); }); it('should capture a rest param mixed with a named param', () => { testMatch('/:abc/:rest*', { named: ['abc'], rest: ['rest'] }, { '/a': {abc: 'a', rest: undefined}, '/a/b': {abc: 'a', rest: 'b'}, '/a/bcd': {abc: 'a', rest: 'bcd'}, '/a/': {abc: 'a', rest: ''}, '/a/b/': {abc: 'a', rest: 'b/'}, '/a//': {abc: 'a', rest: '/'}, '/a/b/c': {abc: 'a', rest: 'b/c'}, '/a/b/c/': {abc: 'a', rest: 'b/c/'}, }); }); }); }); function testGlob(pattern: string, matches: string[], nonMatches: string[]) { const glob = new FirebaseGlob(pattern); matches.forEach(url => expect(glob.test(url)).toBe(true, url)); nonMatches.forEach(url => expect(glob.test(url)).toBe(false, url)); } function testMatch(pattern: string, captures: { named?: string[], rest?: string[] }, matches: { [url: string]: object|undefined }) { const glob = new FirebaseGlob(pattern); expect(Object.keys(glob.namedParams)).toEqual(captures.named || []); expect(Object.keys(glob.restParams)).toEqual(captures.rest || []); Object.keys(matches).forEach(url => expect(glob.match(url)).toEqual(matches[url])); } ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseGlob.ts ================================================ import * as XRegExp from 'xregexp'; const dot = /\./g; const star = /\*/g; const doubleStar = /(^|\/)\*\*($|\/)/g; // e.g. a/**/b or **/b or a/** but not a**b const modifiedPatterns = /(.)\(([^)]+)\)/g; // e.g. `@(a|b) const restParam = /\/:([A-Za-z]+)\*/g; // e.g. `:rest*` const namedParam = /\/:([A-Za-z]+)/g; // e.g. `:api` const possiblyEmptyInitialSegments = /^\.🐷\//g; // e.g. `**/a` can also match `a` const possiblyEmptySegments = /\/\.🐷\//g; // e.g. `a/**/b` can also match `a/b` const willBeStar = /🐷/g; // e.g. `a**b` not matched by previous rule export class FirebaseGlob { pattern: string; regex: XRegExp; namedParams: { [key: string]: boolean } = {}; restParams: { [key: string]: boolean } = {}; constructor(glob: string) { try { const pattern = glob .replace(dot, '\\.') .replace(modifiedPatterns, replaceModifiedPattern) .replace(restParam, (_, param) => { // capture the rest of the string this.restParams[param] = true; return `(?:/(?<${param}>.🐷))?`; }) .replace(namedParam, (_, param) => { // capture the named parameter this.namedParams[param] = true; return `/(?<${param}>[^/]+)`; }) .replace(doubleStar, '$1.🐷$2') // use the pig to avoid replacing ** in next rule .replace(star, '[^/]*') // match a single segment .replace(possiblyEmptyInitialSegments, '(?:.*)')// deal with **/ special cases .replace(possiblyEmptySegments, '(?:/|/.*/)') // deal with /**/ special cases .replace(willBeStar, '*'); // other ** matches this.pattern = `^${pattern}$`; this.regex = XRegExp(this.pattern); } catch (e) { throw new Error(`Error in FirebaseGlob: "${glob}" - ${e.message}`); } } test(url: string) { return XRegExp.test(url, this.regex); } match(url: string) { const match = XRegExp.exec(url, this.regex); if (match) { const result = {}; const names = (this.regex as any).xregexp.captureNames || []; names.forEach(name => result[name] = match[name]); return result; } } } function replaceModifiedPattern(_, modifier, pattern) { switch (modifier) { case '!': throw new Error(`"not" expansions are not supported: "${_}"`); case '?': case '+': return `(${pattern})${modifier}`; case '*': return `(${pattern})🐷`; // it will become a star case '@': return `(${pattern})`; default: throw new Error(`unknown expansion type: "${modifier}" in "${_}"`); } } ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseRedirect.spec.ts ================================================ import { FirebaseRedirect } from './FirebaseRedirect'; describe('FirebaseRedirect', () => { describe('replace', () => { it('should return undefined if the redirect does not match the url', () => { const redirect = new FirebaseRedirect('/a/b/c', '/x/y/z'); expect(redirect.replace('/1/2/3')).toBe(undefined); }); it('should return the destination if there is a match', () => { const redirect = new FirebaseRedirect('/a/b/c', '/x/y/z'); expect(redirect.replace('/a/b/c')).toBe('/x/y/z'); }); it('should inject name params into the destination', () => { const redirect = new FirebaseRedirect('/api/:package/:api-*', '<:package><:api>'); expect(redirect.replace('/api/common/NgClass-directive')).toEqual(''); }); it('should inject rest params into the destination', () => { const redirect = new FirebaseRedirect('/a/:rest*', '/x/:rest*/y'); expect(redirect.replace('/a/b/c')).toEqual('/x/b/c/y'); }); it('should inject both named and rest parameters into the destination', () => { const redirect = new FirebaseRedirect('/:a/:rest*', '/x/:a/y/:rest*/z'); expect(redirect.replace('/a/b/c')).toEqual('/x/a/y/b/c/z'); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseRedirect.ts ================================================ import * as XRegExp from 'xregexp'; import { FirebaseGlob } from './FirebaseGlob'; export class FirebaseRedirect { glob = new FirebaseGlob(this.source); constructor(public source: string, public destination: string) {} replace(url: string) { const match = this.glob.match(url); if (match) { const paramReplacers = Object.keys(this.glob.namedParams).map(name => [ XRegExp(`:${name}`, 'g'), match[name] ]); const restReplacers = Object.keys(this.glob.restParams).map(name => [ XRegExp(`:${name}\\*`, 'g'), match[name] ]); return XRegExp.replaceEach(this.destination, [...paramReplacers, ...restReplacers]); } } } ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseRedirector.spec.ts ================================================ import { FirebaseRedirector } from './FirebaseRedirector'; describe('FirebaseRedirector', () => { it('should replace with the first matching redirect', () => { const redirector = new FirebaseRedirector([ { source: '/a/b/c', destination: '/X/Y/Z' }, { source: '/a/:foo/c', destination: '/X/:foo/Z' }, { source: '/**/:foo/c', destination: '/A/:foo/zzz' }, ]); expect(redirector.redirect('/a/b/c')).toEqual('/X/Y/Z'); expect(redirector.redirect('/a/moo/c')).toEqual('/X/moo/Z'); expect(redirector.redirect('/x/y/a/b/c')).toEqual('/A/b/zzz'); expect(redirector.redirect('/x/y/c')).toEqual('/A/y/zzz'); }); it('should return the original url if no redirect matches', () => { const redirector = new FirebaseRedirector([ { source: 'x', destination: 'X' }, { source: 'y', destination: 'Y' }, { source: 'z', destination: 'Z' }, ]); expect(redirector.redirect('a')).toEqual('a'); }); it('should recursively redirect', () => { const redirector = new FirebaseRedirector([ { source: 'a', destination: 'b' }, { source: 'b', destination: 'c' }, { source: 'c', destination: 'd' }, ]); expect(redirector.redirect('a')).toEqual('d'); }); it('should throw if stuck in an infinite loop', () => { const redirector = new FirebaseRedirector([ { source: 'a', destination: 'b' }, { source: 'b', destination: 'c' }, { source: 'c', destination: 'a' }, ]); expect(() => redirector.redirect('a')).toThrowError('infinite redirect loop'); }); }); ================================================ FILE: apps/rxjs.dev/tools/firebase-test-utils/FirebaseRedirector.ts ================================================ import { FirebaseRedirect } from './FirebaseRedirect'; export interface FirebaseRedirectConfig { source: string; destination: string; } export class FirebaseRedirector { private redirects: FirebaseRedirect[]; constructor(redirects: FirebaseRedirectConfig[]) { this.redirects = redirects.map(redirect => new FirebaseRedirect(redirect.source, redirect.destination)); } redirect(url: string) { let ttl = 50; while (ttl > 0) { const newUrl = this.doRedirect(url); if (newUrl === url) { return url; } else { url = newUrl; ttl--; } } throw new Error('infinite redirect loop'); } private doRedirect(url: string) { for (const redirect of this.redirects) { const newUrl = redirect.replace(url); if (newUrl !== undefined) { return newUrl; } } return url; } } ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/audit.txt ================================================ -a-xy-----b--x--cxyz-| ----i ----i ----i > audit() -----y--------x-----z| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/bufferWhen.txt ================================================ [styles] event_radius = 33 operator_height = 60 completion_height = 80 ---a---b---c---d---e---f---g---h---| -------------s > bufferWhen() -------------x------------y--------(z|) x := [a, b, c] y := [d, e, f] z := [g, h] ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/concatAll.txt ================================================ x = ----a------b------| y = ---c-d---| z = ---e--f-| -x---y----z------| > concatAll -----a------b---------c-d------e--f-| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/debounce.txt ================================================ -a----bc----d-ef----| ---x ---x ---x > debounce() ----a-----c-------f-| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/delay.txt ================================================ [styles] event_radius = 15 ---a--b--c---| > delay(20) -----a--b--c-| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/exhaustAll.txt ================================================ x = --a---b---c--| y = ---d--e---f---| z = ---g--h---i---| ------x-------y------z--| ghosts = y > exhaustAll --------a---b---c-------g--h---i---| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/throttle.txt ================================================ -a-xy-----b--x--cxyz-| ----i ----i ----i > throttle() -a--------b-----c----| ================================================ FILE: apps/rxjs.dev/tools/marbles/diagrams/windowWhen.txt ================================================ [styles] event_radius = 33 operator_height = 60 completion_height = 80 ---a---b---c---d---e---f---g---h---| -------------x| -------------x| -------------x| > windowWhen() x = ---a---b---c-| y = --d---e---f---g| z = -g---h---| x------------y------------z--------| ================================================ FILE: apps/rxjs.dev/tools/marbles/scripts/index.ts ================================================ import { renderMarbleDiagram } from '@swirly/renderer-node'; import { readdir, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { parseMarbleDiagramSpecification } from '@swirly/parser'; import { DiagramStyles } from '@swirly/types'; import * as SVGO from 'svgo'; const styles: DiagramStyles = { frame_width: 20, completion_height: 20, higher_order_angle: 30, arrow_fill_color: 'black', background_color: 'rgba(255, 255, 255, 0.0)', operator_fill_color: 'rgba(255, 255, 255, 0.0)' }; const optimizeXml = async (unoptXml: string): Promise => { const svgo = new SVGO({ plugins: [{ removeViewBox: false }] }); const { data } = await svgo.optimize(unoptXml); return data; }; const renderMarble = (path: string, fileName: string): Promise => { const file = readFileSync(join(path, fileName)); const diagramSpec = parseMarbleDiagramSpecification(file.toString()); const { xml: unoptXml } = renderMarbleDiagram(diagramSpec, { styles }); const optimizedSVGPromise = optimizeXml(unoptXml); return optimizedSVGPromise.then((svgXML) => { const svgFileName = fileName.split('.')[0] + '.svg'; const svgPath = join(process.cwd(), 'src', 'assets', 'images', 'marble-diagrams', svgFileName); writeFileSync(svgPath, svgXML, { encoding: 'utf-8', flag: 'w' }); return true; }); }; const diagramsPath = join(process.cwd(), 'tools', 'marbles', 'diagrams'); readdir(diagramsPath, (err, files) => { Promise.all(files.map(fileName => renderMarble(diagramsPath, fileName))) .then(_ => console.log('All SVGs created')) .catch(e => console.error('generating SVGs failed', e)); }); ================================================ FILE: apps/rxjs.dev/tools/marbles/tsconfig.marbles.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "module": "commonjs" } } ================================================ FILE: apps/rxjs.dev/tools/stackblitz/rxjs.version.js ================================================ // Exposes the current RxJS version number from the library's package.json // for usage in TypeScript files. // (Since said package.json is outside of this TypeScript project, it's not // available for a direct TypeScript import). module.exports = require('../../../../packages/rxjs/package.json').version; ================================================ FILE: apps/rxjs.dev/tools/transforms/.eslintignore ================================================ **/*.template.js ================================================ FILE: apps/rxjs.dev/tools/transforms/.eslintrc.js ================================================ module.exports = { root: true, env: { es6: true, jasmine: true, node: true, }, extends: ['eslint:recommended', 'plugin:jasmine/recommended'], parserOptions: { ecmaVersion: 2020, }, plugins: ['jasmine'], rules: { 'linebreak-style': ['error', 'unix'], 'no-prototype-builtins': ['off'], quotes: ['error', 'single'], semi: ['error', 'always'], 'jasmine/new-line-before-expect': ['off'], }, }; ================================================ FILE: apps/rxjs.dev/tools/transforms/README.md ================================================ # Overview All the content that is rendered by the AIO application, and some of its configuration files, are generated from source files by [Dgeni](https://github.com/angular/dgeni). Dgeni is a general purpose documentation generation tool. Markdown files in `/aio/content`, code comments in the core Angular source files and example files are processed and transformed into files that are consumed by the AIO application. Dgeni is configured by "packages", which contain services and processors. Some of these packages are installed as `node_modules` from the [dgeni-packages](https://github.com/angular/dgeni-packages) and some are specific to the AIO project. The project specific packages are stored in this folder (`aio/tools/transforms`). If you are an author and want to know how to generate the documentation, the steps are outlined in the top level [README.md](../../README.md#guide-to-authoring). ## Root packages To run Dgeni, you must specify a root package, which acts as the entry point to the documentation generation. This root package, in turn requires a number of other packages, some are defined locally in the `tools/transforms` folder, such as `tools/transforms/cheatsheet-package` and `tools/transforms/content-package`, etc. And some are brought in from the `dgeni-packages` node modules, such as `jsdoc` and `nunjucks`. * The primary root package is defined in `tools/transforms/angular.io-package/index.js`. This package is used to run a full generation of all the documentation. * There are also root packages defined in `tools/transforms/authors-package/*-package.js`. These packages are used by the documentation authors when writing docs, since it allows them to run partial doc generation, which is not complete but is faster for quickly seeing changes to the document that you are working on. ## Other packages * angular-base-package * angular-api-package * angular-content-package * content-package * links-package * post-process-package * remark-package * target-package ## Templates All the templates for the angular.io dgeni transformations are stored in the `tools/transforms/templates` folder. See the [README](./templates/README.md). ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/index.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const basePackage = require('../angular-base-package'); const typeScriptPackage = require('dgeni-packages/typescript'); // prettier-ignore const { API_SOURCE_PATH, API_TEMPLATES_PATH, MARBLE_IMAGES_PATH, MARBLE_IMAGES_WEB_PATH, requireFolder } = require('../config'); // prettier-ignore module.exports = new Package('angular-api', [basePackage, typeScriptPackage]) // Register the processors .processor(require('./processors/migrateLegacyJSDocTags')) .processor(require('./processors/splitDescription')) .processor(require('./processors/convertPrivateClassesToInterfaces')) .processor(require('./processors/generateApiListDoc')) .processor(require('./processors/generateDeprecationsListDoc')) .processor(require('./processors/mergeDecoratorDocs')) .processor(require('./processors/extractDecoratedClasses')) .processor(require('./processors/matchUpDirectiveDecorators')) .processor(require('./processors/addMetadataAliases')) .processor(require('./processors/computeApiBreadCrumbs')) .processor(require('./processors/filterContainedDocs')) .processor(require('./processors/processClassLikeMembers')) .processor(require('./processors/markBarredODocsAsPrivate')) .processor(require('./processors/filterPrivateDocs')) .processor(require('./processors/computeSearchTitle')) .processor(require('./processors/simplifyMemberAnchors')) .processor(require('./processors/computeStability')) .processor(require('./processors/markAliases').markAliases) .processor(require('./processors/checkOperator')) .factory(require('./post-processors/embedMarbleDiagrams')) /** * These are the API doc types that will be rendered to actual files. * This is a super set of the exported docs, since we convert some classes to * more Angular specific API types, such as decorators and directives. */ .factory(function API_DOC_TYPES_TO_RENDER(EXPORT_DOC_TYPES) { return EXPORT_DOC_TYPES.concat(['decorator', 'directive', 'pipe', 'module', 'deprecation']); }) /** * These are the doc types that are API docs, including ones that will be merged into container docs, * such as members and overloads. */ .factory(function API_DOC_TYPES(API_DOC_TYPES_TO_RENDER) { return API_DOC_TYPES_TO_RENDER.concat(['member', 'function-overload']); }) // Where do we get the source files? .config(function (readTypeScriptModules) { // API files are typescript readTypeScriptModules.basePath = API_SOURCE_PATH; readTypeScriptModules.ignoreExportsMatching = [/^[_ɵ]|^VERSION$/]; readTypeScriptModules.hidePrivateMembers = true; // NOTE: This list should be in sync with tools/public_api_guard/BUILD.bazel readTypeScriptModules.sourceFiles = [ 'index.ts', 'operators/index.ts', 'ajax/index.ts', 'fetch/index.ts', 'webSocket/index.ts', 'testing/index.ts' ]; }) // Configure jsdoc-style tag parsing .config(function (parseTagsProcessor, getInjectables) { // Load up all the tag definitions in the tag-defs folder parseTagsProcessor.tagDefinitions = parseTagsProcessor.tagDefinitions.concat(getInjectables(requireFolder(__dirname, './tag-defs'))); }) .config(function (computeStability, splitDescription, EXPORT_DOC_TYPES, API_DOC_TYPES) { computeStability.docTypes = EXPORT_DOC_TYPES; // Only split the description on the API docs splitDescription.docTypes = API_DOC_TYPES; }) .config(function (computePathsProcessor, EXPORT_DOC_TYPES, generateApiListDoc, generateDeprecationListDoc) { const API_SEGMENT = 'api'; generateApiListDoc.outputFolder = API_SEGMENT; generateDeprecationListDoc.outputFolder = API_SEGMENT; computePathsProcessor.pathTemplates.push({ docTypes: ['module'], getPath: function computeModulePath(doc) { doc.moduleFolder = `${API_SEGMENT}/${doc.id.replace(/\/index$/, '')}`; return doc.moduleFolder; }, outputPathTemplate: '${moduleFolder}.json' }); computePathsProcessor.pathTemplates.push({ docTypes: EXPORT_DOC_TYPES.concat(['decorator', 'directive', 'pipe']), pathTemplate: '${moduleDoc.moduleFolder}/${name}', outputPathTemplate: '${moduleDoc.moduleFolder}/${name}.json', }); computePathsProcessor.pathTemplates.push({ docTypes: ['const', 'function', 'interface', 'class', 'type-alias'], getPath: (doc) => { return `${API_SEGMENT}/${doc.id.replace(/^index\//, `index/${doc.docType}/`)}`; }, outputPathTemplate: '${path}.json', }); }) .config(function (templateFinder) { // Where to find the templates for the API doc rendering templateFinder.templateFolders.unshift(API_TEMPLATES_PATH); }) .config(function (embedMarbleDiagramsPostProcessor) { embedMarbleDiagramsPostProcessor.marbleImagesPath = MARBLE_IMAGES_PATH; embedMarbleDiagramsPostProcessor.marbleImagesOutputWebPath = `/${MARBLE_IMAGES_WEB_PATH}`; }) .config(function (convertToJsonProcessor, postProcessHtml, API_DOC_TYPES_TO_RENDER, API_DOC_TYPES, autoLinkCode, embedMarbleDiagramsPostProcessor) { convertToJsonProcessor.docTypes = convertToJsonProcessor.docTypes.concat(API_DOC_TYPES_TO_RENDER); postProcessHtml.docTypes = convertToJsonProcessor.docTypes.concat(API_DOC_TYPES_TO_RENDER); postProcessHtml.plugins.push(embedMarbleDiagramsPostProcessor.process); autoLinkCode.docTypes = API_DOC_TYPES; autoLinkCode.codeElements = ['code', 'code-example', 'code-pane']; }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/mocks/aliasedExports.ts ================================================ export * from './operator'; export { operator as aliasedOperator } from './operator'; export { operator } from './operator'; export { anotherOperator as operatorWithoutDuplicate } from './anotherOperator'; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/mocks/anotherOperator.ts ================================================ export const anotherOperator = () => {}; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/mocks/importedSrc.ts ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ export const x = 100; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/mocks/operator.ts ================================================ export const operator = () => {}; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/mocks/testSrc.ts ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * @module * @description * This is the module description */ export * from './importedSrc'; /** * This is some random other comment */ /** * This is MyClass */ export class MyClass { message: String; /** * Create a new MyClass * @param {String} name The name to say hello to */ constructor(name) { this.message = 'hello ' + name; } /** * Return a greeting message */ greet() { return this.message; } } /** * An exported function */ export const myFn = (val: number) => val * 2; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/post-processors/embedMarbleDiagrams.js ================================================ const fs = require('fs'); const path = require('path'); const visit = require('unist-util-visit'); const is = require('hast-util-is-element'); /** * Find pre-rendered marble diagrams and override their `src` attributes in docs. */ module.exports = function embedMarbleDiagramsPostProcessor() { const service = { marbleImagesPath: null, marbleImagesOutputWebPath: null, process: () => { return (tree) => { visit(tree, node => { if (is(node, 'img')) { const props = node.properties; const src = props.src; const expectedImgPath = `${service.marbleImagesPath}/${src}`; if (fs.existsSync(expectedImgPath)) { const operator = path.basename(src, path.extname(src)); const filename = path.basename(expectedImgPath); props.src = `${service.marbleImagesOutputWebPath}/${filename}`; props.width = '100%'; if (!props.alt) { props.alt = `${operator} marble diagram`; } } } }); }; }, }; return service; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/addMetadataAliases.js ================================================ const CssSelectorParser = require('css-selector-parser').CssSelectorParser; const cssParser = new CssSelectorParser(); /** * @dgProcessor addMetadataAliases * * Directives and components can also be referenced by their selectors, * and Pipes can be referenced by their name. * So let's add each selector as an alias to this doc. */ module.exports = function addMetadataAliasesProcessor() { return { $runAfter: ['extractDecoratedClassesProcessor'], $runBefore: ['computing-ids'], $process: function(docs) { docs.forEach(doc => { switch(doc.docType) { case 'directive': case 'component': doc.aliases = doc.aliases.concat(extractSelectors(doc[doc.docType + 'Options'].selector)); break; case 'pipe': if (doc.pipeOptions.name) { doc.aliases = doc.aliases.concat(stripQuotes(doc.pipeOptions.name)); } break; } }); } }; }; function extractSelectors(selectors) { const selectorAST = cssParser.parse(stripQuotes(selectors)); const rules = selectorAST.selectors ? selectorAST.selectors.map(ruleSet => ruleSet.rule) : [selectorAST.rule]; const aliases = {}; rules.forEach(rule => { if (rule.tagName) { aliases[rule.tagName] = true; } if (rule.attrs) { rule.attrs.forEach(attr => aliases[attr.name] = true); } }); return Object.keys(aliases); } function stripQuotes(value) { return (typeof(value) === 'string') ? value.trim().replace(/^(['"])(.*)\1$/, '$2') : value; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/addMetadataAliases.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./addMetadataAliases'); const Dgeni = require('dgeni'); describe('addSelectorsAsAliases processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('addMetadataAliasesProcessor'); expect(processor.$process).toBeDefined(); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['extractDecoratedClassesProcessor']); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['computing-ids']); }); it('should add new aliases for directives, components and pipes', () => { const processor = processorFactory(); const docs = [ { docType: 'class', name: 'MyClass', aliases: ['MyClass'] }, { docType: 'interface', name: 'MyInterface', aliases: ['MyInterface'] }, { docType: 'enum', name: 'MyEnum', aliases: ['MyEnum'] }, { docType: 'function', name: 'myFunction', aliases: ['myFunction'] }, { docType: 'pipe', name: 'MyPipe', aliases: ['MyPipe'], pipeOptions: { name: '\'myPipe\'' } }, { docType: 'directive', name: 'MyDirective', aliases: ['MyDirective'], directiveOptions: { selector: '\'my-directive,[myDirective],[my-directive]\'' } }, { docType: 'directive', name: 'NgModel', aliases: ['NgModel'], directiveOptions: { selector: '\'[ngModel]:not([formControlName]):not([formControl])\'' } }, { docType: 'component', name: 'MyComponent', aliases: ['MyComponent'], componentOptions: { selector: '\'my-component\'' } }, { docType: 'decorator', name: 'MyDecorator', aliases: ['MyDecorator'] }, { docType: 'module', name: 'myModule', aliases: ['myModule'], id: 'some/myModule' }, { docType: 'var', name: 'myVar', aliases: ['myVar'] }, { docType: 'let', name: 'myLet', aliases: ['myLet'] }, { docType: 'const', name: 'myConst', aliases: ['myConst'] }, { docType: 'type-alias', name: 'myType', aliases: ['myType'] }, ]; processor.$process(docs); expect(docs[0].aliases).toEqual([docs[0].name]); expect(docs[1].aliases).toEqual([docs[1].name]); expect(docs[2].aliases).toEqual([docs[2].name]); expect(docs[3].aliases).toEqual([docs[3].name]); expect(docs[4].aliases).toEqual([docs[4].name, 'myPipe']); expect(docs[5].aliases).toEqual([docs[5].name, 'my-directive', 'myDirective']); expect(docs[6].aliases).toEqual([docs[6].name, 'ngModel']); expect(docs[7].aliases).toEqual([docs[7].name, 'my-component']); expect(docs[8].aliases).toEqual([docs[8].name]); expect(docs[9].aliases).toEqual([docs[9].name]); expect(docs[10].aliases).toEqual([docs[10].name]); expect(docs[11].aliases).toEqual([docs[11].name]); expect(docs[12].aliases).toEqual([docs[12].name]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/checkOperator.js ================================================ module.exports = function checkOperator() { return { $runAfter: ['generateApiListDoc'], $runBefore: ['rendering-docs'], $process(docs) { docs.forEach((doc) => { doc.isOperator = !!(doc.originalModule?.startsWith('internal/operators') && doc.docType === 'function'); }); }, }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeApiBreadCrumbs.js ================================================ module.exports = function computeApiBreadCrumbs(EXPORT_DOC_TYPES) { return { $runAfter: ['paths-computed'], $runBefore: ['rendering-docs'], $process(docs) { // Compute the breadcrumb for each doc by processing its containers docs.forEach(doc => { if (EXPORT_DOC_TYPES.indexOf(doc.docType) !== -1) { doc.breadCrumbs = [ { text: 'API', path: '/api' }, { text: 'rxjs/' + doc.moduleDoc.id, path: doc.moduleDoc.path }, { text: doc.name, path: doc.path } ]; } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeApiBreadCrumbs.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./computeApiBreadCrumbs'); const Dgeni = require('dgeni'); describe('angular-api-package: computeApiBreadCrumbs processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('computeApiBreadCrumbs'); expect(processor.$process).toBeDefined(); expect(processor.$runAfter).toEqual(['paths-computed']); expect(processor.$runBefore).toEqual(['rendering-docs']); }); it('should attach a breadCrumbs property to each of the EXPORT_DOC_TYPES docs', () => { const EXPORT_DOC_TYPES = ['class', 'interface']; const processor = processorFactory(EXPORT_DOC_TYPES); const docs = [ { docType: 'class', name: 'ClassA', path: 'module-1/class-a', moduleDoc: { id: 'moduleOne', path: 'module-1' } }, { docType: 'interface', name: 'InterfaceB', path: 'module-2/interface-b', moduleDoc: { id: 'moduleTwo', path: 'module-2' } }, { docType: 'guide', name: 'Guide One', path: 'guide/guide-1' }, ]; processor.$process(docs); expect(docs[0].breadCrumbs).toEqual([ { text: 'API', path: '/api' }, { text: 'rxjs/moduleOne', path: 'module-1' }, { text: 'ClassA', path: 'module-1/class-a' }, ]); expect(docs[1].breadCrumbs).toEqual([ { text: 'API', path: '/api' }, { text: 'rxjs/moduleTwo', path: 'module-2' }, { text: 'InterfaceB', path: 'module-2/interface-b' }, ]); expect(docs[2].breadCrumbs).toBeUndefined(); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeSearchTitle.js ================================================ module.exports = function computeSearchTitleProcessor() { return { $runAfter: ['ids-computed'], $runBefore: ['generateKeywordsProcessor'], $process(docs) { docs.forEach(doc => { switch(doc.docType) { case 'function': doc.searchTitle = `${doc.name}()`; break; case 'module': doc.searchTitle = `${doc.id} package`; break; } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeSearchTitle.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./computeSearchTitle'); const Dgeni = require('dgeni'); describe('computeSearchTitle processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('computeSearchTitleProcessor'); expect(processor.$process).toBeDefined(); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['ids-computed']); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['generateKeywordsProcessor']); }); it('should compute a search title for API docs', () => { const processor = processorFactory(); const docs = [ { docType: 'class', name: 'MyClass' }, { docType: 'interface', name: 'MyInterface' }, { docType: 'enum', name: 'MyEnum' }, { docType: 'function', name: 'myFunction' }, { docType: 'pipe', name: 'MyPipe', pipeOptions: { name: 'myPipe' } }, { docType: 'directive', name: 'MyDirective', directiveOptions: {} }, { docType: 'decorator', name: 'MyDecorator' }, { docType: 'module', name: 'myModule', id: 'some/myModule' }, { docType: 'var', name: 'myVar' }, { docType: 'let', name: 'myLet' }, { docType: 'const', name: 'myConst' }, { docType: 'type-alias', name: 'myType' }, ]; processor.$process(docs); expect(docs[0].searchTitle).toBeUndefined(); expect(docs[1].searchTitle).toBeUndefined(); expect(docs[2].searchTitle).toBeUndefined(); expect(docs[3].searchTitle).toEqual('myFunction()'); expect(docs[4].searchTitle).toBeUndefined(); expect(docs[5].searchTitle).toBeUndefined(); expect(docs[6].searchTitle).toBeUndefined(); expect(docs[7].searchTitle).toEqual('some/myModule package'); expect(docs[8].searchTitle).toBeUndefined(); expect(docs[9].searchTitle).toBeUndefined(); expect(docs[10].searchTitle).toBeUndefined(); expect(docs[11].searchTitle).toBeUndefined(); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeStability.js ================================================ module.exports = function computeStability(log, createDocMessage) { return { docTypes: [], $runAfter: ['tags-extracted'], $runBefore: ['rendering-docs'], $process(docs) { docs.forEach(doc => { if (this.docTypes.indexOf(doc.docType) !== -1 && doc.experimental === undefined && doc.deprecated === undefined && doc.stable === undefined) { log.debug(createDocMessage('Adding stable property', doc)); doc.stable = true; } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/computeStability.spec.js ================================================ const log = require('dgeni/lib/mocks/log')(false); const createDocMessage = require('dgeni-packages/base/services/createDocMessage')(); const computeStability = require('./computeStability')(log, createDocMessage); const testPackage = require('../../helpers/test-package'); const Dgeni = require('dgeni'); describe('computeStability processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('computeStability'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { expect(computeStability.$runBefore).toEqual(['rendering-docs']); }); it('should run after the correct processor', () => { expect(computeStability.$runAfter).toEqual(['tags-extracted']); }); it('should compute stability based on the existence of experimental and deprecated tags', () => { computeStability.docTypes = ['test']; const docs = [ { docType: 'test' }, { docType: 'test', experimental: undefined }, { docType: 'test', experimental: true }, { docType: 'test', experimental: '' }, { docType: 'test', deprecated: undefined }, { docType: 'test', deprecated: true }, { docType: 'test', deprecated: '' }, { docType: 'test', experimental: true, deprecated: true }, ]; computeStability.$process(docs); expect(docs.map(doc => doc.stable)).toEqual([ true, true, undefined, undefined, true, undefined, undefined, undefined ]); }); it('should ignore docs that are not in the docTypes list', () => { computeStability.docTypes = ['test1', 'test2']; const docs = [ { docType: 'test1' }, { docType: 'test2' }, { docType: 'test3' }, { docType: 'test4' }, ]; computeStability.$process(docs); expect(docs.map(doc => doc.stable)).toEqual([ true, true, undefined, undefined ]); }); it('should not ignore docs where `stable` has already been defined', () => { computeStability.docTypes = ['test']; const docs = [ { docType: 'test' }, { docType: 'test', stable: true }, { docType: 'test', stable: '' }, { docType: 'test', stable: undefined }, ]; computeStability.$process(docs); expect(docs.map(doc => doc.stable)).toEqual([ true, true, '', true ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/convertPrivateClassesToInterfaces.js ================================================ module.exports = function convertPrivateClassesToInterfacesProcessor( convertPrivateClassesToInterfaces) { return { $runAfter: ['processing-docs'], $runBefore: ['docs-processed'], $process: function(docs) { convertPrivateClassesToInterfaces(docs, false); return docs; } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/extractDecoratedClasses.js ================================================ var _ = require('lodash'); module.exports = function extractDecoratedClassesProcessor(EXPORT_DOC_TYPES) { // Add the "directive" docType into those that can be exported from a module EXPORT_DOC_TYPES.push('directive', 'pipe'); return { $runAfter: ['processing-docs'], $runBefore: ['docs-processed'], decoratorTypes: ['Directive', 'Component', 'Pipe'], $process: function(docs) { var decoratorTypes = this.decoratorTypes; _.forEach(docs, function(doc) { _.forEach(doc.decorators, function(decorator) { if (decoratorTypes.indexOf(decorator.name) !== -1) { doc.docType = decorator.name.toLowerCase(); doc[doc.docType + 'Options'] = decorator.argumentInfo[0]; } }); }); return docs; } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/extractDecoratedClasses.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('extractDecoratedClasses processor', function() { var dgeni, injector, processor; beforeEach(function() { dgeni = new Dgeni([testPackage('angular-api-package')]); injector = dgeni.configureInjector(); processor = injector.get('extractDecoratedClassesProcessor'); }); it('should extract specified decorator arguments', function() { var doc1 = { id: '@angular/common/ngFor', name: 'ngFor', docType: 'class', decorators: [{ name: 'Directive', arguments: ['{selector: \'[ng-for][ng-for-of]\', properties: [\'ngForOf\']}'], argumentInfo: [{selector: '[ng-for][ng-for-of]', properties: ['ngForOf']}] }] }; var doc2 = { id: '@angular/core/DecimalPipe', name: 'DecimalPipe', docType: 'class', decorators: [{name: 'Pipe', arguments: ['{name: \'number\'}'], argumentInfo: [{name: 'number'}]}] }; processor.$process([doc1, doc2]); expect(doc1).toEqual(jasmine.objectContaining({ id: '@angular/common/ngFor', name: 'ngFor', docType: 'directive', directiveOptions: {selector: '[ng-for][ng-for-of]', properties: ['ngForOf']} })); expect(doc2).toEqual(jasmine.objectContaining({ id: '@angular/core/DecimalPipe', name: 'DecimalPipe', docType: 'pipe', pipeOptions: {name: 'number'} })); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/filterContainedDocs.js ================================================ /** * Remove docs that are contained in (owned by) another doc * so that they don't get rendered as files in themselves. */ module.exports = function filterContainedDocs() { return { docTypes: ['member', 'function-overload', 'get-accessor-info', 'set-accessor-info', 'parameter'], $runAfter: ['extra-docs-added'], $runBefore: ['computing-paths'], $process: function(docs) { var docTypes = this.docTypes; return docs.filter(function(doc) { return docTypes.indexOf(doc.docType) === -1; }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/filterPrivateDocs.js ================================================ module.exports = function filterPrivateDocs() { return { $runAfter: ['extra-docs-added'], $runBefore: ['computing-paths'], $process: function(docs) { return docs.filter(function(doc) { return doc.privateExport !== true; }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/filterPrivateDocs.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./filterPrivateDocs'); const Dgeni = require('dgeni'); describe('filterPrivateDocs processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('filterPrivateDocs'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['computing-paths']); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['extra-docs-added']); }); it('should remove docs that are marked as private exports', () => { const processor = processorFactory(); const docs = [ { name: 'public1'}, { name: 'ɵPrivate1', privateExport: true }, { name: 'public2'}, { name: 'ɵPrivate2', privateExport: true }, { id: 'other'} ]; const filteredDocs = processor.$process(docs); expect(filteredDocs).toEqual([ { name: 'public1'}, { name: 'public2'}, { id: 'other'} ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/generateApiListDoc.js ================================================ module.exports = function generateApiListDoc() { return { $runAfter: ['extra-docs-added'], $runBefore: ['rendering-docs'], outputFolder: null, $validate: {outputFolder: {presence: true}}, $process: function(docs) { docs.push({ docType: 'api-list-data', template: 'json-doc.template.json', path: this.outputFolder + '/api-list.json', outputPath: this.outputFolder + '/api-list.json', data: docs .filter(doc => doc.docType === 'module') .map(getModuleInfo) }); } }; }; function getModuleInfo(moduleDoc) { const moduleName = moduleDoc.id.replace(/\/index$/, ''); return { name: moduleName.toLowerCase(), title: moduleName, items: moduleDoc.exports // Ignore internals and private exports (indicated by the ɵ prefix) .filter(doc => !doc.internal && !doc.privateExport) // Ignore all renamed exports that are just duplicates of other docs .filter(doc => !doc.duplicateOf) .map(getExportInfo) .sort((a, b) => a.name === b.name ? 0 : a.name > b.name ? 1 : -1) }; } function getExportInfo(exportDoc) { return { name: exportDoc.name.toLowerCase(), title: exportDoc.name, path: exportDoc.path, docType: getDocType(exportDoc), stability: getStability(exportDoc), securityRisk: !!exportDoc.security }; } function getDocType(doc) { // We map `let` and `var` types to `const` if (['let', 'var'].indexOf(doc.docType) !== -1) { return 'const'; } return doc.docType; } const stabilityProperties = ['stable', 'experimental', 'deprecated']; function getStability(doc) { return stabilityProperties.find(prop => doc.hasOwnProperty(prop)) || ''; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/generateApiListDoc.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./generateApiListDoc'); const Dgeni = require('dgeni'); describe('generateApiListDoc processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('generateApiListDoc'); expect(processor.$process).toBeDefined(); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['extra-docs-added']); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['rendering-docs']); }); it('should create a new api list doc', () => { const processor = processorFactory(); const docs = []; processor.outputFolder = 'test/path'; processor.$process(docs); expect(docs[0]).toEqual({ docType: 'api-list-data', template: 'json-doc.template.json', path: 'test/path/api-list.json', outputPath: 'test/path/api-list.json', data: [] }); }); it('should add an info object to the doc for each module doc', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [] }, { docType: 'module', id: '@angular/core/index', exports: [] }, { docType: 'module', id: '@angular/http/index', exports: [] }, ]; processor.$process(docs); expect(docs[3].data).toEqual([ { name: '@angular/common', title: '@angular/common', items: [] }, { name: '@angular/core', title: '@angular/core', items: [] }, { name: '@angular/http', title: '@angular/http', items: [] }, ]); }); it('should add info about each export on each module', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'directive', name: 'AaaAaa', path: 'aaa' }, { docType: 'pipe', name: 'BbbBbb', path: 'bbb' }, { docType: 'decorator', name: 'CccCcc', path: 'ccc' }, { docType: 'class', name: 'DddDdd', path: 'ddd' } ] }, { docType: 'module', id: '@angular/core/index', exports: [ { docType: 'interface', name: 'EeeEee', path: 'eee' }, { docType: 'function', name: 'FffFff', path: 'fff' }, { docType: 'enum', name: 'GggGgg', path: 'ggg' }, { docType: 'type-alias', name: 'HhhHhh', path: 'hhh' }, { docType: 'const', name: 'IiiIii', path: 'iii' }, ] }, ]; processor.$process(docs); expect(docs[2].data[0].items).toEqual([ { docType: 'directive', title: 'AaaAaa', name: 'aaaaaa', path: 'aaa', stability: '', securityRisk: false }, { docType: 'pipe', title: 'BbbBbb', name: 'bbbbbb', path: 'bbb', stability: '', securityRisk: false }, { docType: 'decorator', title: 'CccCcc', name: 'cccccc', path: 'ccc', stability: '', securityRisk: false }, { docType: 'class', title: 'DddDdd', name: 'dddddd', path: 'ddd', stability: '', securityRisk: false } ]); expect(docs[2].data[1].items).toEqual([ { docType: 'interface', title: 'EeeEee', name: 'eeeeee', path: 'eee', stability: '', securityRisk: false }, { docType: 'function', title: 'FffFff', name: 'ffffff', path: 'fff', stability: '', securityRisk: false }, { docType: 'enum', title: 'GggGgg', name: 'gggggg', path: 'ggg', stability: '', securityRisk: false }, { docType: 'type-alias', title: 'HhhHhh', name: 'hhhhhh', path: 'hhh', stability: '', securityRisk: false }, { docType: 'const', title: 'IiiIii', name: 'iiiiii', path: 'iii', stability: '', securityRisk: false }, ]); }); it('should ignore internal and private exports', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'directive', name: 'AaaAaa', path: 'aaa', internal: true }, { docType: 'class', name: 'XxxXxx', path: 'xxx', privateExport: true }, { docType: 'pipe', name: 'BbbBbb', path: 'bbb' } ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'pipe', title: 'BbbBbb', name: 'bbbbbb', path: 'bbb', stability: '', securityRisk: false }, ]); }); it('should convert `let` and `var` docTypes to `const`', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'var', name: 'AaaAaa', path: 'aaa' }, { docType: 'let', name: 'BbbBbb', path: 'bbb' }, ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'const', title: 'AaaAaa', name: 'aaaaaa', path: 'aaa', stability: '', securityRisk: false }, { docType: 'const', title: 'BbbBbb', name: 'bbbbbb', path: 'bbb', stability: '', securityRisk: false }, ]); }); it('should convert security to a boolean securityRisk', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'class', name: 'AaaAaa', path: 'aaa', security: 'This is a security risk' }, { docType: 'class', name: 'BbbBbb', path: 'bbb', security: '' }, ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'class', title: 'AaaAaa', name: 'aaaaaa', path: 'aaa', stability: '', securityRisk: true }, { docType: 'class', title: 'BbbBbb', name: 'bbbbbb', path: 'bbb', stability: '', securityRisk: false }, ]); }); it('should convert stability tags to the stable string property', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'class', name: 'AaaAaa', path: 'aaa', stable: undefined }, { docType: 'class', name: 'BbbBbb', path: 'bbb', experimental: 'Some message' }, { docType: 'class', name: 'CccCcc', path: 'ccc', deprecated: null }, { docType: 'class', name: 'DddDdd', path: 'ddd' }, ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'class', title: 'AaaAaa', name: 'aaaaaa', path: 'aaa', stability: 'stable', securityRisk: false }, { docType: 'class', title: 'BbbBbb', name: 'bbbbbb', path: 'bbb', stability: 'experimental', securityRisk: false }, { docType: 'class', title: 'CccCcc', name: 'cccccc', path: 'ccc', stability: 'deprecated', securityRisk: false }, { docType: 'class', title: 'DddDdd', name: 'dddddd', path: 'ddd', stability: '', securityRisk: false }, ]); }); it('should sort items in each group alphabetically', () => { const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ { docType: 'class', name: 'DddDdd', path: 'uuu' }, { docType: 'class', name: 'BbbBbb', path: 'vvv' }, { docType: 'class', name: 'AaaAaa', path: 'xxx' }, { docType: 'class', name: 'CccCcc', path: 'yyy' }, ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'class', title: 'AaaAaa', name: 'aaaaaa', path: 'xxx', stability: '', securityRisk: false }, { docType: 'class', title: 'BbbBbb', name: 'bbbbbb', path: 'vvv', stability: '', securityRisk: false }, { docType: 'class', title: 'CccCcc', name: 'cccccc', path: 'yyy', stability: '', securityRisk: false }, { docType: 'class', title: 'DddDdd', name: 'dddddd', path: 'uuu', stability: '', securityRisk: false }, ]); }); it('should remove duplicate exports', () => { const origDoc = { docType: 'class', name: 'DddDdd', path: 'uuu' }; const duplicatedDoc = { docType: 'class', name: 'BbbBbb', path: 'vvv', duplicateOf: origDoc }; origDoc.renamedDuplicates = [duplicatedDoc]; const processor = processorFactory(); const docs = [ { docType: 'module', id: '@angular/common/index', exports: [ origDoc, duplicatedDoc, { docType: 'class', name: 'AaaAaa', path: 'xxx' }, { docType: 'class', name: 'CccCcc', path: 'yyy' }, ]} ]; processor.$process(docs); expect(docs[1].data[0].items).toEqual([ { docType: 'class', title: 'AaaAaa', name: 'aaaaaa', path: 'xxx', stability: '', securityRisk: false }, { docType: 'class', title: 'CccCcc', name: 'cccccc', path: 'yyy', stability: '', securityRisk: false }, { docType: 'class', title: 'DddDdd', name: 'dddddd', path: 'uuu', stability: '', securityRisk: false }, ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/generateDeprecationsListDoc.js ================================================ module.exports = function generateDeprecationListDoc() { return { $runAfter: ['extra-docs-added'], $runBefore: ['rendering-docs'], outputFolder: null, $process: function(docs) { docs.push({ docType: 'deprecation', template: 'json-doc.template.json', path: this.outputFolder + '/deprecations', outputPath: this.outputFolder + '/deprecations.json', data: docs .filter(doc => doc.deprecated) .map(doc => { return { name: doc.name, type: doc.docType, path: doc.path, text: doc.deprecated }; }) }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/markAliases.spec.ts ================================================ import { Dgeni, Document, DocCollection } from 'dgeni'; import { Injector } from 'dgeni/lib/Injector'; import { ReadTypeScriptModules } from 'dgeni-packages/typescript/processors/readTypeScriptModules'; import * as path from 'path'; import 'jasmine'; import * as testPackage from '../../helpers/test-package'; import { markAliases } from './markAliases'; describe('markAliases processor', () => { let dgeni: Dgeni; let injector: Injector; let readTypescript: ReadTypeScriptModules; beforeEach(() => { dgeni = new Dgeni([testPackage('angular-api-package')]); injector = dgeni.configureInjector(); readTypescript = injector.get('readTypeScriptModules'); readTypescript.basePath = path.resolve(__dirname, '../mocks'); readTypescript.sourceFiles = [ 'aliasedExports.ts' ]; }); it('should be available on the injector', () => { const processor = injector.get('markAliases'); expect(processor.$process).toBeDefined(); }); it('should mark aliased exports and remove the aliased doc', () => { const exportedDocs: DocCollection = []; const moduleDoc = { docType: 'module', exports: exportedDocs, }; exportedDocs.push( { docType: 'class', id: 'class-1', moduleDoc: moduleDoc, name: 'aliased-name', aliasSymbol: { escapedName: 'aliased-name', resolvedSymbol: { escapedName: 'original-name' } } }, { docType: 'class', id: 'class-2', moduleDoc: moduleDoc, name: 'original-name', aliasSymbol: { escapedName: 'original-name', resolvedSymbol: { escapedName: 'original-name' } } }, { docType: 'guide', id: 'guide-1', moduleDoc: moduleDoc, name: 'guide', aliasSymbol: { escapedName: 'guide', resolvedSymbol: { escapedName: 'guide' } } }, ); const docs = [ moduleDoc, ...exportedDocs, ]; const processor = markAliases(console); processor.$process(docs); const originalDoc = docs.find((doc: Document) => doc.name === 'original-name'); const duplicateNames = originalDoc.renamedDuplicates.map((doc: Document) => doc.name); expect(duplicateNames).toEqual(['aliased-name']); expect(originalDoc).toBe(originalDoc.renamedDuplicates[0].duplicateOf); }); it('should mark aliased exports and remove the aliased doc from a mock file', () => { const processor = markAliases(console); let docs: DocCollection = []; readTypescript.$process(docs); processor.$process(docs); const originalDoc = docs.find((doc: Document) => doc.name === 'operator'); const duplicateNames = originalDoc.renamedDuplicates.map((doc: Document) => doc.name); expect(duplicateNames).toEqual(['aliasedOperator']); expect(originalDoc).toBe(originalDoc.renamedDuplicates[0].duplicateOf); }); it('should leave non duplicate exports unmarked', () => { const processor = markAliases(console); let docs: DocCollection = []; readTypescript.$process(docs); processor.$process(docs); const nonDuplicateOperator = docs.find((doc: Document) => doc.name === 'operatorWithoutDuplicate'); expect(nonDuplicateOperator.renamedDuplicates).toBeUndefined(); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/markAliases.ts ================================================ import { DocCollection, Document, Processor } from 'dgeni'; export function markAliases(log: any): MarkAliases { return new MarkAliases(log); } const getOriginalName = (doc: Document): string => doc.aliasSymbol.resolvedSymbol.escapedName; class MarkAliases implements Processor { $runAfter = ['readTypeScriptModules']; $runBefore = ['generateApiListDoc', 'createSitemap']; constructor( private log: any, ) { } $process(docs: DocCollection): void { docs .filter((doc: Document) => doc.moduleDoc) .forEach((doc: Document) => { const duplicateDocs = this.findDuplicateDocs(doc); if (duplicateDocs.length > 0) { duplicateDocs.forEach((duplicateDoc: Document) => duplicateDoc.duplicateOf = doc); doc.renamedDuplicates = duplicateDocs; this.log.debug(`${doc.name} has the following aliases:`, duplicateDocs.map((doc: Document) => doc.name).join(', ')); } }); } private findDuplicateDocs(doc: Document): DocCollection { return doc.moduleDoc.exports .filter((exportedDoc: Document) => exportedDoc !== doc && exportedDoc.aliasSymbol && getOriginalName(exportedDoc) === doc.name ); } } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/markBarredODocsAsPrivate.js ================================================ module.exports = function markBarredODocsAsPrivate() { return { $runAfter: ['readTypeScriptModules'], $runBefore: ['adding-extra-docs'], $process: function(docs) { docs.forEach(doc => { if (doc.name && doc.name.indexOf('ɵ') === 0) { doc.privateExport = true; } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/markBarredODocsAsPrivate.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./markBarredODocsAsPrivate'); const Dgeni = require('dgeni'); describe('markBarredODocsAsPrivate processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('markBarredODocsAsPrivate'); expect(processor.$process).toBeDefined(); expect(processor.$runAfter).toContain('readTypeScriptModules'); expect(processor.$runBefore).toContain('adding-extra-docs'); }); it('should mark docs starting with barred-o ɵ as private', () => { const processor = processorFactory(); const docs = [ { name: 'ɵPrivate' }, { name: 'public' } ]; processor.$process(docs); expect(docs[0].privateExport).toBeTruthy(); expect(docs[1].privateExport).toBeFalsy(); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/matchUpDirectiveDecorators.js ================================================ /** * @dgProcessor * @description * Directives in Angular are specified by various decorators. In particular the `@Directive()` * decorator on the class and various other property decorators, such as `@Input`. * * This processor will extract this decorator information and attach it as properties to the * directive document. * * Notably, the `input` and `output` binding information can be specified * either via property decorators (`@Input()`/`@Output()`) or by properties on the metadata * passed to the `@Directive` decorator. This processor will collect up info from both and * merge them. */ module.exports = function matchUpDirectiveDecorators() { return { $runAfter: ['ids-computed', 'paths-computed'], $runBefore: ['rendering-docs'], $process: function(docs) { docs.forEach(function(doc) { if (doc.docType === 'directive') { doc.selector = stripQuotes(doc.directiveOptions.selector); doc.exportAs = stripQuotes(doc.directiveOptions.exportAs); doc.inputs = getBindingInfo(doc.directiveOptions.inputs, doc.members, 'Input'); doc.outputs = getBindingInfo(doc.directiveOptions.outputs, doc.members, 'Output'); } }); } }; }; function getBindingInfo(directiveBindings, members, bindingType) { const bindings = {}; // Parse the bindings from the directive decorator if (directiveBindings) { directiveBindings.forEach(function(binding) { const bindingInfo = parseBinding(binding); bindings[bindingInfo.propertyName] = bindingInfo; }); } if (members) { members.forEach(function(member) { if (member.decorators) { // Search for members with binding decorators member.decorators.forEach(function(decorator) { if (decorator.name === bindingType) { bindings[member.name] = createBindingInfo(member.name, decorator.arguments[0] || member.name); } }); } // Now ensure that any bindings have the associated member attached // Note that this binding could have come from the directive decorator if (bindings[member.name]) { bindings[member.name].memberDoc = member; } }); } // Convert the map back to an array return Object.keys(bindings).map(function(key) { return bindings[key]; }); } function stripQuotes(value) { return (typeof(value) === 'string') ? value.trim().replace(/^(['"])(.*)\1$/, '$2') : value; } function parseBinding(option) { // Directive decorator bindings are of the form: "propName : bindingName" (bindingName is optional) const optionPair = option.split(':'); const propertyName = optionPair[0].trim(); const bindingName = (optionPair[1] || '').trim() || propertyName; return createBindingInfo(propertyName, bindingName); } function createBindingInfo(propertyName, bindingName) { return { propertyName: stripQuotes(propertyName), bindingName: stripQuotes(bindingName) }; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/matchUpDirectiveDecorators.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./matchUpDirectiveDecorators'); const Dgeni = require('dgeni'); describe('matchUpDirectiveDecorators processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('matchUpDirectiveDecorators'); expect(processor.$process).toBeDefined(); expect(processor.$runAfter).toContain('ids-computed'); expect(processor.$runAfter).toContain('paths-computed'); expect(processor.$runBefore).toContain('rendering-docs'); }); it('should extract selector and exportAs from the directive decorator on directive docs', () => { const docs = [{ docType: 'directive', directiveOptions: { selector: 'a,b,c', exportAs: 'someExport' } }]; processorFactory().$process(docs); expect(docs[0].selector).toEqual('a,b,c'); expect(docs[0].exportAs).toEqual('someExport'); }); it('should ignore properties from the directive decorator on non-directive docs', () => { const docs = [{ docType: 'class', directiveOptions: { selector: 'a,b,c', exportAs: 'someExport' } }]; processorFactory().$process(docs); expect(docs[0].selector).toBeUndefined(); expect(docs[0].exportAs).toBeUndefined(); }); it('should strip whitespace and quotes off directive properties', () => { const docs = [ { docType: 'directive', directiveOptions: { selector: '"a,b,c"', exportAs: '\'someExport\'' } }, { docType: 'directive', directiveOptions: { selector: ' a,b,c ', exportAs: ' someExport ' } }, { docType: 'directive', directiveOptions: { selector: ' "a,b,c" ', exportAs: ' \'someExport\' ' } } ]; processorFactory().$process(docs); expect(docs[0].selector).toEqual('a,b,c'); expect(docs[0].exportAs).toEqual('someExport'); expect(docs[1].selector).toEqual('a,b,c'); expect(docs[1].exportAs).toEqual('someExport'); expect(docs[2].selector).toEqual('a,b,c'); expect(docs[2].exportAs).toEqual('someExport'); }); it('should extract inputs and outputs from the directive decorator', () => { const docs = [{ docType: 'directive', directiveOptions: { inputs: ['in1:in2', 'in3', ' in4:in5 ', ' in6 '], outputs: ['out1:out1', ' out2:out3 ', ' out4 '] }, members: [ { name: 'in1' }, { name: 'in3' }, { name: 'in4' }, { name: 'in6' }, { name: 'out1' }, { name: 'out2' }, { name: 'out4' } ] }]; processorFactory().$process(docs); expect(docs[0].inputs).toEqual([ { propertyName: 'in1', bindingName: 'in2', memberDoc: docs[0].members[0] }, { propertyName: 'in3', bindingName: 'in3', memberDoc: docs[0].members[1] }, { propertyName: 'in4', bindingName: 'in5', memberDoc: docs[0].members[2] }, { propertyName: 'in6', bindingName: 'in6', memberDoc: docs[0].members[3] } ]); expect(docs[0].outputs).toEqual([ { propertyName: 'out1', bindingName: 'out1', memberDoc: docs[0].members[4] }, { propertyName: 'out2', bindingName: 'out3', memberDoc: docs[0].members[5] }, { propertyName: 'out4', bindingName: 'out4', memberDoc: docs[0].members[6] } ]); }); it('should extract inputs and outputs from decorated properties', () => { const docs = [{ docType: 'directive', directiveOptions: {}, members: [ { name: 'a1', decorators: [{ name: 'Input', arguments: ['a2'] }] }, { name: 'b1', decorators: [{ name: 'Output', arguments: ['b2'] }] }, { name: 'c1', decorators: [{ name: 'Input', arguments: [] }] }, { name: 'd1', decorators: [{ name: 'Output', arguments: [] }] }, ] }]; processorFactory().$process(docs); expect(docs[0].inputs).toEqual([ { propertyName: 'a1', bindingName: 'a2', memberDoc: docs[0].members[0] }, { propertyName: 'c1', bindingName: 'c1', memberDoc: docs[0].members[2] } ]); expect(docs[0].outputs).toEqual([ { propertyName: 'b1', bindingName: 'b2', memberDoc: docs[0].members[1] }, { propertyName: 'd1', bindingName: 'd1', memberDoc: docs[0].members[3] } ]); }); it('should merge directive inputs/outputs with decorator property inputs/outputs', () => { const docs = [{ docType: 'directive', directiveOptions: { inputs: ['a1:a2'], outputs: ['b1:b2'] }, members: [ { name: 'a1' }, { name: 'a3', decorators: [{ name: 'Input', arguments: ['a4'] }] }, { name: 'b1' }, { name: 'b3', decorators: [{ name: 'Output', arguments: ['b4'] }] }, ] }]; processorFactory().$process(docs); expect(docs[0].inputs).toEqual([ { propertyName: 'a1', bindingName: 'a2', memberDoc: docs[0].members[0] }, { propertyName: 'a3', bindingName: 'a4', memberDoc: docs[0].members[1] } ]); expect(docs[0].outputs).toEqual([ { propertyName: 'b1', bindingName: 'b2', memberDoc: docs[0].members[2] }, { propertyName: 'b3', bindingName: 'b4', memberDoc: docs[0].members[3] } ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/mergeDecoratorDocs.js ================================================ /** * Decorators in the Angular code base are made up from three code items: * * 1) An interface that represents the call signature of the decorator. E.g. * * ``` * export interface ComponentDecorator { * (obj: Component): TypeDecorator; * new (obj: Component): Component; * } * ``` * * 2) An interface that represents the members of the object that should be passed * into the decorator. E.g. * * ``` * export interface Component extends Directive { * changeDetection?: ChangeDetectionStrategy; * viewProviders?: Provider[]; * templateUrl?: string; * ... * } * ``` * * 3) A constant that is created by a call to a generic function, whose type parameter is * the call signature interface of the decorator. E.g. * * ``` * export const Component: ComponentDecorator = * makeDecorator('Component', { ... }, Directive) * ``` * * This processor searches for these constants (3) by looking for a call to * `make...Decorator(...)`. (There are variations to the call for property and param * decorators). From this call we identify the `decoratorType` (e.g. `ComponentDecorator`). * * Calls to `make...Decorator` will return an object of type X. This type is the document * referred to in (2). This is the primary doc that we care about for documenting the decorator. * It holds all of the members of the metadata that is passed to the decorator call. * * Finally we want to capture the documentation attached to the call signature interface of the * associated decorator (1). We copy across the properties that we care about from this call * signature (e.g. description, whatItDoes and howToUse). */ module.exports = function mergeDecoratorDocs(log) { return { $runAfter: ['processing-docs'], $runBefore: ['docs-processed'], makeDecoratorCalls: [ {type: '', description: 'toplevel', functionName: 'makeDecorator'}, {type: 'Prop', description: 'property', functionName: 'makePropDecorator'}, {type: 'Param', description: 'parameter', functionName: 'makeParamDecorator'}, ], $process: function(docs) { var makeDecoratorCalls = this.makeDecoratorCalls; var docsToMerge = Object.create(null); docs.forEach(function(doc) { const initializer = getInitializer(doc); if (initializer) { makeDecoratorCalls.forEach(function(call) { // find all the decorators, signified by a call to `make...Decorator(metadata)` if (initializer.expression && initializer.expression.text === call.functionName) { log.debug('mergeDecoratorDocs: found decorator', doc.docType, doc.name); doc.docType = 'decorator'; doc.decoratorLocation = call.description; // Get the type of the decorator metadata from the first "type" argument of the call. // For example the `X` of `createDecorator(...)`. doc.decoratorType = initializer.arguments[0].text; // clear the symbol type named since it is not needed doc.symbolTypeName = undefined; // keep track of the names of the metadata interface that will need to be merged into this decorator doc docsToMerge[doc.name + 'Decorator'] = doc; } }); } }); // merge the metadata docs into the decorator docs docs = docs.filter(function(doc) { if (docsToMerge[doc.name]) { // We have found an `XxxDecorator` document that will hold the call signature of the decorator var decoratorDoc = docsToMerge[doc.name]; var callMember = doc.members.filter(function(member) { return member.isCallMember; })[0]; log.debug( 'mergeDecoratorDocs: merging', doc.name, 'into', decoratorDoc.name, callMember.description.substring(0, 50)); // Merge the documentation found in this call signature into the original decorator decoratorDoc.description = callMember.description; decoratorDoc.howToUse = callMember.howToUse; decoratorDoc.whatItDoes = callMember.whatItDoes; // remove doc from its module doc's exports doc.moduleDoc.exports = doc.moduleDoc.exports.filter(function(exportDoc) { return exportDoc !== doc; }); // remove from the overall list of docs to be rendered return false; } return true; }); } }; }; function getInitializer(doc) { var initializer = doc.symbol && doc.symbol.valueDeclaration && doc.symbol.valueDeclaration.initializer; // There appear to be two forms of initializer: // export var Injectable: InjectableFactory = // makeDecorator(InjectableMetadata); // and // export var RouteConfig: (configs: RouteDefinition[]) => ClassDecorator = // makeDecorator(RouteConfigAnnotation); // In the first case, the type assertion `` causes the AST to contain an // extra level of expression // to hold the new type of the expression. if (initializer && initializer.expression && initializer.expression.expression) { initializer = initializer.expression; } return initializer; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/mergeDecoratorDocs.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('mergeDecoratorDocs processor', () => { let processor, moduleDoc, decoratorDoc, metadataDoc, otherDoc; beforeEach(() => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); processor = injector.get('mergeDecoratorDocs'); moduleDoc = {}; decoratorDoc = { name: 'Component', docType: 'const', description: 'A description of the metadata for the Component decorator', symbol: { valueDeclaration: { initializer: { expression: { text: 'makeDecorator' }, arguments: [{ text: 'X' }] } } }, members: [ { name: 'templateUrl', description: 'A description of the templateUrl property' } ], moduleDoc }; metadataDoc = { name: 'ComponentDecorator', docType: 'interface', description: 'A description of the interface for the call signature for the Component decorator', members: [ { isCallMember: true, description: 'The actual description of the call signature', whatItDoes: 'Does something cool...', howToUse: 'Use it like this...' }, { description: 'Some other member' } ], moduleDoc }; otherDoc = { name: 'Y', docType: 'const', symbol: { valueDeclaration: { initializer: { expression: { text: 'otherCall' }, arguments: [{ text: 'param1' }] } } }, moduleDoc }; moduleDoc.exports = [decoratorDoc, metadataDoc, otherDoc]; }); it('should change the docType of only the docs that are initialized by a call to makeDecorator', () => { processor.$process([decoratorDoc, metadataDoc, otherDoc]); expect(decoratorDoc.docType).toEqual('decorator'); expect(otherDoc.docType).toEqual('const'); }); it('should extract the "type" of the decorator meta data', () => { processor.$process([decoratorDoc, metadataDoc, otherDoc]); expect(decoratorDoc.decoratorType).toEqual('X'); }); it('should copy across properties from the call signature doc', () => { processor.$process([decoratorDoc, metadataDoc, otherDoc]); expect(decoratorDoc.description).toEqual('The actual description of the call signature'); expect(decoratorDoc.whatItDoes).toEqual('Does something cool...'); expect(decoratorDoc.howToUse).toEqual('Use it like this...'); }); it('should remove the metadataDoc from the module exports', () => { processor.$process([decoratorDoc, metadataDoc, otherDoc]); expect(moduleDoc.exports).not.toContain(metadataDoc); }); it('should cope with decorators that have type params', () => { decoratorDoc.symbol.valueDeclaration.initializer.expression.type = {}; processor.$process([decoratorDoc, metadataDoc, otherDoc]); expect(decoratorDoc.docType).toEqual('decorator'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/migrateLegacyJSDocTags.js ================================================ module.exports = function migrateLegacyJSDocTags(log, createDocMessage) { return { $runAfter: ['tags-extracted'], $runBefore: ['processing-docs'], $process(docs) { let migrated = false; docs.forEach(doc => { if (doc.howToUse) { if (doc.usageNotes) { throw new Error(createDocMessage('`@usageNotes` and the deprecated `@howToUse` are not allowed on the same doc', doc)); } log.debug(createDocMessage('Using deprecated `@howToUse` tag as though it was `@usageNotes` tag', doc)); doc.usageNotes = doc.howToUse; doc.howToUse = null; migrated = true; } if (doc.whatItDoes) { log.debug(createDocMessage('Merging the content of `@whatItDoes` tag into the description.', doc)); if (doc.description) { doc.description = `${doc.whatItDoes}\n\n${doc.description}`; } else { doc.description = doc.whatItDoes; } doc.whatItDoes = null; migrated = true; } }); if (migrated) { log.warn('Some deprecated tags were migrated.'); log.warn('This automatic handling will be removed in a future version of the doc generation.\n'); } } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/migrateLegacyJSDocTags.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./migrateLegacyJSDocTags'); const log = require('dgeni/lib/mocks/log')(false); const createDocMessage = require('dgeni-packages/base/services/createDocMessage')(); const Dgeni = require('dgeni'); describe('migrateLegacyJSDocTags processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('migrateLegacyJSDocTags'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { const processor = processorFactory(log, createDocMessage); expect(processor.$runBefore).toEqual(['processing-docs']); }); it('should run after the correct processor', () => { const processor = processorFactory(log, createDocMessage); expect(processor.$runAfter).toEqual(['tags-extracted']); }); it('should migrate `howToUse` property to `usageNotes` property', () => { const processor = processorFactory(log, createDocMessage); const docs = [ { howToUse: 'this is how to use it' } ]; processor.$process(docs); expect(docs[0].howToUse).toBe(null); expect(docs[0].usageNotes).toEqual('this is how to use it'); }); it('should migrate `whatItDoes` property to the `description`', () => { const processor = processorFactory(log, createDocMessage); const docs = [ { whatItDoes: 'what it does' }, { whatItDoes: 'what it does', description: 'the description' }, { description: 'the description' } ]; processor.$process(docs); expect(docs[0].whatItDoes).toBe(null); expect(docs[0].description).toEqual('what it does'); expect(docs[1].whatItDoes).toBe(null); expect(docs[1].description).toEqual('what it does\n\nthe description'); expect(docs[2].whatItDoes).toBeUndefined(); expect(docs[2].description).toEqual('the description'); }); it('should ignore docs that have neither `howToUse` nor `whatItDoes` properties', () => { const processor = processorFactory(log, createDocMessage); const docs = [ { }, { description: 'the description' } ]; processor.$process(docs); expect(docs).toEqual([ { }, { description: 'the description' } ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/processClassLikeMembers.js ================================================ /** * A class like API doc contains members, but these can be either properties or method. * Separate the members into two new collections: `doc.properties` and `doc.methods`. */ module.exports = function processClassLikeMembers() { return { $runAfter: ['filterContainedDocs'], $runBefore: ['rendering-docs'], $process(docs) { docs.forEach(doc => { if (doc.members) { doc.properties = []; doc.methods = []; doc.members.forEach(member => { if (isMethod(member)) { doc.methods.push(member); computeMemberDescription(member); } else { doc.properties.push(member); if (!member.description) { // Is this property defined as a constructor parameter e.g. `constructor(public property: string) { ... }`? const constructorDoc = member.containerDoc.constructorDoc; if (constructorDoc) { const matchingParameterDoc = constructorDoc.parameterDocs.filter(doc => doc.declaration === member.declaration)[0]; member.constructorParamDoc = matchingParameterDoc; } } } }); } if (doc.statics) { doc.staticProperties = []; doc.staticMethods = []; doc.statics.forEach(member => { if (isMethod(member)) { doc.staticMethods.push(member); computeMemberDescription(member); } else { doc.staticProperties.push(member); } }); } }); } }; }; function isMethod(doc) { return doc.hasOwnProperty('parameters') && !doc.isGetAccessor && !doc.isSetAccessor; } function computeMemberDescription(member) { if (!member.description && member.overloads) { // Perhaps the description is on one of the overloads - take the first non-empty one member.description = member.overloads.map(overload => overload.description).filter(description => !!description)[0]; } } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/processClassLikeMembers.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./processClassLikeMembers'); const Dgeni = require('dgeni'); const property1 = { description: 'property 1' }; const property2 = { description: 'property 2' }; const getter1 = { parameters: [], isGetAccessor: true, description: 'getter 1' }; const setter1 = { parameters: [], isSetAccessor: true, description: 'setter 1' }; const method1 = { parameters: [] }; const method2 = { parameters: [] }; const method3 = { parameters: [] }; describe('angular-api-package: processClassLikeMembers processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('processClassLikeMembers'); expect(processor.$process).toBeDefined(); expect(processor.$runAfter).toEqual(['filterContainedDocs']); expect(processor.$runBefore).toEqual(['rendering-docs']); }); it('should copy instance members into properties and methods', () => { const processor = processorFactory(); const docs = [ { members: [ property1, method1, getter1] }, { members: [ method2, property2, method3, setter1] }, { } ]; processor.$process(docs); expect(docs[0].properties).toEqual([property1, getter1]); expect(docs[0].methods).toEqual([method1]); expect(docs[1].properties).toEqual([property2, setter1]); expect(docs[1].methods).toEqual([method2, method3]); expect(docs[2].properties).toBeUndefined(); expect(docs[2].methods).toBeUndefined(); }); it('should copy static members into properties and methods', () => { const processor = processorFactory(); const docs = [ { statics: [ property1, method1, getter1] }, { statics: [ method2, property2, method3, setter1] }, { } ]; processor.$process(docs); expect(docs[0].staticProperties).toEqual([property1, getter1]); expect(docs[0].staticMethods).toEqual([method1]); expect(docs[1].staticProperties).toEqual([property2, setter1]); expect(docs[1].staticMethods).toEqual([method2, method3]); expect(docs[2].staticProperties).toBeUndefined(); expect(docs[2].staticMethods).toBeUndefined(); }); it('should wire up properties that are declared as parameters on the constructor to its associated parameter doc', () => { const processor = processorFactory(); const propertyDeclaration = {}; const parameterDoc1 = { declaration: {} }; const parameterDoc2 = { declaration: propertyDeclaration }; const parameterDoc3 = { declaration: {} }; const property = { declaration: propertyDeclaration, containerDoc: { constructorDoc: { parameterDocs: [ parameterDoc1, parameterDoc2, parameterDoc3 ] } } }; const docs = [{ members: [ property] }]; processor.$process(docs); expect(property.constructorParamDoc).toEqual(parameterDoc2); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/simplifyMemberAnchors.js ================================================ /** * Members that have overloads get long unwieldy anchors because they must be distinguished * by their parameter lists. * But the primary overload doesn't not need this distinction, so can just be the name of the member. */ module.exports = function simplifyMemberAnchors() { return { $runAfter: ['paths-computed'], $runBefore: ['rendering-docs'], $process: function(docs) { return docs.forEach(doc => { if (doc.members) { doc.members.forEach(member => { member.anchor = computeAnchor(member); member.path = doc.path + '#' + member.anchor; }); } if (doc.statics) { doc.statics.forEach(member => { member.anchor = computeAnchor(member); member.path = doc.path + '#' + member.anchor; }); } }); } }; }; function computeAnchor(member) { // if the member is a "call" type then it has no name return encodeURI(member.name.trim() || 'call'); } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/simplifyMemberAnchors.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./simplifyMemberAnchors'); const Dgeni = require('dgeni'); describe('simplifyMemberAnchors processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('simplifyMemberAnchors'); expect(processor.$process).toBeDefined(); expect(processor.$runAfter).toEqual(['paths-computed']); expect(processor.$runBefore).toEqual(['rendering-docs']); }); describe('$process', () => { describe('docs without members', () => { it('should ignore the docs', () => { const processor = processorFactory(); const docs = [ { id: 'some-doc' }, { id: 'some-other' } ]; processor.$process(docs); expect(docs).toEqual([ { id: 'some-doc' }, { id: 'some-other' } ]); }); }); describe('docs with members', () => { it('should compute an anchor for each instance member', () => { const processor = processorFactory(); const docs = [ { id: 'some-doc', members: [ { name: 'foo' }, { name: 'new' }, { name: '' } ] } ]; processor.$process(docs); expect(docs[0].members.map(member => member.anchor)).toEqual(['foo', 'new', 'call']); }); it('should compute a path for each instance member', () => { const processor = processorFactory(); const docs = [ { id: 'some-doc', path: 'a/b/c', members: [ { name: 'foo' }, { name: 'new' }, { name: '' } ] } ]; processor.$process(docs); expect(docs[0].members.map(member => member.path)).toEqual(['a/b/c#foo', 'a/b/c#new', 'a/b/c#call']); }); }); describe('docs with static members', () => { it('should compute an anchor for each static member', () => { const processor = processorFactory(); const docs = [ { id: 'some-doc', statics: [ { name: 'foo' }, { name: 'new' }, { name: '' } ] } ]; processor.$process(docs); expect(docs[0].statics.map(member => member.anchor)).toEqual(['foo', 'new', 'call']); }); it('should compute a path for each static member', () => { const processor = processorFactory(); const docs = [ { id: 'some-doc', path: 'a/b/c', statics: [ { name: 'foo' }, { name: 'new' }, { name: '' } ] } ]; processor.$process(docs); expect(docs[0].statics.map(member => member.path)).toEqual(['a/b/c#foo', 'a/b/c#new', 'a/b/c#call']); }); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/splitDescription.js ================================================ /** * Split the description (of selected docs) into: * * `shortDescription`: the first paragraph * * `description`: the rest of the paragraphs */ module.exports = function splitDescription() { return { $runAfter: ['tags-extracted', 'migrateLegacyJSDocTags'], $runBefore: ['processing-docs'], docTypes: [], $process(docs) { docs.forEach(doc => { if (this.docTypes.indexOf(doc.docType) !== -1 && doc.description !== undefined) { const description = doc.description.trim(); const endOfParagraph = description.search(/\n\s*\n/); if (endOfParagraph === -1) { doc.shortDescription = description; doc.description = ''; } else { doc.shortDescription = description.substr(0, endOfParagraph).trim(); doc.description = description.substr(endOfParagraph).trim(); } } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/processors/splitDescription.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./splitDescription'); const Dgeni = require('dgeni'); describe('splitDescription processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('splitDescription'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['processing-docs']); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['tags-extracted', 'migrateLegacyJSDocTags']); }); it('should split the `description` property into the first paragraph and other paragraphs', () => { const processor = processorFactory(); processor.docTypes = ['test']; const docs = [ { docType: 'test' }, { docType: 'test', description: '' }, { docType: 'test', description: 'abc' }, { docType: 'test', description: 'abc\n' }, { docType: 'test', description: 'abc\n\n' }, { docType: 'test', description: 'abc\ncde' }, { docType: 'test', description: 'abc\ncde\n' }, { docType: 'test', description: 'abc\n\ncde' }, { docType: 'test', description: 'abc\n \ncde' }, { docType: 'test', description: 'abc\n\n\ncde' }, { docType: 'test', description: 'abc\n\ncde\nfgh' }, { docType: 'test', description: 'abc\n\ncde\n\nfgh' }, ]; processor.$process(docs); expect(docs).toEqual([ { docType: 'test' }, { docType: 'test', shortDescription: '', description: '' }, { docType: 'test', shortDescription: 'abc', description: '' }, { docType: 'test', shortDescription: 'abc', description: '' }, { docType: 'test', shortDescription: 'abc', description: '' }, { docType: 'test', shortDescription: 'abc\ncde', description: '' }, { docType: 'test', shortDescription: 'abc\ncde', description: '' }, { docType: 'test', shortDescription: 'abc', description: 'cde' }, { docType: 'test', shortDescription: 'abc', description: 'cde' }, { docType: 'test', shortDescription: 'abc', description: 'cde' }, { docType: 'test', shortDescription: 'abc', description: 'cde\nfgh' }, { docType: 'test', shortDescription: 'abc', description: 'cde\n\nfgh' }, ]); }); it('should ignore docs that do not match the specified doc types', () => { const processor = processorFactory(); processor.docTypes = ['test']; const docs = [ { docType: 'test', description: 'abc\n\ncde' }, { docType: 'other', description: 'abc\n\ncde' } ]; processor.$process(docs); expect(docs).toEqual([ { docType: 'test', shortDescription: 'abc', description: 'cde' }, { docType: 'other', description: 'abc\n\ncde' } ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/tag-defs/deprecated.js ================================================ module.exports = function() { return {name: 'deprecated'}; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/tag-defs/internal.js ================================================ /** * Use this tag to ensure that dgeni does not include this code item * in the rendered docs. * * The `@internal` tag indicates to the compiler not to include the * item in the public typings file. * Use the `@nodoc` alias if you only want to hide the item from the * docs but not from the typings file. */ module.exports = function() { return { name: 'internal', aliases: ['nodoc'], transforms: function() { return true; } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-api-package/tag-defs/throws.js ================================================ module.exports = function(extractTypeTransform, wholeTagTransform) { return { name: 'throws', aliases: ['exception'], multi: true, transforms: [ extractTypeTransform, wholeTagTransform ] }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/ignore-words.json ================================================ [ "a", "able", "about", "above", "abst", "accordance", "according", "accordingly", "across", "act", "actually", "added", "adj", "adopted", "affected", "affecting", "affects", "after", "afterwards", "again", "against", "ah", "all", "almost", "alone", "along", "already", "also", "although", "always", "am", "among", "amongst", "an", "and", "announce", "another", "any", "anybody", "anyhow", "anymore", "anyone", "anything", "anyway", "anyways", "anywhere", "apparently", "approximately", "are", "aren", "arent", "arise", "around", "as", "aside", "ask", "asking", "at", "auth", "available", "away", "awfully", "b", "back", "be", "became", "because", "become", "becomes", "becoming", "been", "before", "beforehand", "begin", "beginning", "beginnings", "begins", "behind", "being", "believe", "below", "beside", "besides", "between", "beyond", "biol", "both", "brief", "briefly", "but", "by", "c", "ca", "came", "can", "cannot", "can't", "cant", "cause", "causes", "certain", "certainly", "co", "com", "come", "comes", "contain", "containing", "contains", "could", "couldnt", "d", "date", "did", "didn't", "didnt", "different", "do", "does", "doesn't", "doesnt", "doing", "done", "don't", "dont", "down", "downwards", "due", "during", "e", "each", "ed", "edu", "effect", "eg", "eight", "eighty", "either", "else", "elsewhere", "end", "ending", "enough", "especially", "et", "et-al", "etc", "even", "ever", "every", "everybody", "everyone", "everything", "everywhere", "ex", "except", "f", "far", "few", "ff", "fifth", "first", "five", "fix", "followed", "following", "follows", "for", "former", "formerly", "forth", "found", "four", "from", "further", "furthermore", "g", "gave", "get", "gets", "getting", "give", "given", "gives", "giving", "go", "goes", "gone", "got", "gotten", "h", "had", "happens", "hardly", "has", "hasn't", "hasnt", "have", "haven't", "havent", "having", "he", "hed", "hence", "her", "here", "hereafter", "hereby", "herein", "heres", "hereupon", "hers", "herself", "hes", "hi", "hid", "him", "himself", "his", "hither", "home", "how", "howbeit", "however", "hundred", "i", "id", "ie", "if", "i'll", "ill", "im", "immediate", "immediately", "importance", "important", "in", "inc", "indeed", "index", "information", "instead", "into", "invention", "inward", "is", "isn't", "isnt", "it", "itd", "it'll", "itll", "its", "itself", "i've", "ive", "j", "just", "k", "keep", "keeps", "kept", "keys", "kg", "km", "know", "known", "knows", "l", "largely", "last", "lately", "later", "latter", "latterly", "least", "less", "lest", "let", "lets", "like", "liked", "likely", "line", "little", "'ll", "'ll", "look", "looking", "looks", "ltd", "m", "made", "mainly", "make", "makes", "many", "may", "maybe", "me", "mean", "means", "meantime", "meanwhile", "merely", "mg", "might", "million", "miss", "ml", "more", "moreover", "most", "mostly", "mr", "mrs", "much", "mug", "must", "my", "myself", "n", "na", "name", "namely", "nay", "nd", "near", "nearly", "necessarily", "necessary", "need", "needs", "neither", "never", "nevertheless", "new", "next", "nine", "ninety", "no", "nobody", "non", "none", "nonetheless", "noone", "nor", "normally", "nos", "not", "noted", "nothing", "now", "nowhere", "o", "obtain", "obtained", "obviously", "of", "off", "often", "oh", "ok", "okay", "old", "omitted", "on", "once", "one", "ones", "only", "onto", "or", "ord", "other", "others", "otherwise", "ought", "our", "ours", "ourselves", "out", "outside", "over", "overall", "owing", "own", "p", "page", "pages", "part", "particular", "particularly", "past", "per", "perhaps", "placed", "please", "plus", "poorly", "possible", "possibly", "potentially", "pp", "predominantly", "present", "previously", "primarily", "probably", "promptly", "proud", "provides", "put", "q", "que", "quickly", "quite", "qv", "r", "ran", "rather", "rd", "re", "readily", "really", "recent", "recently", "ref", "refs", "regarding", "regardless", "regards", "related", "relatively", "research", "respectively", "resulted", "resulting", "results", "right", "run", "s", "said", "same", "saw", "say", "saying", "says", "sec", "section", "see", "seeing", "seem", "seemed", "seeming", "seems", "seen", "self", "selves", "sent", "seven", "several", "shall", "she", "shed", "she'll", "shell", "shes", "should", "shouldn't", "shouldnt", "show", "showed", "shown", "showns", "shows", "significant", "significantly", "similar", "similarly", "since", "six", "slightly", "so", "some", "somebody", "somehow", "someone", "somethan", "something", "sometime", "sometimes", "somewhat", "somewhere", "soon", "sorry", "specifically", "specified", "specify", "specifying", "state", "states", "still", "stop", "strongly", "sub", "substantially", "successfully", "such", "sufficiently", "suggest", "sup", "sure", "t", "take", "taken", "taking", "tell", "tends", "th", "than", "thank", "thanks", "thanx", "that", "that'll", "thatll", "thats", "that've", "thatve", "the", "their", "theirs", "them", "themselves", "then", "thence", "there", "thereafter", "thereby", "thered", "therefore", "therein", "there'll", "therell", "thereof", "therere", "theres", "thereto", "thereupon", "there've", "thereve", "these", "they", "theyd", "they'll", "theyll", "theyre", "they've", "theyve", "think", "this", "those", "thou", "though", "thoughh", "thousand", "throug", "through", "throughout", "thru", "thus", "til", "tip", "to", "together", "too", "took", "toward", "towards", "tried", "tries", "truly", "try", "trying", "ts", "twice", "two", "u", "un", "under", "unfortunately", "unless", "unlike", "unlikely", "until", "unto", "up", "upon", "ups", "us", "use", "used", "useful", "usefully", "usefulness", "uses", "using", "usually", "v", "value", "various", "'ve", "'ve", "very", "via", "viz", "vol", "vols", "vs", "w", "want", "wants", "was", "wasn't", "wasnt", "way", "we", "wed", "welcome", "we'll", "well", "went", "were", "weren't", "werent", "we've", "weve", "what", "whatever", "what'll", "whatll", "whats", "when", "whence", "whenever", "where", "whereafter", "whereas", "whereby", "wherein", "wheres", "whereupon", "wherever", "whether", "which", "while", "whim", "whither", "who", "whod", "whoever", "whole", "who'll", "wholl", "whom", "whomever", "whos", "whose", "why", "widely", "will", "willing", "wish", "with", "within", "without", "won't", "wont", "words", "would", "wouldn't", "wouldnt", "www", "x", "y", "yes", "yet", "you", "youd", "you'll", "youll", "your", "youre", "yours", "yourself", "yourselves", "you've", "youve", "z", "zero" ] ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/index.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const path = require('path'); const Package = require('dgeni').Package; const jsdocPackage = require('dgeni-packages/jsdoc'); const nunjucksPackage = require('dgeni-packages/nunjucks'); const linksPackage = require('../links-package'); const remarkPackage = require('../remark-package'); const postProcessPackage = require('dgeni-packages/post-process-html'); const { PROJECT_ROOT, CONTENTS_PATH, OUTPUT_PATH, DOCS_OUTPUT_PATH, TEMPLATES_PATH, AIO_PATH, requireFolder } = require('../config'); // prettier-ignore module.exports = new Package('angular-base', [ jsdocPackage, nunjucksPackage, linksPackage, remarkPackage, postProcessPackage, ]) // Register the processors .processor(require('./processors/generateKeywords')) .processor(require('./processors/createSitemap')) .processor(require('./processors/checkUnbalancedBackTicks')) .processor(require('./processors/convertToJson')) .processor(require('./processors/fixInternalDocumentLinks')) .processor(require('./processors/copyContentAssets')) .processor(require('./processors/renderLinkInfo')) // overrides base packageInfo and returns the one for the 'angular/angular' repo. .factory('packageInfo', function () { return require(path.resolve(PROJECT_ROOT, './packages/rxjs/package.json')); }) .factory(require('./readers/json')) .factory(require('./services/copyFolder')) .factory(require('./services/filterPipes')) .factory(require('./services/filterAmbiguousDirectiveAliases')) .factory(require('./services/filterFromInImports')) .factory(require('./services/getImageDimensions')) .factory(require('./post-processors/add-image-dimensions')) .factory(require('./post-processors/auto-link-code')) .config(function (checkAnchorLinksProcessor) { // This is disabled here to prevent false negatives for the `docs-watch` task. // It is re-enabled in the main `angular.io-package` checkAnchorLinksProcessor.$enabled = false; }) // Where do we get the source files? .config(function (readFilesProcessor, generateKeywordsProcessor, jsonFileReader) { readFilesProcessor.fileReaders.push(jsonFileReader); readFilesProcessor.basePath = PROJECT_ROOT; readFilesProcessor.sourceFiles = []; generateKeywordsProcessor.ignoreWords = require(path.resolve(__dirname, 'ignore-words')); generateKeywordsProcessor.docTypesToIgnore = [ undefined, 'json-doc', 'api-list-data', 'api-list-data', 'contributors-json', 'navigation-json', 'announcements-json', ]; generateKeywordsProcessor.propertiesToIgnore = ['basePath', 'renderedContent', 'docType', 'searchTitle']; }) // Where do we write the output files? .config(function (writeFilesProcessor) { writeFilesProcessor.outputFolder = DOCS_OUTPUT_PATH; }) // Configure nunjucks rendering of docs via templates .config(function (renderDocsProcessor, templateFinder, templateEngine, getInjectables) { // Where to find the templates for the doc rendering templateFinder.templateFolders = [TEMPLATES_PATH]; // Standard patterns for matching docs to templates templateFinder.templatePatterns = [ '${ doc.template }', '${ doc.id }.${ doc.docType }.template.html', '${ doc.id }.template.html', '${ doc.docType }.template.html', '${ doc.id }.${ doc.docType }.template.js', '${ doc.id }.template.js', '${ doc.docType }.template.js', '${ doc.id }.${ doc.docType }.template.json', '${ doc.id }.template.json', '${ doc.docType }.template.json', 'common.template.html', ]; // Nunjucks and Angular conflict in their template bindings so change Nunjucks templateEngine.config.tags = { variableStart: '{$', variableEnd: '$}' }; templateEngine.filters = templateEngine.filters.concat(getInjectables(requireFolder(__dirname, './rendering'))); // helpers are made available to the nunjucks templates renderDocsProcessor.helpers.relativePath = function (from, to) { return path.relative(from, to); }; }) .config(function (copyContentAssetsProcessor) { copyContentAssetsProcessor.assetMappings.push({ from: path.resolve(CONTENTS_PATH, 'images'), to: path.resolve(OUTPUT_PATH, 'images') }); }) // We are not going to be relaxed about ambiguous links .config(function (getLinkInfo) { getLinkInfo.useFirstAmbiguousLink = false; }) .config(function (generateKeywordsProcessor) { generateKeywordsProcessor.outputFolder = 'app'; }) .config(function ( postProcessHtml, addImageDimensions, autoLinkCode, filterPipes, filterAmbiguousDirectiveAliases, filterFromInImports ) { addImageDimensions.basePath = path.resolve(AIO_PATH, 'src'); autoLinkCode.customFilters = [filterPipes, filterAmbiguousDirectiveAliases]; autoLinkCode.wordFilters = [filterFromInImports]; postProcessHtml.plugins = [ require('./post-processors/autolink-headings'), addImageDimensions, require('./post-processors/h1-checker'), autoLinkCode, ]; }) .config(function (convertToJsonProcessor) { convertToJsonProcessor.docTypes = []; }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/add-image-dimensions.js ================================================ const visit = require('unist-util-visit'); const is = require('hast-util-is-element'); const source = require('unist-util-source'); /** * Add the width and height of the image to the `img` tag if they are * not already provided. This helps prevent jank when the page is * rendered before the image has downloaded. * * If there is no `src` attribute on an image, or it is not possible * to load the image file indicated by the `src` then a warning is emitted. */ module.exports = function addImageDimensions(getImageDimensions) { return function addImageDimensionsImpl() { return (ast, file) => { visit(ast, node => { if (is(node, 'img')) { const props = node.properties; const src = props.src; if (!src) { file.message('Missing src in image tag `' + source(node, file) + '`'); } else { try { const dimensions = getImageDimensions(addImageDimensionsImpl.basePath, src); if (props.width === undefined && props.height === undefined) { props.width = '' + dimensions.width; props.height = '' + dimensions.height; } } catch(e) { if (e.code === 'ENOENT') { file.message('Unable to load src in image tag `' + source(node, file) + '`'); } else { file.fail(e.message); } } } } }); }; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/add-image-dimensions.spec.js ================================================ var createTestPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('addImageDimensions post-processor', () => { let processor, getImageDimensionsSpy, addImageDimensions, log; beforeEach(() => { const testPackage = createTestPackage('angular-base-package') .factory('getImageDimensions', mockGetImageDimensions); const dgeni = new Dgeni([testPackage]); const injector = dgeni.configureInjector(); log = injector.get('log'); addImageDimensions = injector.get('addImageDimensions'); addImageDimensions.basePath = 'base/path'; getImageDimensionsSpy = injector.get('getImageDimensions'); processor = injector.get('postProcessHtml'); processor.docTypes = ['a']; processor.plugins = [addImageDimensions]; }); it('should add the image dimensions into tags', () => { const docs = [{ docType: 'a', renderedContent: `

xxx

yyy

zzz

` }]; processor.$process(docs); expect(getImageDimensionsSpy).toHaveBeenCalledWith('base/path', 'a/b.jpg'); expect(getImageDimensionsSpy).toHaveBeenCalledWith('base/path', 'c/d.png'); expect(docs).toEqual([jasmine.objectContaining({ docType: 'a', renderedContent: `

xxx

yyy

zzz

` })]); }); it('should log a warning for images with no src attribute', () => { const docs = [{ docType: 'a', renderedContent: '' }]; processor.$process(docs); expect(getImageDimensionsSpy).not.toHaveBeenCalled(); expect(docs).toEqual([jasmine.objectContaining({ docType: 'a', renderedContent: '' })]); expect(log.warn).toHaveBeenCalledWith('Missing src in image tag `` - doc (a) '); }); it('should fail for images whose source cannot be loaded', () => { getImageDimensionsSpy.and.callFake(() => { const error = new Error('no such file or directory'); error.code = 'ENOENT'; throw error; }); const docs = [{ docType: 'a', renderedContent: '' }]; processor.$process(docs); expect(log.warn).toHaveBeenCalledWith('Unable to load src in image tag `` - doc (a) '); expect(getImageDimensionsSpy).toHaveBeenCalledWith('base/path', 'missing'); }); it('should ignore images with width or height attributes', () => { const docs = [{ docType: 'a', renderedContent: ` ` }]; processor.$process(docs); expect(docs).toEqual([jasmine.objectContaining({ docType: 'a', renderedContent: ` ` })]); }); function mockGetImageDimensions() { const imageInfo = { 'a/b.jpg': { width: 10, height: 20 }, 'c/d.png': { width: 30, height: 40 }, }; // eslint-disable-next-line jasmine/no-unsafe-spy return jasmine.createSpy('getImageDimensions') .and.callFake((base, url) => imageInfo[url]); } }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/auto-link-code.js ================================================ const visit = require('unist-util-visit-parents'); const is = require('hast-util-is-element'); const textContent = require('hast-util-to-string'); /** * Automatically add in a link to the relevant document for code blocks. * E.g. `MyClass` becomes `MyClass` * * @property docTypes an array of strings. * Only docs that have one of these docTypes will be linked to. * Usually set to the API exported docTypes, e.g. "class", "function", "directive", etc. * * @property customFilters array of functions `(docs, words, wordIndex) => docs` that will filter * out docs where a word should not link to a doc. * - `docs` is the array of docs that match the link `word` * - `words` is the collection of words parsed from the code text * - `wordIndex` is the index of the current `word` for which we are finding a link * * @property codeElements an array of strings. * Only text contained in these elements will be linked to. * Usually set to "code" but also "code-example" for angular.io. */ module.exports = function autoLinkCode(getDocFromAlias) { autoLinkCodeImpl.docTypes = []; autoLinkCodeImpl.customFilters = []; autoLinkCodeImpl.wordFilters = []; autoLinkCodeImpl.codeElements = ['code']; autoLinkCodeImpl.ignoredLanguages = ['bash', 'sh', 'shell', 'json', 'markdown']; autoLinkCodeImpl.failOnMissingDocPath = false; return autoLinkCodeImpl; function autoLinkCodeImpl() { return (ast, file) => { visit(ast, 'element', (node, ancestors) => { if (!isValidCodeElement(node, ancestors)) { return; } visit(node, 'text', (node, ancestors) => { const isInLink = isInsideLink(ancestors); if (isInLink) { return; } const parent = ancestors[ancestors.length - 1]; const index = parent.children.indexOf(node); // Can we convert the whole text node into a doc link? const docs = getFilteredDocsFromAlias([node.value], 0); if (foundValidDoc(docs, node.value, file)) { parent.children.splice(index, 1, createLinkNode(docs[0], node.value)); } else { // Parse the text for words that we can convert to links const nodes = getNodes(node, file); // Replace the text node with the links and leftover text nodes Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes)); // Do not visit this node's children or the newly added nodes return [visit.SKIP, index + nodes.length]; } }); }); }; } function isValidCodeElement(node, ancestors) { // Only interested in code elements that: // * do not have `no-auto-link` class // * do not have an ignored language // * are not inside links const isCodeElement = autoLinkCodeImpl.codeElements.some(elementType => is(node, elementType)); const hasNoAutoLink = node.properties.className && node.properties.className.includes('no-auto-link'); const isLanguageSupported = !autoLinkCodeImpl.ignoredLanguages.includes(node.properties.language); const isInLink = isInsideLink(ancestors); return isCodeElement && !hasNoAutoLink && isLanguageSupported && !isInLink; } function isInsideLink(ancestors) { return ancestors.some(ancestor => is(ancestor, 'a')); } function getFilteredDocsFromAlias(words, index) { // Remove docs that fail the custom filter tests. return autoLinkCodeImpl.customFilters.reduce( (docs, filter) => filter(docs, words, index), getDocFromAlias(words[index])); } function shouldSkipFindingValidDoc(words, index) { return autoLinkCodeImpl.wordFilters.reduce((skip, filter) => skip || filter(words, index), false); } function getNodes(node, file) { return textContent(node) .split(/([A-Za-z0-9_.-]+)/) .filter(word => word.length) .map((word, index, words) => { const filteredDocs = getFilteredDocsFromAlias(words, index); const skipFindingValidDoc = shouldSkipFindingValidDoc(words, index); return !skipFindingValidDoc && foundValidDoc(filteredDocs, word, file) ? // Create a link wrapping the text node. createLinkNode(filteredDocs[0], word) : // this is just text so push a new text node {type: 'text', value: word}; }); } /** * Validates the docs to be used to generate the links. The validation ensures * that the docs are not `internal` and that the `docType` is supported. The `path` * can be empty when the `API` is not public. * * @param {Array} docs An array of objects containing the doc details * * @param {string} keyword The keyword the doc applies to */ function foundValidDoc(docs, keyword, file) { if (docs.length !== 1) { return false; } var doc = docs[0]; const isInvalidDoc = doc.docType === 'member' && !keyword.includes('.'); if (isInvalidDoc) { return false; } if (!doc.path) { var message = ` autoLinkCode: Doc path is empty for "${doc.id}" - link will not be generated for "${keyword}". Please make sure if the doc should be public. If not, it should probably not be referenced in the docs.`; if (autoLinkCodeImpl.failOnMissingDocPath) { file.fail(message); } else { file.message(message); } return false; } return !doc.internal && autoLinkCodeImpl.docTypes.includes(doc.docType); } function createLinkNode(doc, text) { return { type: 'element', tagName: 'a', properties: {href: doc.path, class: 'code-anchor'}, children: [{type: 'text', value: text}] }; } }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/auto-link-code.spec.js ================================================ var createTestPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('autoLinkCode post-processor', () => { let processor, autoLinkCode, aliasMap, filterPipes; beforeEach(() => { const testPackage = createTestPackage('angular-base-package'); const dgeni = new Dgeni([testPackage]); const injector = dgeni.configureInjector(); autoLinkCode = injector.get('autoLinkCode'); autoLinkCode.docTypes = ['class', 'pipe', 'function', 'const', 'member']; aliasMap = injector.get('aliasMap'); processor = injector.get('postProcessHtml'); processor.docTypes = ['test-doc']; processor.plugins = [autoLinkCode]; filterPipes = injector.get('filterPipes'); }); it('should insert an anchor into every code item that matches the id of an API doc', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('MyClass'); }); it('should insert an anchor into every code item that matches an alias of an API doc', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass', 'foo.MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'foo.MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('foo.MyClass'); }); it('should match code items within a block of code that contain a dot in their identifier', () => { aliasMap.addDoc({ docType: 'member', id: 'MyEnum.Value', aliases: ['Value', 'MyEnum.Value'], path: 'a/b/myenum' }); const doc = { docType: 'test-doc', renderedContent: 'someFn(): MyEnum.Value' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('someFn(): MyEnum.Value'); }); it('should ignore code items that do not match a link to an API doc', () => { aliasMap.addDoc({ docType: 'guide', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('MyClass'); }); it('should ignore code items that are already inside a link', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: '
MyClass
' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('
MyClass
'); }); it('should ignore code items match an API doc but are not in the list of acceptable docTypes', () => { aliasMap.addDoc({ docType: 'directive', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('MyClass'); }); it('should ignore code items that match an API doc but are attached to other text via a dash', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'xyz-MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('xyz-MyClass'); }); it('should ignore code items that are filtered out by custom filters', () => { autoLinkCode.customFilters = [filterPipes]; aliasMap.addDoc({ docType: 'pipe', id: 'MyClass', aliases: ['MyClass', 'myClass'], path: 'a/b/myclass', pipeOptions: { name: '\'myClass\'' } }); const doc = { docType: 'test-doc', renderedContent: '{ xyz | myClass } { xyz|myClass } MyClass myClass OtherClass|MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('' + '{ xyz | myClass } ' + '{ xyz|myClass } ' + 'MyClass ' + 'myClass OtherClass|MyClass' + ''); }); it('should insert anchors for individual text nodes within a code block', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'MyClassMyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('MyClassMyClass'); }); it('should insert anchors for words that match within text nodes in a code block', () => { aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); aliasMap.addDoc({ docType: 'function', id: 'myFunc', aliases: ['myFunc'], path: 'ng/myfunc' }); aliasMap.addDoc({ docType: 'const', id: 'MY_CONST', aliases: ['MY_CONST'], path: 'ng/my_const' }); const doc = { docType: 'test-doc', renderedContent: 'myFunc() {\n return new MyClass(MY_CONST);\n}' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('myFunc() {\n return new MyClass(MY_CONST);\n}'); }); it('should work with custom elements', () => { autoLinkCode.codeElements = ['code-example']; aliasMap.addDoc({ docType: 'class', id: 'MyClass', aliases: ['MyClass'], path: 'a/b/myclass' }); const doc = { docType: 'test-doc', renderedContent: 'MyClass' }; processor.$process([doc]); expect(doc.renderedContent).toEqual('MyClass'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/autolink-headings.js ================================================ const has = require('hast-util-has-property'); const is = require('hast-util-is-element'); const slug = require('rehype-slug'); const visit = require('unist-util-visit'); /** * Get remark to add IDs to headings and inject anchors into them. * This is a stripped-down equivalent of [rehype-autolink-headings](https://github.com/wooorm/rehype-autolink-headings) * that supports ignoring headings with the `no-anchor` class. */ const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; const NO_ANCHOR_CLASS = 'no-anchor'; const clone = obj => JSON.parse(JSON.stringify(obj)); const hasClass = (node, cls) => { const className = node.properties.className; return className && className.includes(cls); }; const link = options => tree => visit(tree, node => { if (is(node, HEADINGS) && has(node, 'id') && !hasClass(node, NO_ANCHOR_CLASS)) { node.children.push({ type: 'element', tagName: 'a', properties: Object.assign(clone(options.properties), {href: `#${node.properties.id}`}), children: clone(options.content) }); } }); module.exports = [ slug, [link, { properties: { title: 'Link to this heading', className: ['header-link'], 'aria-hidden': 'true' }, content: [ { type: 'element', tagName: 'i', properties: {className: ['material-icons']}, children: [{ type: 'text', value: 'link' }] } ] }] ]; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/autolink-headings.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); const plugin = require('./autolink-headings'); describe('autolink-headings postprocessor', () => { let processor; beforeEach(() => { const dgeni = new Dgeni([testPackage('angular-base-package')]); const injector = dgeni.configureInjector(); processor = injector.get('postProcessHtml'); processor.docTypes = ['a']; processor.plugins = [plugin]; }); it('should add anchors to headings', () => { const originalContent = `

Heading 1

Heading with bold

Heading with encoded chars &

`; const processedContent = `

Heading 1link

Heading with boldlink

Heading with encoded chars &link

`; const docs = [{docType: 'a', renderedContent: originalContent}]; processor.$process(docs); expect(docs[0].renderedContent).toBe(processedContent); }); it('should ignore headings with the `no-anchor` class', () => { const originalContent = `

Heading 1

Heading with bold

Heading with encoded chars &

`; const processedContent = `

Heading 1

Heading with bold

Heading with encoded chars &

`; const docs = [{docType: 'a', renderedContent: originalContent}]; processor.$process(docs); expect(docs[0].renderedContent).toBe(processedContent); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/h1-checker.js ================================================ const visit = require('unist-util-visit'); const is = require('hast-util-is-element'); const toString = require('hast-util-to-string'); const filter = require('unist-util-filter'); module.exports = function h1CheckerPostProcessor() { return (ast, file) => { file.headings = { h1: [], h2: [], h3: [], h4: [], h5: [], h6: [], hgroup: [] }; visit(ast, node => { if (is(node, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup'])) { file.headings[node.tagName].push(getText(node)); } }); file.title = file.headings.h1[0]; if (file.headings.h1.length > 1) { file.fail(`More than one h1 found in ${file}`); } }; }; function getText(h1) { // Remove the aria-hidden anchor from the h1 node const cleaned = filter(h1, node => !( is(node, 'a') && node.properties && (node.properties.ariaHidden === 'true' || node.properties['aria-hidden'] === 'true') )); return cleaned ? toString(cleaned) : ''; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/post-processors/h1-checker.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); const plugin = require('./h1-checker'); describe('h1Checker postprocessor', () => { let processor, createDocMessage; beforeEach(() => { const dgeni = new Dgeni([testPackage('angular-base-package')]); const injector = dgeni.configureInjector(); createDocMessage = injector.get('createDocMessage'); processor = injector.get('postProcessHtml'); processor.docTypes = ['a']; processor.plugins = [plugin]; }); it('should complain if there is more than one h1 in a document', () => { const doc = { docType: 'a', renderedContent: `

Heading 1

Heading 2

Heading 1a

` }; expect(() => processor.$process([doc])).toThrowError(createDocMessage('More than one h1 found in ' + doc.renderedContent, doc)); }); it('should not complain if there is exactly one h1 in a document', () => { const doc = { docType: 'a', renderedContent: `

Heading 1

Heading 2

` }; expect(() => processor.$process([doc])).not.toThrow(); }); it('should not complain if there are no h1s in a document', () => { const doc = { docType: 'a', renderedContent: `

Heading 2

` }; expect(() => processor.$process([doc])).not.toThrow(); }); it('should attach the h1 text to the vFile', () => { const doc = { docType: 'a', renderedContent: '

Heading 1

' }; processor.$process([doc]); expect(doc.vFile.title).toEqual('Heading 1'); }); it('should clean aria-hidden anchors from h1 text added to the vFile', () => { const doc = { docType: 'a', renderedContent: '

' + '' + 'link' + 'What is Angular?' + '

' }; processor.$process([doc]); expect(doc.vFile.title).toEqual('What is Angular?'); }); it('should not break if the h1 is empty (except for an aria-hidden anchor)', () => { const doc = { docType: 'a', renderedContent: `

` }; expect(() => processor.$process([doc])).not.toThrow(); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/checkUnbalancedBackTicks.js ================================================ var _ = require('lodash'); /** * @dgProcessor checkUnbalancedBackTicks * @description * Searches the rendered content for an odd number of (```) backticks, * which would indicate an unbalanced pair and potentially a typo in the * source content. */ module.exports = function checkUnbalancedBackTicks(log, createDocMessage) { var BACKTICK_REGEX = /^ *```/gm; return { // $runAfter: ['checkAnchorLinksProcessor'], $runAfter: ['inlineTagProcessor'], $runBefore: ['writeFilesProcessor'], $process: function(docs) { _.forEach(docs, function(doc) { if (doc.renderedContent) { var matches = doc.renderedContent.match(BACKTICK_REGEX); if (matches && matches.length % 2 !== 0) { doc.unbalancedBackTicks = true; log.warn(createDocMessage( 'checkUnbalancedBackTicks processor: unbalanced backticks found in rendered content', doc)); log.warn(doc.renderedContent); } } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/checkUnbalancedBackTicks.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('checkUnbalancedBackTicks', function() { var dgeni, injector, processor, log; beforeEach(function() { dgeni = new Dgeni([testPackage('angular-base-package')]); injector = dgeni.configureInjector(); processor = injector.get('checkUnbalancedBackTicks'); log = injector.get('log'); }); it('should warn if there are an odd number of back ticks in the rendered content', function() { var docs = [{ renderedContent: '```\n' + 'code block\n' + '```\n' + '```\n' + 'code block with missing closing back ticks\n' }]; processor.$process(docs); expect(log.warn).toHaveBeenCalledWith( 'checkUnbalancedBackTicks processor: unbalanced backticks found in rendered content - doc'); expect(docs[0].unbalancedBackTicks).toBe(true); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/convertToJson.js ================================================ module.exports = function convertToJsonProcessor(log, createDocMessage) { return { $runAfter: ['checkUnbalancedBackTicks'], $runBefore: ['writeFilesProcessor'], docTypes: [], $process: function(docs) { const docTypes = this.docTypes; docs.forEach((doc) => { if (docTypes.indexOf(doc.docType) !== -1) { let contents = doc.renderedContent || ''; let title = doc.title; // We do allow an empty `title` but if it is `undefined` we resort to `vFile.title` and then `name` if (title === undefined) { title = (doc.vFile && doc.vFile.title); } if (title === undefined) { title = doc.name; } // If there is still no title then log a warning if (title === undefined) { title = ''; log.warn(createDocMessage('Title property expected', doc)); } doc.renderedContent = JSON.stringify({ id: doc.path, title, contents }, null, 2); } }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/convertToJson.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('convertToJson processor', () => { var dgeni, injector, processor, log; beforeEach(function() { dgeni = new Dgeni([testPackage('angular-base-package')]); injector = dgeni.configureInjector(); processor = injector.get('convertToJsonProcessor'); log = injector.get('log'); processor.docTypes = ['test-doc']; }); it('should be part of the dgeni package', () => { expect(processor).toBeDefined(); }); it('should convert the renderedContent to JSON', () => { const docs = [{ docType: 'test-doc', title: 'The Title', name: 'The Name', path: 'test/doc', renderedContent: 'Some Content' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).id).toEqual('test/doc'); expect(JSON.parse(docs[0].renderedContent).title).toEqual('The Title'); expect(JSON.parse(docs[0].renderedContent).contents).toEqual('Some Content'); }); it('should get the title from name if no title is specified', () => { const docs = [{ docType: 'test-doc', name: 'The Name' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).title).toEqual('The Name'); }); it('should accept an empty title', () => { const docs = [{ docType: 'test-doc', title: '' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).title).toEqual(''); expect(log.warn).not.toHaveBeenCalled(); }); it('should accept an empty name if title is not provided', () => { const docs = [{ docType: 'test-doc', name: '' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).title).toEqual(''); expect(log.warn).not.toHaveBeenCalled(); }); it('should get the title from the title extracted from the h1 in the rendered content if no title property is specified', () => { const docs = [{ docType: 'test-doc', vFile: { title: 'Some title' }, renderedContent: '

Some title

Article 1

' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).contents).toEqual('

Some title

Article 1

'); expect(JSON.parse(docs[0].renderedContent).title).toEqual('Some title'); }); it('should set missing titles to empty', () => { const docs = [{ docType: 'test-doc' }]; processor.$process(docs); expect(JSON.parse(docs[0].renderedContent).title).toBe(''); }); it('should log a warning', () => { const docs = [{ docType: 'test-doc' }]; processor.$process(docs); expect(log.warn).toHaveBeenCalledWith('Title property expected - doc (test-doc) '); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/copyContentAssets.js ================================================ module.exports = function copyContentAssetsProcessor(copyFolder) { return { $runBefore: ['postProcessHtml'], assetMappings: [], $process() { this.assetMappings.forEach(map => { copyFolder(map.from, map.to); }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/copyContentAssets.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const Dgeni = require('dgeni'); const factory = require('./copyContentAssets'); describe('extractDecoratedClasses processor', function() { let dgeni, injector, processor; beforeEach(function() { dgeni = new Dgeni([testPackage('angular-content-package')]); injector = dgeni.configureInjector(); processor = injector.get('copyContentAssetsProcessor'); }); it('should exist', () => { expect(processor).toBeDefined(); }); it('should call copyFolder with each mapping', () => { const mockCopyFolder = jasmine.createSpy(); processor = factory(mockCopyFolder); processor.assetMappings.push({ from: 'a/b/c', to: 'x/y/z' }); processor.assetMappings.push({ from: '1/2/3', to: '4/5/6' }); processor.$process(); expect(mockCopyFolder).toHaveBeenCalledWith('a/b/c', 'x/y/z'); expect(mockCopyFolder).toHaveBeenCalledWith('1/2/3', '4/5/6'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/createSitemap.js ================================================ module.exports = function createSitemap() { return { blacklistedDocTypes: [ 'navigation-json', 'contributors-json', 'resources-json', ], blacklistedPaths: [ 'test', 'file-not-found', 'overview-dump' ], $runAfter: ['paths-computed'], $runBefore: ['rendering-docs'], $process(docs) { docs.push({ id: 'sitemap.xml', path: 'sitemap.xml', outputPath: '../sitemap.xml', template: 'sitemap.template.xml', urls: docs // Filter out docs that are not outputted .filter(doc => doc.outputPath) // Filter out unwanted docs .filter(doc => this.blacklistedDocTypes.indexOf(doc.docType) === -1) .filter(doc => this.blacklistedPaths.indexOf(doc.path) === -1) // Filter out duplicate renamed exports .filter(doc => !doc.duplicateOf) // Capture the path of each doc .map(doc => doc.path) // Convert the homepage: `index` to `/` .map(path => path === 'index' ? '' : path) }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/createSitemap.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('createSitemap processor', () => { var injector, processor; beforeEach(() => { const dgeni = new Dgeni([testPackage('angular-base-package')]); injector = dgeni.configureInjector(); processor = injector.get('createSitemap'); }); it('should be available from the injector', () => { expect(processor).toBeDefined(); }); it('should run after "paths-computed"', () => { expect(processor.$runAfter).toEqual(['paths-computed']); }); it('should run before "rendering-docs"', () => { expect(processor.$runBefore).toEqual(['rendering-docs']); }); describe('$process', () => { describe('should add a sitemap doc', () => { it('with the correct id, path, outputPath and template properties', () => { const docs = []; processor.$process(docs); expect(docs.pop()).toEqual(jasmine.objectContaining({ id: 'sitemap.xml', path: 'sitemap.xml', outputPath: '../sitemap.xml', template: 'sitemap.template.xml' })); }); it('with an array of urls for each doc that has an outputPath', () => { const docs = [ { path: 'abc', outputPath: 'abc' }, { path: 'cde' }, { path: 'fgh', outputPath: 'fgh' }, ]; processor.$process(docs); expect(docs.pop().urls).toEqual(['abc', 'fgh']); }); it('ignoring blacklisted doc types', () => { const docs = [ { path: 'abc', outputPath: 'abc', docType: 'good' }, { path: 'cde', outputPath: 'cde', docType: 'bad' }, { path: 'fgh', outputPath: 'fgh', docType: 'good' }, ]; processor.blacklistedDocTypes = ['bad']; processor.$process(docs); expect(docs.pop().urls).toEqual(['abc', 'fgh']); }); it('ignoring blacklisted paths', () => { const docs = [ { path: 'abc', outputPath: 'abc' }, { path: 'cde', outputPath: 'cde' }, { path: 'fgh', outputPath: 'fgh' }, ]; processor.blacklistedPaths = ['cde']; processor.$process(docs); expect(docs.pop().urls).toEqual(['abc', 'fgh']); }); it('mapping the home page\'s path to `/`', () => { const docs = [ { path: 'abc', outputPath: 'abc' }, { path: 'index', outputPath: 'index.json' }, { path: 'fgh', outputPath: 'fgh' }, ]; processor.$process(docs); expect(docs.pop().urls).toEqual(['abc', '', 'fgh']); }); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/fixInternalDocumentLinks.js ================================================ /** * @dgProcessor fixInternalDocumentLinks * @description * Add in the document path to links that start with a hash. * This is important when the web app has a base href in place, * since links like: `` would get mapped to * the URL `base/#some-id` even if the current location is `base/some/doc`. */ module.exports = function fixInternalDocumentLinks() { var INTERNAL_LINK = /(]*href=")(#[^"]*)/g; return { $runAfter: ['inlineTagProcessor'], $runBefore: ['convertToJsonProcessor'], $process: function(docs) { docs.forEach(doc => { doc.renderedContent = doc.renderedContent.replace(INTERNAL_LINK, (_, pre, hash) => { return pre + doc.path + hash; }); }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/fixInternalDocumentLinks.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./fixInternalDocumentLinks'); const Dgeni = require('dgeni'); describe('fixInternalDocumentLinks processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-base-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('fixInternalDocumentLinks'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { const processor = processorFactory(); expect(processor.$runBefore).toEqual(['convertToJsonProcessor']); }); it('should run after the correct processor', () => { const processor = processorFactory(); expect(processor.$runAfter).toEqual(['inlineTagProcessor']); }); it('should prefix internal hash links with the current doc path', () => { const processor = processorFactory(); const docs = [ { path: 'some/doc', renderedContent: ` Google Some Id Link to heading Link to heading Link to heading ` }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'some/doc', renderedContent: ` Google Some Id Link to heading Link to heading Link to heading ` }, ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/generateKeywords.js ================================================ /* eslint-disable */ /** * @dgProcessor generateKeywordsProcessor * @description * This processor extracts all the keywords from each document and creates * a new document that will be rendered as a JavaScript file containing all * this data. */ module.exports = function generateKeywordsProcessor(log) { return { ignoreWords: [], propertiesToIgnore: [], docTypesToIgnore: [], outputFolder: '', $validate: { ignoreWords: {}, docTypesToIgnore: {}, propertiesToIgnore: {}, outputFolder: { presence: true }, }, $runAfter: ['postProcessHtml'], $runBefore: ['writing-files'], async $process(docs) { const { stemmer: stem } = await import('stemmer'); const dictionary = new Map(); const emptySet = new Set(); // Keywords to ignore const ignoreWords = new Set(this.ignoreWords); log.debug('Words to ignore', ignoreWords); const propertiesToIgnore = new Set(this.propertiesToIgnore); log.debug('Properties to ignore', propertiesToIgnore); const docTypesToIgnore = new Set(this.docTypesToIgnore); log.debug('Doc types to ignore', docTypesToIgnore); const filteredDocs = docs // We are not interested in some docTypes .filter((doc) => !docTypesToIgnore.has(doc.docType)) // Ignore internals and private exports (indicated by the ɵ prefix) .filter((doc) => !doc.internal && !doc.privateExport) // Ignore duplicates and remove the `/api/operators/` path entries from the search results .filter((doc) => doc.path.indexOf('api/operators/') !== 0); for (const doc of filteredDocs) { // Search each top level property of the document for search terms let mainTokens = []; for (const key of Object.keys(doc)) { const value = doc[key]; if (isString(value) && !propertiesToIgnore.has(key)) { mainTokens.push(...tokenize(value, ignoreWords, dictionary)); } } const memberTokens = extractMemberTokens(doc, dictionary); // Extract all the keywords from the headings let headingTokens = []; if (doc.vFile && doc.vFile.headings) { for (const headingTag of Object.keys(doc.vFile.headings)) { for (const headingText of doc.vFile.headings[headingTag]) { headingTokens.push(...tokenize(headingText, ignoreWords, dictionary)); } } } // Extract the title to use in searches doc.searchTitle = doc.searchTitle || doc.title || (doc.vFile && doc.vFile.title) || doc.name || ''; // Attach all this search data to the document doc.searchTerms = {}; if (headingTokens.length > 0) { doc.searchTerms.headings = headingTokens; } if (mainTokens.length > 0) { doc.searchTerms.keywords = mainTokens; } if (memberTokens.length > 0) { doc.searchTerms.members = memberTokens; } if (doc.searchKeywords) { doc.searchTerms.topics = doc.searchKeywords.trim(); } } // Now process all the search data and collect it up to be used in creating a new document const searchData = { dictionary: Array.from(dictionary.keys()).join(' '), pages: filteredDocs.map((page) => { // Copy the properties from the searchTerms object onto the search data object const searchObj = { path: page.path, title: page.searchTitle, type: page.docType, }; if (page.deprecated) { searchObj.deprecated = true; } return Object.assign(searchObj, page.searchTerms); }), }; docs.push({ docType: 'json-doc', id: 'search-data-json', path: this.outputFolder + '/search-data.json', outputPath: this.outputFolder + '/search-data.json', data: searchData, renderedContent: JSON.stringify(searchData), }); return docs; // Helpers function tokenize(text, ignoreWords, dictionary) { // Split on whitespace and things that are likely to be HTML tags (this is not exhaustive but reduces the unwanted tokens that are indexed). const rawTokens = text.split( new RegExp( '[\\s/]+' + // whitespace '|' + // or '', // simple HTML tags (e.g. ,
, , etc.) 'ig' ) ); const tokens = []; for (let token of rawTokens) { token = token.trim(); // Trim unwanted trivia characters from the start and end of the token const TRIVIA_CHARS = '[\\s_"\'`({[<$*)}\\]>.,-]'; // Tokens can contain letters, numbers, underscore, dot or hyphen but not at the start or end. // The leading TRIVIA_CHARS will capture any leading `.`, '-`' or `_` so we don't have to avoid them in this regular expression. // But we do need to ensure we don't capture the at the end of the token. const POSSIBLE_TOKEN = '[a-z0-9_.-]*[a-z0-9]'; token = token.replace(new RegExp(`^${TRIVIA_CHARS}*(${POSSIBLE_TOKEN})${TRIVIA_CHARS}*$`, 'i'), '$1'); // Skip if blank or in the ignored words list if (token === '' || ignoreWords.has(token.toLowerCase())) { continue; } // Skip tokens that contain weird characters if (!/^\w[\w.-]*$/.test(token)) { continue; } storeToken(token, tokens, dictionary); if (token.startsWith('ng')) { // Strip off `ng`, `ng-`, `ng1`, `ng2`, etc storeToken(token.replace(/^ng[-12]*/, ''), tokens, dictionary); } } return tokens; } function storeToken(token, tokens, dictionary) { token = stem(token); if (!dictionary.has(token)) { dictionary.set(token, dictionary.size); } tokens.push(dictionary.get(token)); } function extractMemberTokens(doc, dictionary) { if (!doc) return []; let memberContent = []; if (doc.members) { doc.members.forEach((member) => memberContent.push(...tokenize(member.name, emptySet, dictionary))); } if (doc.statics) { doc.statics.forEach((member) => memberContent.push(...tokenize(member.name, emptySet, dictionary))); } if (doc.extendsClauses) { doc.extendsClauses.forEach((clause) => memberContent.push(...extractMemberTokens(clause.doc, dictionary))); } if (doc.implementsClauses) { doc.implementsClauses.forEach((clause) => memberContent.push(...extractMemberTokens(clause.doc, dictionary))); } return memberContent; } }, }; }; function isString(value) { return typeof value == 'string'; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/generateKeywords.spec.js ================================================ const path = require('canonical-path'); const Dgeni = require('dgeni'); const testPackage = require('../../helpers/test-package'); const mockLogger = require('dgeni/lib/mocks/log')(false); const processorFactory = require('./generateKeywords'); const mockReadFilesProcessor = { basePath: 'base/path', }; const ignoreWords = require(path.resolve(__dirname, '../ignore-words')); function createProcessor() { const processor = processorFactory(mockLogger, mockReadFilesProcessor); processor.ignoreWords = ignoreWords; return processor; } describe('generateKeywords processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-base-package'), testPackage('angular-api-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('generateKeywordsProcessor'); expect(processor.$process).toBeDefined(); }); it('should run after the correct processor', () => { const processor = createProcessor(); expect(processor.$runAfter).toEqual(['postProcessHtml']); }); it('should run before the correct processor', () => { const processor = createProcessor(); expect(processor.$runBefore).toEqual(['writing-files']); }); it('should ignore internal and private exports', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'PublicExport', path: '' }, { docType: 'class', name: 'PrivateExport', privateExport: true, path: '' }, { docType: 'class', name: 'InternalExport', internal: true, path: '' }, ]); expect(docs[docs.length - 1].data.pages).toEqual([jasmine.objectContaining({ title: 'PublicExport', type: 'class' })]); }); it('should ignore docs that are in the `docTypesToIgnore` list', async () => { const processor = createProcessor(); processor.docTypesToIgnore = ['interface']; const docs = await processor.$process([ { docType: 'class', name: 'Class', path: '' }, { docType: 'interface', name: 'Interface', path: '' }, { docType: 'content', name: 'Guide', path: '' }, ]); expect(docs[docs.length - 1].data.pages).toEqual([ jasmine.objectContaining({ title: 'Class', type: 'class' }), jasmine.objectContaining({ title: 'Guide', type: 'content' }), ]); }); it('should not collect keywords from properties that are in the `propertiesToIgnore` list', async () => { const processor = createProcessor(); processor.propertiesToIgnore = ['docType', 'ignore']; const docs = await processor.$process([ { docType: 'class', name: 'FooClass', ignore: 'ignore this content', path: '' }, { docType: 'interface', name: 'BarInterface', capture: 'capture this content', path: '' }, ]); expect(docs[docs.length - 1].data).toEqual({ dictionary: 'fooclass barinterfac captur content', pages: [ jasmine.objectContaining({ title: 'FooClass', type: 'class', keywords: [0] }), jasmine.objectContaining({ title: 'BarInterface', type: 'interface', keywords: [1, 2, 3] }), ], }); }); it('should not collect keywords that look like HTML tags', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'FooClass', content: `
Content inside a table
`, path: '', }, ]); expect(docs[docs.length - 1].data).toEqual({ dictionary: 'class fooclass content insid tabl', pages: [jasmine.objectContaining({ keywords: [0, 1, 2, 3, 4] })], }); }); it('should compute `doc.searchTitle` from the doc properties if not already provided', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'A', searchTitle: 'searchTitle A', title: 'title A', vFile: { headings: { h1: ['vFile A'] } }, path: '' }, { docType: 'class', name: 'B', title: 'title B', vFile: { headings: { h1: ['vFile B'] } }, path: '' }, { docType: 'class', name: 'C', vFile: { title: 'vFile C', headings: { h1: ['vFile C'] } }, path: '' }, { docType: 'class', name: 'D', path: '' }, ]); expect(docs[docs.length - 1].data.pages).toEqual([ jasmine.objectContaining({ title: 'searchTitle A' }), jasmine.objectContaining({ title: 'title B' }), jasmine.objectContaining({ title: 'vFile C' }), jasmine.objectContaining({ title: 'D' }), ]); }); it('should use `doc.searchTitle` as the title in the search index', async () => { const processor = createProcessor(); const docs = await processor.$process([{ docType: 'class', name: 'PublicExport', searchTitle: 'class PublicExport', path: '' }]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data.pages).toEqual([jasmine.objectContaining({ title: 'class PublicExport', type: 'class' })]); }); it('should add heading words to the search terms', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'PublicExport', searchTitle: 'class PublicExport', vFile: { headings: { h2: ['Important heading', 'Secondary heading'] } }, path: '', }, ]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class publicexport head secondari', pages: [jasmine.objectContaining({ headings: [2, 3, 2] })], }); }); it('should add member doc properties to the search terms', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'PublicExport', searchTitle: 'class PublicExport', vFile: { headings: { h2: ['heading A'] } }, content: 'Some content with ngClass in it.', members: [{ name: 'instanceMethodA' }, { name: 'instancePropertyA' }, { name: 'instanceMethodB' }, { name: 'instancePropertyB' }], statics: [{ name: 'staticMethodA' }, { name: 'staticPropertyA' }, { name: 'staticMethodB' }, { name: 'staticPropertyB' }], path: '', }, ]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class publicexport content ngclass instancemethoda instancepropertya instancemethodb instancepropertyb staticmethoda staticpropertya staticmethodb staticpropertyb head', pages: [ jasmine.objectContaining({ members: [4, 5, 6, 7, 8, 9, 10, 11], }), ], }); }); it('should add member doc properties contained in the ignored word list to the search terms', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'PublicExport', searchTitle: 'class PublicExport', vFile: { headings: { h2: ['heading A'] } }, content: 'Some content with ngClass in it.', members: [{ name: 'some' }, { name: 'none' }, { name: 'get' }, { name: 'put' }], statics: [{ name: 'zero' }, { name: 'one' }, { name: 'next' }, { name: 'index' }], path: '', }, ]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class publicexport content ngclass some none get put zero on next index head', pages: [ jasmine.objectContaining({ members: [4, 5, 6, 7, 8, 9, 10, 11], }), ], }); }); it('should add inherited member doc properties to the search terms', async () => { const processor = createProcessor(); const parentClass = { docType: 'class', name: 'ParentClass', members: [{ name: 'parentMember1' }], statics: [{ name: 'parentMember2' }], path: '', }; const parentInterface = { docType: 'interface', name: 'ParentInterface', members: [{ name: 'parentMember3' }], path: '', }; const childClass = { docType: 'class', name: 'Child', members: [{ name: 'childMember1' }], statics: [{ name: 'childMember2' }], extendsClauses: [{ doc: parentClass }], implementsClauses: [{ doc: parentInterface }], path: '', }; const docs = await processor.$process([childClass, parentClass, parentInterface]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class child childmember1 childmember2 parentmember1 parentmember2 parentmember3 parentclass interfac parentinterfac', pages: [ jasmine.objectContaining({ title: 'Child', members: [2, 3, 4, 5, 6], }), jasmine.objectContaining({ title: 'ParentClass', members: [4, 5], }), jasmine.objectContaining({ title: 'ParentInterface', members: [6], }), ], }); }); it('should add inherited member doc properties contained in the ignored word list to the search terms', async () => { const processor = createProcessor(); const parentClass = { docType: 'class', name: 'ParentClass', members: [{ name: 'one' }], statics: [{ name: 'zero' }], path: '', }; const parentInterface = { docType: 'interface', name: 'ParentInterface', members: [{ name: 'index' }], path: '', }; const childClass = { docType: 'class', name: 'Child', members: [{ name: 'next' }], statics: [{ name: 'get' }], extendsClauses: [{ doc: parentClass }], implementsClauses: [{ doc: parentInterface }], path: '', }; const docs = await processor.$process([childClass, parentClass, parentInterface]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class child next get on zero index parentclass interfac parentinterfac', pages: [ jasmine.objectContaining({ title: 'Child', members: [2, 3, 4, 5, 6], }), jasmine.objectContaining({ title: 'ParentClass', members: [4, 5], }), jasmine.objectContaining({ title: 'ParentInterface', members: [6], }), ], }); }); it('should include both stripped and unstripped "ng" prefixed tokens', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'PublicExport', searchTitle: 'ngController', vFile: { headings: { h2: ['ngModel'] } }, content: 'Some content with ngClass in it.', path: '', }, ]); const keywordsDoc = docs[docs.length - 1]; expect(keywordsDoc.data).toEqual({ dictionary: 'class publicexport ngcontrol control content ngclass ngmodel model', pages: [ jasmine.objectContaining({ headings: [6, 7], keywords: [0, 1, 2, 3, 4, 5, 0], }), ], }); }); it('should generate compressed encoded renderedContent property', async () => { const processor = createProcessor(); const docs = await processor.$process([ { docType: 'class', name: 'SomeClass', description: 'The is the documentation for the SomeClass API.', vFile: { headings: { h1: ['SomeClass'], h2: ['Some heading'] } }, path: '', }, { docType: 'class', name: 'SomeClass2', description: 'description', members: [{ name: 'member1' }], deprecated: true, path: '', }, ]); const keywordsDoc = docs[docs.length - 1]; expect(JSON.parse(keywordsDoc.renderedContent)).toEqual({ dictionary: 'class someclass document api head someclass2 descript member1', pages: [ { path: '', title: 'SomeClass', type: 'class', headings: [1, 4], keywords: [0, 1, 2, 1, 3], }, { path: '', title: 'SomeClass2', type: 'class', keywords: [0, 5, 6], members: [7], deprecated: true, }, ], }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/renderLinkInfo.js ================================================ /** * @dgProcessor renderLinkInfo * @description For each doc that has one of the specified docTypes, * add HTML comments that describe the links to and from the doc. */ module.exports = function renderLinkInfo(extractLinks) { return { docTypes: [], $runBefore: ['convertToJsonProcessor'], $runAfter: ['fixInternalDocumentLinks'], $process(docs) { const toLinks = {}; const fromLinks = {}; const docsToCheck = docs.filter(doc => this.docTypes.indexOf(doc.docType) !== -1); // Extract and store all links found in each doc in hashes docsToCheck.forEach(doc => { const linksFromDoc = extractLinks(doc.renderedContent).hrefs; // Update the hashes fromLinks[doc.path] = linksFromDoc; linksFromDoc.forEach(linkPath => { linkPath = linkPath.match(/^[^#?]+/)[0]; // remove the query and hash from the link (toLinks[linkPath] = toLinks[linkPath] || []).push(doc.path); }); }); // Add HTML comments to the end of the rendered content that list the links found above docsToCheck.forEach(doc => { const linksFromDoc = getLinks(fromLinks, doc.path); const linksToDoc = getLinks(toLinks, doc.path); doc.renderedContent += `\n\n` + ``; }); } }; }; function getLinks(hash, docPath) { const links = (hash[docPath] || []).filter(link => link !== docPath); const internal = {}; const external = {}; links.forEach(link => { if (/^[^:/#?]+:/.test(link)) { external[link] = true; } else { internal[link] = true; } }); return Object.keys(internal).sort() .concat(Object.keys(external).sort()); } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/processors/renderLinkInfo.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const processorFactory = require('./renderLinkInfo'); const extractLinks = require('dgeni-packages/base/services/extractLinks')(); const Dgeni = require('dgeni'); describe('renderLinkInfo processor', () => { it('should be available on the injector', () => { const dgeni = new Dgeni([testPackage('angular-base-package')]); const injector = dgeni.configureInjector(); const processor = injector.get('renderLinkInfo'); expect(processor.$process).toBeDefined(); }); it('should run before the correct processor', () => { const processor = processorFactory(extractLinks); expect(processor.$runBefore).toEqual(['convertToJsonProcessor']); }); it('should run after the correct processor', () => { const processor = processorFactory(extractLinks); expect(processor.$runAfter).toEqual(['fixInternalDocumentLinks']); }); it('should add HTML comments for links out of docs', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, { path: 'test-2', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-2', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should order links alphabetically', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should list repeated links only once', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should list internal links before external', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should ignore docs that do not have the specified docType', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, { path: 'test-2', docType: 'test2', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-2', docType: 'test2', renderedContent: '' }, ]); }); it('should add HTML comments for links into docs', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, { path: 'test-2', docType: 'test', renderedContent: '' }, { path: 'test-3', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-2', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-3', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should not include links to themselves', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, { path: 'test-2', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-2', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); it('should match links that contain fragments or queries', () => { const processor = processorFactory(extractLinks); processor.docTypes = ['test']; const docs = [ { path: 'test-1', docType: 'test', renderedContent: '' }, { path: 'test-2', docType: 'test', renderedContent: '' }, { path: 'test-3', docType: 'test', renderedContent: '' }, ]; processor.$process(docs); expect(docs).toEqual([ { path: 'test-1', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-2', docType: 'test', renderedContent: '\n' + '\n' + '' }, { path: 'test-3', docType: 'test', renderedContent: '\n' + '\n' + '' }, ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/readers/json.js ================================================ /** * Read in JSON files */ module.exports = function jsonFileReader() { return { name: 'jsonFileReader', getDocs: function(fileInfo) { // We return a single element array because content files only contain one document return [{ docType: fileInfo.baseName + '-json', data: JSON.parse(fileInfo.content), template: 'json-doc.template.json', id: fileInfo.baseName, aliases: [fileInfo.baseName, fileInfo.relativePath] }]; } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/filterByPropertyValue.js ================================================ module.exports = function filterBy() { return { name: 'filterByPropertyValue', process: function(list, property, value) { if (!list) return list; return list.filter(item => item[property] === value); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/filterByPropertyValue.spec.js ================================================ const factory = require('./filterByPropertyValue'); describe('filterByPropertyValue filter', () => { let filter; beforeEach(function() { filter = factory(); }); it('should be called "filterByPropertyValue"', function() { expect(filter.name).toEqual('filterByPropertyValue'); }); it('should filter out items that do not match the given property value', function() { expect(filter.process([{ a: 1 }, { a: 2 }, { b: 1 }, { a: 1, b: 2 }, { a: null }, { a: undefined }], 'a', 1)) .toEqual([{ a: 1 }, { a: 1, b: 2 }]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/toId.js ================================================ module.exports = function toId() { return { name: 'toId', process: function(str) { return str.replace(/[^(a-z)(A-Z)(0-9)._-]/g, '-'); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/toId.spec.js ================================================ var factory = require('./toId'); describe('toId filter', function() { var filter; beforeEach(function() { filter = factory(); }); it('should be called "toId"', function() { expect(filter.name).toEqual('toId'); }); it('should convert a string to make it appropriate for use as an HTML id', function() { expect(filter.process('This is a big string with €bad#characters¢\nAnd even NewLines')) .toEqual('This-is-a-big-string-with--bad-characters--And-even-NewLines'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/trimBlankLines.js ================================================ module.exports = function() { return { name: 'trimBlankLines', process: function(str) { var lines = str.split(/\r?\n/); while (lines.length && (lines[0].trim() === '')) { lines.shift(); } while (lines.length && (lines[lines.length - 1].trim() === '')) { lines.pop(); } return lines.join('\n'); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/trimBlankLines.spec.js ================================================ var factory = require('./trimBlankLines'); describe('trimBlankLines filter', function() { var filter; beforeEach(function() { filter = factory(); }); it('should be called "trimBlankLines"', function() { expect(filter.name).toEqual('trimBlankLines'); }); it('should remove empty lines from the start and end of the string', function() { expect(filter.process('\n \n\nsome text\n \nmore text\n \n')) .toEqual('some text\n \nmore text'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/truncateCode.js ================================================ module.exports = function() { return { name: 'truncateCode', process: function(str, lines) { if (lines === undefined) return str; const parts = str && str.split && str.split(/\r?\n/); if (parts && parts.length > lines) { return balance(parts[0] + '...', ['{', '(', '['], ['}', ')', ']']); } else { return str; } } }; }; /** * Try to balance the brackets by adding closers on to the end of a string * for every bracket that is left open. * The chars at each index in the openers and closers should match (i.e openers = ['{', '('], closers = ['}', ')']) * * @param {string} str The string to balance * @param {string[]} openers an array of chars that open a bracket * @param {string[]} closers an array of chars that close a brack * @returns the balanced string */ function balance(str, openers, closers) { const stack = []; // Add each open bracket to the stack, removing them when there is a matching closer str.split('').forEach(function(char) { const closerIndex = closers.indexOf(char); if (closerIndex !== -1 && stack[stack.length-1] === closerIndex) { stack.pop(); } else { const openerIndex = openers.indexOf(char); if (openerIndex !== -1) { stack.push(openerIndex); } } }); // Now the stack should contain all the unclosed brackets while(stack.length) { str += closers[stack.pop()]; } return str; } ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/rendering/truncateCode.spec.js ================================================ var factory = require('./truncateCode'); describe('truncateCode filter', function() { var filter; beforeEach(function() { filter = factory(); }); it('should be called "truncateCode"', function() { expect(filter.name).toEqual('truncateCode'); }); it('should return the whole string given lines is undefined', function() { expect(filter.process('some text\n \nmore text\n \n')) .toEqual('some text\n \nmore text\n \n'); }); it('should return the whole string if less than the given number of lines', function() { expect(filter.process('this is a pretty long string that only exists on one line', 1)) .toEqual('this is a pretty long string that only exists on one line'); expect(filter.process('this is a pretty long string\nthat exists on two lines', 2)) .toEqual('this is a pretty long string\nthat exists on two lines'); }); it('should return the specified number of lines and an ellipsis if there are more lines', function() { expect(filter.process('some text\n \nmore text\n \n', 1)).toEqual('some text...'); }); it('should add closing brackets for all the unclosed opening brackets after truncating', function() { expect(filter.process('()[]{}\nsecond line', 1)).toEqual('()[]{}...'); expect(filter.process('([]{}\nsecond line', 1)).toEqual('([]{}...)'); expect(filter.process('()[{}\nsecond line', 1)).toEqual('()[{}...]'); expect(filter.process('()[]{\nsecond line', 1)).toEqual('()[]{...}'); expect(filter.process('([{\nsecond line', 1)).toEqual('([{...}])'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/copyFolder.js ================================================ const {copySync} = require('fs-extra'); module.exports = function copyFolder() { return (from, to) => copySync(from, to); }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterAmbiguousDirectiveAliases.js ================================================ /** * This service is used by the autoLinkCode post-processor to filter out ambiguous directive * docs where the matching word is a directive selector. * E.g. `ngModel`, which is a selector for a number of directives, where we are only really * interested in the `NgModel` class. */ module.exports = function filterAmbiguousDirectiveAliases() { return (docs, words, index) => { const word = words[index]; // we are only interested if there are multiple matching docs if (docs.length > 1) { if (docs.every(doc => // We are only interested if they are all either directives or components (doc.docType === 'directive' || doc.docType === 'component') && // and the matching word is in the selector for all of them doc[doc.docType + 'Options'].selector.indexOf(word) != -1 )) { // find the directive whose class name matches the word (case-insensitive) return docs.filter(doc => doc.name.toLowerCase() === word.toLowerCase()); } } return docs; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterAmbiguousDirectiveAliases.spec.js ================================================ const filterAmbiguousDirectiveAliases = require('./filterAmbiguousDirectiveAliases')(); const words = ['Http', 'ngModel', 'NgModel', 'NgControlStatus']; describe('filterAmbiguousDirectiveAliases(docs, words, index)', () => { it('should not try to filter the docs, if the docs are not all directives or components', () => { const docs = [ { docType: 'class', name: 'Http' }, { docType: 'directive', name: 'NgModel', directiveOptions: { selector: '[ngModel]' } }, { docType: 'component', name: 'NgModel', componentOptions: { selector: '[ngModel]' } } ]; // take a copy to prove `docs` was not modified const filteredDocs = docs.slice(0); expect(filterAmbiguousDirectiveAliases(docs, words, 1)).toEqual(filteredDocs); expect(filterAmbiguousDirectiveAliases(docs, words, 2)).toEqual(filteredDocs); }); describe('(where all the docs are components or directives', () => { describe('and do not all contain the matching word in their selector)', () => { it('should not try to filter the docs', () => { const docs = [ { docType: 'directive', name: 'NgModel', ['directiveOptions']: { selector: '[ngModel]' } }, { docType: 'component', name: 'NgControlStatus', ['componentOptions']: { selector: '[ngControlStatus]' } } ]; // take a copy to prove `docs` was not modified const filteredDocs = docs.slice(0); expect(filterAmbiguousDirectiveAliases(docs, words, 1)).toEqual(filteredDocs); expect(filterAmbiguousDirectiveAliases(docs, words, 2)).toEqual(filteredDocs); // Also test that the check is case-sensitive docs[1].componentOptions.selector = '[ngModel]'; filteredDocs[1].componentOptions.selector = '[ngModel]'; expect(filterAmbiguousDirectiveAliases(docs, words, 2)).toEqual(filteredDocs); }); }); describe('and do all contain the matching word in there selector)', () => { it('should filter out docs whose class name is not (case-insensitively) equal to the matching word', () => { const docs = [ { docType: 'directive', name: 'NgModel', ['directiveOptions']: { selector: '[ngModel],[ngControlStatus]' } }, { docType: 'component', name: 'NgControlStatus', ['componentOptions']: { selector: '[ngModel],[ngControlStatus]' } } ]; const filteredDocs = [ { docType: 'directive', name: 'NgModel', ['directiveOptions']: { selector: '[ngModel],[ngControlStatus]' } } ]; expect(filterAmbiguousDirectiveAliases(docs, words, 1)).toEqual(filteredDocs); }); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterFromInImports.spec.js ================================================ const filterFromInImports = require('./filterFromInImports')(); const words = ['import', ' { ', 'from', ' } ', 'from', ' \'', 'rxjs', '\';']; const words2 = [' } ', 'from', '(', 'of']; describe('filterFromInImports(words, index)', () => { it('should not filter the word, if the word is not "from"', () => { expect(filterFromInImports(words, 0)).toEqual(false); }); it('should not filter the word, if the word "from" is not positioned between } and \' signs', () => { expect(filterFromInImports(words, 2)).toEqual(false); }); it('should filter "from" when "from" is positioned between } and \' signs', () => { expect(filterFromInImports(words, 4)).toEqual(true); }); it('should not filter "from" when "from" is after } but not before \'', () => { expect(filterFromInImports(words2, 1)).toEqual(false); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterFromInImports.ts ================================================ /** * This filter is filtering word 'from' in ES6 import statements. * For example, next line: * * ``` * import { interval, from } from 'rxjs'; * ``` * * will filter the second occurrence of the word 'from' leaving * it without the link, but the first occurrence will remain * unfiltered, thus it will get the link to * /api/index/function/from */ module.exports = function filterFromInImports(): (words: string[], index: number) => boolean { return (words: string[], index: number) => { const previousWord = words[index - 1]; const nextWord = words[index + 1]; return words[index] === 'from' && /}/.test(previousWord) && /'/.test(nextWord); }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterPipes.js ================================================ /** * This service is used by the autoLinkCode post-processors to filter out pipe docs * where the matching word is the pipe name and is not preceded by a pipe */ module.exports = function filterPipes() { return (docs, words, index) => docs.filter(doc => doc.docType !== 'pipe' || doc.pipeOptions.name !== '\'' + words[index] + '\'' || index > 0 && words[index - 1].trim() === '|'); }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/filterPipes.spec.js ================================================ const filterPipes = require('./filterPipes')(); describe('filterPipes', () => { it('should ignore docs that are not pipes', () => { const docs = [{ docType: 'class', name: 'B', pipeOptions: { name: '\'b\'' } }]; const words = ['A', 'b', 'B', 'C']; const filteredDocs = [{ docType: 'class', name: 'B', pipeOptions: { name: '\'b\'' } }]; expect(filterPipes(docs, words, 1)).toEqual(filteredDocs); expect(filterPipes(docs, words, 2)).toEqual(filteredDocs); }); it('should ignore docs that are pipes but do not match the pipe name', () => { const docs = [{ docType: 'pipe', name: 'B', pipeOptions: { name: '\'b\'' } }]; const words = ['A', 'B', 'C']; const filteredDocs = [{ docType: 'pipe', name: 'B', pipeOptions: { name: '\'b\'' } }]; expect(filterPipes(docs, words, 1)).toEqual(filteredDocs); }); it('should ignore docs that are pipes, match the pipe name and are preceded by a pipe character', () => { const docs = [{ docType: 'pipe', name: 'B', pipeOptions: { name: '\'b\'' } }]; const words = ['A', '|', 'b', 'C']; const filteredDocs = [{ docType: 'pipe', name: 'B', pipeOptions: { name: '\'b\'' } }]; expect(filterPipes(docs, words, 2)).toEqual(filteredDocs); }); it('should filter out docs that are pipes, match the pipe name but are not preceded by a pipe character', () => { const docs = [ { docType: 'pipe', name: 'B', pipeOptions: { name: '\'b\'' } }, { docType: 'class', name: 'B' } ]; const words = ['A', 'b', 'C']; const index = 1; const filteredDocs = [{ docType: 'class', name: 'B' }]; expect(filterPipes(docs, words, index)).toEqual(filteredDocs); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-base-package/services/getImageDimensions.js ================================================ const { resolve } = require('canonical-path'); const sizeOf = require('image-size'); module.exports = function getImageDimensions() { return (basePath, path) => sizeOf(resolve(basePath, path)); }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-content-package/index.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const basePackage = require('../angular-base-package'); const contentPackage = require('../content-package'); const { CONTENTS_PATH } = require('../config'); module.exports = new Package('angular-content', [basePackage, contentPackage]) // Where do we get the source files? .config(function(readFilesProcessor) { readFilesProcessor.sourceFiles = readFilesProcessor.sourceFiles.concat([ { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/{deprecations,guide,tutorial}/**/*.md', fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH + '/marketing', include: CONTENTS_PATH + '/marketing/**/*.{html,md}', fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/*.md', exclude: [CONTENTS_PATH + '/index.md'], fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/navigation.json', fileReader: 'jsonFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/marketing/contributors.json', fileReader: 'jsonFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/marketing/announcements.json', fileReader: 'jsonFileReader' }, ]); }) // Configure jsdoc-style tag parsing .config(function(inlineTagProcessor) { inlineTagProcessor.inlineTagDefinitions.push(require('./inline-tag-defs/anchor')); }) .config(function(computePathsProcessor) { // Replace any path templates inherited from other packages // (we want full and transparent control) computePathsProcessor.pathTemplates = computePathsProcessor.pathTemplates.concat([ { docTypes: ['content'], getPath: (doc) => `${doc.id.replace(/\/index$/, '')}`, outputPathTemplate: '${path}.json' }, {docTypes: ['navigation-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'}, {docTypes: ['contributors-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'}, {docTypes: ['announcements-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'} ]); }) // We want the content files to be converted .config(function(convertToJsonProcessor, postProcessHtml) { convertToJsonProcessor.docTypes.push('content'); postProcessHtml.docTypes.push('content'); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular-content-package/inline-tag-defs/anchor.js ================================================ module.exports = { name: 'a', description: 'A shorthand for creating heading anchors. Usage: `{@a some-id}`', handler: function(doc, tagName, tagDescription) { return ''; } }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular.io-package/index.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const gitPackage = require('dgeni-packages/git'); const apiPackage = require('../angular-api-package'); const contentPackage = require('../angular-content-package'); const { extname, resolve } = require('canonical-path'); const { existsSync } = require('fs'); const { SRC_PATH, DOCS_OUTPUT_PATH, DECISION_TREE_PATH } = require('../config'); // prettier-ignore module.exports = new Package('angular.io', [gitPackage, apiPackage, contentPackage]) // This processor relies upon the versionInfo. See below... .processor(require('./processors/processNavigationMap')) .processor(require('./processors/createOverviewDump')) .processor(require('./processors/cleanGeneratedFiles')) .processor(require('../rxjs-decision-tree-generator')) // We don't include this in the angular-base package because the `versionInfo` stuff // accesses the file system and git, which is slow. .config(function(renderDocsProcessor, versionInfo) { // Add the version data to the renderer, for use in things like github links renderDocsProcessor.extraData.versionInfo = versionInfo; }) .config(function(checkAnchorLinksProcessor, linkInlineTagDef) { // Fail the processing if there is an invalid link linkInlineTagDef.failOnBadLink = false; checkAnchorLinksProcessor.$enabled = false; // since we encode the HTML to JSON we need to ensure that this processor runs before that encoding happens. checkAnchorLinksProcessor.$runBefore = ['convertToJsonProcessor']; checkAnchorLinksProcessor.$runAfter = ['fixInternalDocumentLinks']; // We only want to check docs that are going to be output as JSON docs. checkAnchorLinksProcessor.checkDoc = (doc) => doc.path && doc.outputPath && extname(doc.outputPath) === '.json'; // Since we have a `base[href="/"]` arrangement all links are relative to that and not relative to the source document's path checkAnchorLinksProcessor.base = '/'; // Ignore links to local assets // (This is not optimal in terms of performance without making changes to dgeni-packages there is no other way. // That being said do this only add 500ms onto the ~30sec doc-gen run - so not a huge issue) checkAnchorLinksProcessor.ignoredLinks.push({ test(url) { return (existsSync(resolve(SRC_PATH, url))); } }); checkAnchorLinksProcessor.pathVariants = ['', '/', '.html', '/index.html', '#top-of-page']; checkAnchorLinksProcessor.errorOnUnmatchedLinks = false; }) .config(function(renderLinkInfo, postProcessHtml) { renderLinkInfo.docTypes = postProcessHtml.docTypes; }) .config(function(decisionTreeGenerator) { decisionTreeGenerator.outputFolder = DOCS_OUTPUT_PATH + '/app'; decisionTreeGenerator.decisionTreeFile = DECISION_TREE_PATH; }); ================================================ FILE: apps/rxjs.dev/tools/transforms/angular.io-package/processors/cleanGeneratedFiles.js ================================================ const rimraf = require('rimraf'); module.exports = function cleanGeneratedFiles() { return { $runAfter: ['writing-files'], $runBefore: ['writeFilesProcessor'], $process: function() { rimraf.sync('src/generated/{*.json}'); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular.io-package/processors/createOverviewDump.js ================================================ var _ = require('lodash'); module.exports = function createOverviewDump() { return { $runAfter: ['processing-docs'], $runBefore: ['docs-processed'], $process: function(docs) { var overviewDoc = { id: 'overview-dump', aliases: ['overview-dump'], path: 'overview-dump', outputPath: 'overview-dump.html', modules: [] }; _.forEach(docs, function(doc) { if (doc.docType === 'module') { overviewDoc.modules.push(doc); } }); docs.push(overviewDoc); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/angular.io-package/processors/processNavigationMap.js ================================================ module.exports = function processNavigationMap(versionInfo, log) { return { $runAfter: ['paths-computed'], $runBefore: ['rendering-docs'], $process: function(docs) { const navigationDoc = docs.find(doc => doc.docType === 'navigation-json'); if (!navigationDoc) { throw new Error( 'Missing navigation map document (docType="navigation-json").' + 'Did you forget to add it to the readFileProcessor?'); } // Verify that all the navigation paths are to valid docs const pathMap = {}; docs.forEach(doc => pathMap[doc.path] = true); const errors = walk(navigationDoc.data, pathMap, []); if (errors.length) { log.error(`Navigation doc: ${navigationDoc.fileInfo.relativePath} contains invalid urls`); // eslint-disable-next-line no-console console.log(errors); // TODO(petebd): fail if there are errors: throw new Error('processNavigationMap failed'); } // Add in the version data in a "secret" field to be extracted in the docs app navigationDoc.data['__versionInfo'] = versionInfo.currentVersion; } }; }; function walk(node, map, path) { let errors = []; for(const key in node) { const child = node[key]; if (child !== null) { // null is allowed if (key === 'url') { const url = child.replace(/#.*$/, ''); // strip hash if (isRelative(url) && !map[url]) { errors.push({ path: path.join('.'), url }); } } else if (typeof child !== 'string') { errors = errors.concat(walk(child, map, path.concat([key]))); } } } return errors; } function isRelative(url) { return !/^(https?:)?\/\//.test(url); } ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/api-package.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const apiPackage = require('../angular-api-package'); const { API_SOURCE_PATH } = require('../config'); const packageMap = { animations: ['animations/index.ts', 'animations/browser/index.ts', 'animations/browser/testing/index.ts'], common: ['common/index.ts', 'common/testing/index.ts', 'common/http/index.ts', 'common/http/testing/index.ts'], core: ['core/index.ts', 'core/testing/index.ts'], elements: ['elements/index.ts'], forms: ['forms/index.ts'], http: ['http/index.ts', 'http/testing/index.ts'], 'platform-browser': ['platform-browser/index.ts', 'platform-browser/animations/index.ts', 'platform-browser/testing/index.ts'], 'platform-browser-dynamic': ['platform-browser-dynamic/index.ts', 'platform-browser-dynamic/testing/index.ts'], 'platform-server': ['platform-server/index.ts', 'platform-server/testing/index.ts'], 'platform-webworker': ['platform-webworker/index.ts'], 'platform-webworker-dynamic': ['platform-webworker-dynamic/index.ts'], router: ['router/index.ts', 'router/testing/index.ts', 'router/upgrade/index.ts'], 'service-worker': ['service-worker/index.ts'], upgrade: ['upgrade/index.ts', 'upgrade/static/index.ts'] }; function createPackage(packageName) { return new Package('author-api', [apiPackage]) .config(function(readTypeScriptModules) { readTypeScriptModules.sourceFiles = packageMap[packageName]; }) .config(function(readFilesProcessor) { readFilesProcessor.sourceFiles = [ { basePath: API_SOURCE_PATH, include: `${API_SOURCE_PATH}/examples/${packageName}/**/*`, fileReader: 'exampleFileReader' } ]; }); } module.exports = { createPackage }; ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/guide-package.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /* eslint no-console: "off" */ const Package = require('dgeni').Package; const contentPackage = require('../angular-content-package'); const { readFileSync } = require('fs'); const { resolve } = require('canonical-path'); const { CONTENTS_PATH } = require('../config'); function createPackage(guideName) { const guideFilePath = `${CONTENTS_PATH}/guide/${guideName}.md`; const guideFile = readFileSync(guideFilePath, 'utf8'); const examples = []; guideFile.replace(/]*path="([^"]+)"/g, (_, path) => examples.push('examples/' + path)); if (examples.length) { console.log('The following example files are referenced in this guide:'); console.log(examples.map(example => ' - ' + example).join('\n')); } return new Package('author-guide', [contentPackage]) .config(function(readFilesProcessor) { readFilesProcessor.sourceFiles = [ { basePath: CONTENTS_PATH, include: guideFilePath, fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: examples.map(example => resolve(CONTENTS_PATH, example)), fileReader: 'exampleFileReader' } ]; }); } module.exports = { createPackage }; ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/index.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /* eslint no-console: "off" */ function createPackage(changedFile) { const marketingMatch = /^apps\/rxjs.dev\/content\/marketing\/(.*)/.exec(changedFile); if (marketingMatch) { console.log('Building marketing docs'); return require('./marketing-package').createPackage(); } const tutorialMatch = /^apps\/rxjs.dev\/content\/tutorial\/([^.]+)\.md/.exec(changedFile); const tutorialExampleMatch = /^apps\/rxjs.dev\/content\/examples\/(toh-[^/]+)\//.exec(changedFile); if (tutorialMatch || tutorialExampleMatch) { const tutorialName = (tutorialMatch && tutorialMatch[1]) || tutorialExampleMatch[1]; console.log('Building tutorial docs'); return require('./tutorial-package').createPackage(tutorialName); } const guideMatch = /^apps\/rxjs.dev\/content\/guide\/([^.]+)\.md/.exec(changedFile); const exampleMatch = /^apps\/rxjs.dev\/content\/examples\/(?:cb-)?([^/]+)\//.exec(changedFile); if (guideMatch || exampleMatch) { const guideName = (guideMatch && guideMatch[1]) || exampleMatch[1]; console.log(`Building guide doc: ${guideName}.md`); return require('./guide-package').createPackage(guideName); } } module.exports = { generateDocs: function (changedFile, options = {}) { const { Dgeni } = require('dgeni'); const package = createPackage(changedFile); if (options.silent) { package.config(function (log) { log.level = 'error'; }); } var dgeni = new Dgeni([package]); const start = Date.now(); return dgeni.generate().then( () => console.log('Generated docs in ' + (Date.now() - start) / 1000 + ' secs'), (err) => console.log('Error generating docs', err) ); }, }; ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/index.spec.js ================================================ /* eslint jasmine/prefer-toHaveBeenCalledWith:0 */ const fs = require('fs/promises'); const { resolve } = require('canonical-path'); const { generateDocs } = require('./index.js'); const { DOCS_OUTPUT_PATH } = require('../config'); describe('authors-package (integration tests)', () => { let originalJasmineTimeout; let files; beforeAll(() => { originalJasmineTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; }); afterAll(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = originalJasmineTimeout)); beforeEach(() => { files = []; spyOn(fs, 'writeFile').and.callFake((file) => { files.push(file); return Promise.resolve(); }); }); it('should generate marketing docs if the "fileChanged" is a marketing doc', (done) => { generateDocs('apps/rxjs.dev/content/marketing/team.html', { silent: true }) .then(() => { expect(fs.writeFile).toHaveBeenCalled(); expect(files).toContain(resolve(DOCS_OUTPUT_PATH, 'team.json')); expect(files).toContain(resolve(DOCS_OUTPUT_PATH, '../navigation.json')); expect(files).toContain(resolve(DOCS_OUTPUT_PATH, '../contributors.json')); done(); }) .catch(done.fail); }); it('should generate guide doc if the "fileChanged" is a guide doc', (done) => { generateDocs('apps/rxjs.dev/content/guide/subject.md', { silent: true }) .then(() => { expect(fs.writeFile).toHaveBeenCalled(); expect(files).toContain(resolve(DOCS_OUTPUT_PATH, 'guide/subject.json')); done(); }) .catch(done.fail); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/marketing-package.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const contentPackage = require('../angular-content-package'); const { CONTENTS_PATH } = require('../config'); function createPackage() { return new Package('author-marketing', [contentPackage]) .config(function(readFilesProcessor) { readFilesProcessor.sourceFiles = [ { basePath: CONTENTS_PATH + '/marketing', include: CONTENTS_PATH + '/marketing/**/*.{html,md}', fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/*.md', exclude: [CONTENTS_PATH + '/index.md'], fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/marketing/*.json', fileReader: 'jsonFileReader' }, { basePath: CONTENTS_PATH, include: CONTENTS_PATH + '/navigation.json', fileReader: 'jsonFileReader' }, ]; }); } module.exports = { createPackage }; ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/tutorial-package.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const Package = require('dgeni').Package; const contentPackage = require('../angular-content-package'); const { readFileSync } = require('fs'); const { resolve } = require('canonical-path'); const { CONTENTS_PATH } = require('../config'); /* eslint no-console: "off" */ function createPackage(tutorialName) { const tutorialFilePath = `${CONTENTS_PATH}/tutorial/${tutorialName}.md`; const tutorialFile = readFileSync(tutorialFilePath, 'utf8'); const examples = []; tutorialFile.replace(/]*path="([^"]+)"/g, (_, path) => examples.push('examples/' + path)); if (examples.length) { console.log('The following example files are referenced in this tutorial:'); console.log(examples.map(example => ' - ' + example).join('\n')); } return new Package('author-tutorial', [contentPackage]) .config(function(readFilesProcessor) { readFilesProcessor.sourceFiles = [ { basePath: CONTENTS_PATH, include: tutorialFilePath, fileReader: 'contentFileReader' }, { basePath: CONTENTS_PATH, include: examples.map(example => resolve(CONTENTS_PATH, example)), fileReader: 'exampleFileReader' } ]; }); } module.exports = { createPackage }; ================================================ FILE: apps/rxjs.dev/tools/transforms/authors-package/watchr.js ================================================ /* eslint no-console: "off" */ const watchr = require('watchr'); const {relative} = require('canonical-path'); const {generateDocs} = require('./index.js'); const { PROJECT_ROOT, CONTENTS_PATH, API_SOURCE_PATH } = require('../config'); function listener(changeType, fullPath) { try { const relativePath = relative(PROJECT_ROOT, fullPath); console.log('The file', relativePath, `was ${changeType}d at`, new Date().toUTCString()); generateDocs(relativePath); } catch(err) { console.log('Error generating docs', err); } } function next(error) { if (error) { console.log(error); } } let p = Promise.resolve(); if (process.argv.indexOf('--watch-only') === -1) { console.log('================================================================'); console.log('Running initial doc generation'); console.log('----------------------------------------------------------------'); console.log('Skip the full doc-gen by running: `yarn docs-watch --watch-only`'); console.log('================================================================'); const {Dgeni} = require('dgeni'); const dgeni = new Dgeni([require('../angular.io-package')]); // Turn off all the potential failures for this doc-gen one-off run. // This enables authors to run `docs-watch` while the docs are still in an unstable state. const injector = dgeni.configureInjector(); injector.get('linkInlineTagDef').failOnBadLink = false; injector.get('checkAnchorLinksProcessor').$enabled = false; injector.get('renderExamples').ignoreBrokenExamples = true; p = dgeni.generate(); } p.then(() => { console.log('==================================================================='); console.log('Started watching files in:'); console.log(' - ', CONTENTS_PATH); console.log(' - ', API_SOURCE_PATH); console.log('Doc gen will run when you change a file in either of these folders.'); console.log('==================================================================='); watchr.open(CONTENTS_PATH, listener, next); watchr.open(API_SOURCE_PATH, listener, next); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/config.js ================================================ const { resolve } = require('path'); const { readdirSync } = require('fs'); const PROJECT_ROOT = resolve(__dirname, '../../../..'); const AIO_PATH = resolve(PROJECT_ROOT, 'apps/rxjs.dev'); const TEMPLATES_PATH = resolve(AIO_PATH, 'tools/transforms/templates'); const API_TEMPLATES_PATH = resolve(TEMPLATES_PATH, 'api'); const CONTENTS_PATH = resolve(AIO_PATH, 'content'); const SRC_PATH = resolve(AIO_PATH, 'src'); const OUTPUT_PATH = resolve(SRC_PATH, 'generated'); const DOCS_OUTPUT_PATH = resolve(OUTPUT_PATH, 'docs'); const API_SOURCE_PATH = resolve(PROJECT_ROOT, 'packages/rxjs/src'); const MARBLE_IMAGES_PATH = resolve(SRC_PATH, 'assets/images/marble-diagrams'); const MARBLE_IMAGES_WEB_PATH = 'assets/images/marble-diagrams'; const DECISION_TREE_PATH = resolve(CONTENTS_PATH, 'operator-decision-tree.yml'); function requireFolder(dirname, folderPath) { const absolutePath = resolve(dirname, folderPath); return readdirSync(absolutePath) .filter((p) => !/[._]spec\.js$/.test(p)) // ignore spec files .map((p) => require(resolve(absolutePath, p))); } module.exports = { PROJECT_ROOT, AIO_PATH, TEMPLATES_PATH, API_TEMPLATES_PATH, CONTENTS_PATH, SRC_PATH, OUTPUT_PATH, DOCS_OUTPUT_PATH, API_SOURCE_PATH, MARBLE_IMAGES_PATH, MARBLE_IMAGES_WEB_PATH, DECISION_TREE_PATH, requireFolder, }; ================================================ FILE: apps/rxjs.dev/tools/transforms/content-package/index.js ================================================ var Package = require('dgeni').Package; var jsdocPackage = require('dgeni-packages/jsdoc'); var linksPackage = require('../links-package'); var { requireFolder } = require('../config'); // Define the dgeni package for generating the docs module.exports = new Package('content', [jsdocPackage, linksPackage]) // Register the services and file readers .factory(require('./readers/content')) // Configure file reading .config(function(readFilesProcessor, contentFileReader) { readFilesProcessor.fileReaders.push(contentFileReader); }) .config(function(parseTagsProcessor, getInjectables) { parseTagsProcessor.tagDefinitions = parseTagsProcessor.tagDefinitions.concat( getInjectables(requireFolder(__dirname, './tag-defs'))); }) // Configure ids and paths .config(function(computeIdsProcessor) { computeIdsProcessor.idTemplates.push({ docTypes: ['content'], getId: function(doc) { return doc.fileInfo .relativePath // path should be relative to `modules` folder .replace(/.*\/?modules\//, '') // path should not include `/docs/` .replace(/\/docs\//, '/') // path should not have a suffix .replace(/\.\w*$/, ''); }, getAliases: function(doc) { return [doc.id]; } }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/content-package/readers/content.js ================================================ /** * @dgService * @description * This file reader will pull the contents from a text file (by default .md) * * The doc will initially have the form: * ``` * { * content: 'the content of the file', * startingLine: 1 * } * ``` */ module.exports = function contentFileReader() { return { name: 'contentFileReader', defaultPattern: /\.md$/, getDocs: function(fileInfo) { // We return a single element array because content files only contain one document return [{docType: 'content', content: fileInfo.content, startingLine: 1}]; } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/content-package/readers/content.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); var path = require('canonical-path'); describe('contentFileReader', function() { var dgeni, injector, fileReader; beforeEach(function() { dgeni = new Dgeni([testPackage('content-package', true)]); injector = dgeni.configureInjector(); fileReader = injector.get('contentFileReader'); }); var createFileInfo = function(file, content, basePath) { return { fileReader: fileReader.name, filePath: file, baseName: path.basename(file, path.extname(file)), extension: path.extname(file).replace(/^\./, ''), basePath: basePath, relativePath: path.relative(basePath, file), content: content }; }; describe('defaultPattern', function() { it('should match .md files', function() { expect(fileReader.defaultPattern.test('abc.md')).toBeTruthy(); expect(fileReader.defaultPattern.test('abc.js')).toBeFalsy(); }); }); describe('getDocs', function() { it('should return an object containing info about the file and its contents', function() { var fileInfo = createFileInfo( 'project/path/modules/someModule/foo/docs/subfolder/bar.ngdoc', 'A load of content', 'project/path'); expect(fileReader.getDocs(fileInfo)).toEqual([ {docType: 'content', content: 'A load of content', startingLine: 1} ]); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/content-package/tag-defs/intro.js ================================================ module.exports = function() { return {name: 'intro'}; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/content-package/tag-defs/title.js ================================================ module.exports = function() { return {name: 'title'}; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/helpers/test-package.js ================================================ /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ const path = require('canonical-path'); const Package = require('dgeni').Package; module.exports = function testPackage(packageName, mockTemplateEngine) { const pkg = new Package('mock_' + packageName, [require('../' + packageName)]); // provide a mock log service pkg.factory('log', function() { return require('dgeni/lib/mocks/log')(false); }); // overrides base packageInfo and returns the one for the 'angular/angular' repo. const PROJECT_ROOT = path.resolve(__dirname, '../../../..'); pkg.factory('packageInfo', function() { return require(path.resolve(PROJECT_ROOT, 'package.json')); }); if (mockTemplateEngine) { pkg.factory('templateEngine', function() { return {}; }); } return pkg; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/helpers/utils.js ================================================ module.exports = { /** * Transform the values of an object via a mapper function * @param {Object} obj * @param {Function} mapper */ mapObject(obj, mapper) { const mappedObj = {}; Object.keys(obj).forEach(key => { mappedObj[key] = mapper(key, obj[key]); }); return mappedObj; }, /** * Parses the attributes from a string taken from an HTML element start tag * E.g. ` a="one" b="two" ` * @param {string} str */ parseAttributes(str) { const attrMap = {}; let index = 0; skipSpace(); while(index < str.length) { takeAttribute(); skipSpace(); } function takeAttribute() { const key = takeKey(); skipSpace(); if (tryEquals()) { skipSpace(); const quote = tryQuote(); attrMap[key] = takeValue(quote); // skip the closing quote or whitespace index++; } else { attrMap[key] = true; } } function skipSpace() { while(index < str.length && /\s/.test(str[index])) { index++; } } function tryEquals() { if (str[index] === '=') { index++; return true; } } function takeKey() { let startIndex = index; while(index < str.length && /[^\s=]/.test(str[index])) { index++; } return str.substring(startIndex, index); } function tryQuote() { const quote = str[index]; if (['"', '\''].indexOf(quote) !== -1) { index++; return quote; } } function takeValue(quote) { let startIndex = index; if (quote) { while(index < str.length && str[index] !== quote) { index++; } if (index >= str.length) { throw new Error(`Unterminated quoted attribute value in \`${str}\`. Starting at ${startIndex}. Expected a ${quote} but got "end of string".`); } } else { while(index < str.length && /\S/.test(str[index])) { index++; } } return str.substring(startIndex, index); } return attrMap; }, renderAttributes(attrMap) { return Object.keys(attrMap).map(key => attrMap[key] === false ? '' : attrMap[key] === true ? ` ${key}` : ` ${key}="${attrMap[key].replace(/"/g, '"')}"`).join(''); } }; ================================================ FILE: apps/rxjs.dev/tools/transforms/helpers/utils.spec.js ================================================ const { mapObject, parseAttributes, renderAttributes } = require('./utils'); describe('utils', () => { describe('mapObject', () => { it('creates a new object', () => { const testObj = { a: 1 }; const mappedObj = mapObject(testObj, (key, value) => value); expect(mappedObj).toEqual(testObj); expect(mappedObj).not.toBe(testObj); }); it('maps the values via the mapper function', () => { const testObj = { a: 1, b: 2 }; const mappedObj = mapObject(testObj, (key, value) => value * 2); expect(mappedObj).toEqual({ a: 2, b: 4 }); }); }); describe('parseAttributes', () => { it('should parse empty string', () => { const attrs = parseAttributes(''); expect(attrs).toEqual({ }); }); it('should parse blank string', () => { const attrs = parseAttributes(' '); expect(attrs).toEqual({ }); }); it('should parse double quoted attributes', () => { const attrs = parseAttributes('a="one" b="two"'); expect(attrs).toEqual({ a: 'one', b: 'two' }); }); it('should parse empty quoted attributes', () => { const attrs = parseAttributes('a="" b="two"'); expect(attrs).toEqual({ a: '', b: 'two' }); }); it('should parse single quoted attributes', () => { const attrs = parseAttributes('a=\'one\' b=\'two\''); expect(attrs).toEqual({ a: 'one', b: 'two' }); }); it('should ignore whitespace', () => { const attrs = parseAttributes(' a = "one" b = "two" '); expect(attrs).toEqual({ a: 'one', b: 'two' }); }); it('should parse attributes with quotes within quotes', () => { const attrs = parseAttributes('a=\'o"n"e\' b="t\'w\'o"'); expect(attrs).toEqual({ a: 'o"n"e', b: 't\'w\'o' }); }); it('should parse attributes with spaces in their values', () => { const attrs = parseAttributes('a="one and two" b="three and four"'); expect(attrs).toEqual({ a: 'one and two', b: 'three and four' }); }); it('should parse empty attributes', () => { const attrs = parseAttributes('a b="two"'); expect(attrs).toEqual({ a: true, b: 'two' }); }); it('should parse unquoted attributes', () => { const attrs = parseAttributes('a=one b=two'); expect(attrs).toEqual({ a: 'one', b: 'two' }); }); it('should complain if a quoted attribute is not closed', () => { expect(() => parseAttributes('a="" b="two')).toThrowError( 'Unterminated quoted attribute value in `a="" b="two`. Starting at 8. Expected a " but got "end of string".' ); }); }); describe('renderAttributes', () => { it('should convert key-value map to a strong that can be used in HTML', () => { expect(renderAttributes({ foo: 'bar', moo: 'car' })).toEqual(' foo="bar" moo="car"'); }); it('should handle boolean values', () => { expect(renderAttributes({ foo: 'bar', loo: true, moo: false })) .toEqual(' foo="bar" loo'); }); it('should escape double quotes inside the value', () => { expect(renderAttributes({ foo: 'bar "car"' })).toEqual(' foo="bar "car""'); }); it('should not escape single quotes inside the value', () => { expect(renderAttributes({ foo: 'bar \'car\'' })).toEqual(' foo="bar \'car\'"'); }); it('should handle an empty object', () => { expect(renderAttributes({ })).toEqual(''); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/index.js ================================================ var Package = require('dgeni').Package; var jsdocPackage = require('dgeni-packages/jsdoc'); module.exports = new Package('links', [jsdocPackage]) .factory(require('./inline-tag-defs/link')) .factory(require('./services/getAliases')) .factory(require('./services/getDocFromAlias')) .factory(require('./services/getLinkInfo')) .factory(require('./services/disambiguators/disambiguateByDeprecated')) .factory(require('./services/disambiguators/disambiguateByNonMember')) .factory(require('./services/disambiguators/disambiguateByModule')) .factory(require('./services/disambiguators/disambiguateByNonOperator')) .config(function (inlineTagProcessor, linkInlineTagDef) { inlineTagProcessor.inlineTagDefinitions.push(linkInlineTagDef); }) .config(function (getDocFromAlias, disambiguateByDeprecated, disambiguateByNonMember, disambiguateByModule, disambiguateByNonOperator) { getDocFromAlias.disambiguators = [disambiguateByDeprecated, disambiguateByNonMember, disambiguateByModule, disambiguateByNonOperator]; }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/inline-tag-defs/link.js ================================================ var INLINE_LINK = /(\S+)(?:\s+([\s\S]+))?/; /** * @dgService linkInlineTagDef * @description * Process inline link tags (of the form {@link some/uri Some Title}), replacing them with HTML anchors * @kind function * @param {Object} url The url to match * @param {Function} docs error message * @return {String} The html link information * * @property {boolean} failOnBadLink Whether to throw an error (aborting the processing) if a link is invalid. */ module.exports = function linkInlineTagDef(getLinkInfo, createDocMessage, log) { return { name: 'link', aliases: ['linkDocs'], failOnBadLink: false, description: 'Process inline link tags (of the form {@link some/uri Some Title}), replacing them with HTML anchors', handler(doc, tagName, tagDescription) { // Parse out the uri and title return tagDescription.replace(INLINE_LINK, (match, uri, title) => { var linkInfo = getLinkInfo(uri, title, doc); if (!linkInfo.valid) { const message = createDocMessage(`Error in {@${tagName} ${tagDescription}} - ${linkInfo.error}`, doc); if (this.failOnBadLink) { throw new Error(message); } else { log.warn(message); } } return '' + linkInfo.title + ''; }); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/inline-tag-defs/link.spec.js ================================================ var testPackageFactory = require('../../helpers/test-package'); var Dgeni = require('dgeni'); describe('link inline-tag-def', function() { let injector, tag, getLinkInfo, log; beforeEach(() => { getLinkInfo = jasmine.createSpy('getLinkInfo'); const testPackage = testPackageFactory('links-package', true) .factory('getLinkInfo', function() { return getLinkInfo; }); getLinkInfo.disambiguators = []; const dgeni = new Dgeni([testPackage]); injector = dgeni.configureInjector(); tag = injector.get('linkInlineTagDef'); log = injector.get('log'); }); it('should be available as a service', () => { expect(tag).toBeDefined(); expect(tag.name).toEqual('link'); expect(tag.aliases).toEqual(['linkDocs']); }); it('should call getLinkInfo', () => { const doc = {}; const tagName = 'link'; const tagDescription = 'doc-id link text'; getLinkInfo.and.returnValue({ url: 'url/to/doc', title: 'link text' }); tag.handler(doc, tagName, tagDescription); expect(getLinkInfo).toHaveBeenCalledWith('doc-id', 'link text', doc); }); it('should return an HTML anchor tag', () => { const doc = {}; const tagName = 'link'; const tagDescription = 'doc-id link text'; getLinkInfo.and.returnValue({ url: 'url/to/doc', title: 'link text' }); const result = tag.handler(doc, tagName, tagDescription); expect(result).toEqual('link text'); }); it('should log a warning if not failOnBadLink and the link is "bad"', () => { const doc = {}; const tagName = 'link'; const tagDescription = 'doc-id link text'; getLinkInfo.and.returnValue({ valid: false, error: 'Error message', errorType: 'error' }); expect(() => tag.handler(doc, tagName, tagDescription)).not.toThrow(); expect(log.warn).toHaveBeenCalledWith('Error in {@link doc-id link text} - Error message - doc'); }); it('should throw an error if failOnBadLink and the link is "bad"', () => { const doc = {}; const tagName = 'link'; const tagDescription = 'doc-id link text'; getLinkInfo.and.returnValue({ valid: false, error: 'Error message', errorType: 'error' }); tag.failOnBadLink = true; expect(() => tag.handler(doc, tagName, tagDescription)).toThrowError('Error in {@link doc-id link text} - Error message - doc'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByDeprecated.js ================================================ module.exports = function disambiguateByDeprecated() { return (alias, originatingDoc, docs) => { const filteredDocs = docs.filter((doc) => doc.deprecated === undefined); return filteredDocs.length > 0 ? filteredDocs : docs; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByDeprecated.spec.js ================================================ const disambiguateByDeprecated = require('./disambiguateByDeprecated')(); const doc1 = { id: 'doc1' }; const doc2 = { id: 'doc2', deprecated: true }; const doc3 = { id: 'doc3', deprecated: '' }; const doc4 = { id: 'doc4' }; const doc5 = { id: 'doc5', deprecated: 'Some text' }; describe('disambiguateByDeprecated', () => { it('should filter out docs whose `deprecated` property is defined', () => { expect(disambiguateByDeprecated('alias', {}, [doc1, doc2, doc3, doc4, doc5])).toEqual([doc1, doc4]); }); it('should not filter docs if all of them are `deprecated`', () => { expect(disambiguateByDeprecated('alias', {}, [doc2, doc3, doc5])).toEqual([doc2, doc3, doc5]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByModule.js ================================================ module.exports = function disambiguateByModule() { return (alias, originatingDoc, docs) => { const originatingModule = originatingDoc && originatingDoc.moduleDoc; if (originatingModule) { const filteredDocs = docs.filter(doc => doc.moduleDoc === originatingModule); if (filteredDocs.length > 0) { return filteredDocs; } } return docs; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByModule.spec.js ================================================ const disambiguateByModule = require('./disambiguateByModule')(); const moduleA = { name: 'a' }; const moduleB = { name: 'b' }; const docs = [ { id: 'doc1', moduleDoc: moduleA }, { id: 'doc2', moduleDoc: moduleA }, { id: 'doc3', moduleDoc: moduleB }, ]; describe('disambiguateByModule', () => { it('should return all docs if the originating doc has no moduleDoc', () => { expect(disambiguateByModule('alias', { }, docs)).toEqual(docs); }); it('should return all docs if no docs match the originating doc moduleDoc', () => { expect(disambiguateByModule('alias', { moduleDoc: { name: 'c' } }, docs)).toEqual(docs); }); it('should return only docs that match the moduleDoc of the originating doc', () => { expect(disambiguateByModule('alias', { moduleDoc: moduleA }, docs)).toEqual([ { id: 'doc1', moduleDoc: moduleA }, { id: 'doc2', moduleDoc: moduleA }, ]); expect(disambiguateByModule('alias', { moduleDoc: moduleB }, docs)).toEqual([ { id: 'doc3', moduleDoc: moduleB }, ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByNonMember.js ================================================ /** * This link disambiguator will remove all the members from the list of ambiguous links * if there is at least one link to a doc that is not a member. * * The heuristic is that exports are more important than members when linking, and that * in general members will be linked to via a more explicit code links such as * `MyClass.member` rather than simply `member`. */ module.exports = function disambiguateByNonMember() { return (alias, originatingDoc, docs) => { const filteredDocs = docs.filter(doc => doc.docType !== 'member'); return filteredDocs.length > 0 ? filteredDocs : docs; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByNonMember.spec.js ================================================ const disambiguateByNonMember = require('./disambiguateByNonMember')(); const doc1 = { id: 'doc1', docType: 'function', containerDoc: {} }; const doc2 = { id: 'doc2', docType: 'member', containerDoc: {} }; const doc3 = { id: 'doc3', docType: 'member', containerDoc: {} }; const doc4 = { id: 'doc4', docType: 'class', containerDoc: {} }; const doc5 = { id: 'doc5', docType: 'member', containerDoc: {} }; describe('disambiguateByNonMember', () => { it('should filter out docs that are not members', () => { const docs = [doc1, doc2, doc3, doc4, doc5]; expect(disambiguateByNonMember('alias', {}, docs)).toEqual([doc1, doc4]); }); it('should return all docs if there are no members', () => { const docs = [doc1, doc4]; expect(disambiguateByNonMember('alias', {}, docs)).toEqual([doc1, doc4]); }); it('should return all docs if there are only members', () => { const docs = [doc2, doc3, doc5]; expect(disambiguateByNonMember('alias', {}, docs)).toEqual([doc2, doc3, doc5]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByNonOperator.js ================================================ /** * This link disambiguator will remove all the members from the list of ambiguous links * if there is at least one link to a doc that does not belong to 'operators' module. * TODO Remove this disambiguator once 'rxjs/operators' export is removed */ module.exports = function disambiguateByNonOperator() { return (alias, originatingDoc, docs) => { const filteredDocs = docs.filter((doc) => doc.moduleDoc?.id !== 'operators'); return filteredDocs.length > 0 ? filteredDocs : docs; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/disambiguators/disambiguateByNonOperator.spec.js ================================================ const disambiguateByNonOperator = require('./disambiguateByNonOperator')(); const indexModule = { id: 'index' }; const operatorsModule = { id: 'operators' }; const doc1 = { id: 'doc1', moduleDoc: indexModule }; const doc2 = { id: 'doc2', moduleDoc: operatorsModule }; const doc3 = { id: 'doc3', moduleDoc: operatorsModule }; const doc4 = { id: 'doc4', moduleDoc: indexModule }; describe('disambiguateByNonOperator', () => { it('should filter out docs that are not operators', () => { const docs = [doc1, doc2]; expect(disambiguateByNonOperator('alias', {}, docs)).toEqual([doc1]); }); it('should return all docs if there are no operators', () => { const docs = [doc1, doc4]; expect(disambiguateByNonOperator('alias', {}, docs)).toEqual([doc1, doc4]); }); it('should return all docs if there are only operators', () => { const docs = [doc2, doc3]; expect(disambiguateByNonOperator('alias', {}, docs)).toEqual([doc2, doc3]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getAliases.js ================================================ function parseCodeName(codeName) { var parts = []; var currentPart; codeName.split('.').forEach(function(part) { var subParts = part.split(':'); var name = subParts.pop(); var modifier = subParts.pop(); if (!modifier && currentPart) { currentPart.name += '.' + name; } else { currentPart = {name: name, modifier: modifier}; parts.push(currentPart); } }); return parts; } /** * @dgService getAliases * @description * Get a list of all the aliases that can be made from the doc * @param {Object} doc A doc from which to extract aliases * @return {Array} A collection of aliases */ module.exports = function getAliases() { return function(doc) { var codeNameParts = parseCodeName(doc.id); var methodName; var aliases = []; // Add the last part to the list of aliases var part = codeNameParts.pop(); // If the name contains a # then it is a member and that should be included in the aliases if (part.name.indexOf('#') !== -1) { methodName = part.name.split('#')[1]; } // Add the part name and modifier, if provided aliases.push(part.name); if (part.modifier) { aliases.push(part.modifier + ':' + part.name); } // Continue popping off the parts of the codeName and work forward collecting up each alias aliases = codeNameParts.reduceRight(function(aliases, part) { // Add this part to each of the aliases we have so far aliases.forEach(function(name) { // Add the part name and modifier, if provided aliases.push(part.name + '.' + name); if (part.modifier) { aliases.push(part.modifier + ':' + part.name + '.' + name); } }); return aliases; }, aliases); if (methodName) { aliases.push(methodName); } return aliases; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getAliases.spec.js ================================================ var getAliasesFactory = require('./getAliases'); describe('getAliases', function() { it('should extract all the parts from a code name', function() { var getAliases = getAliasesFactory(); expect(getAliases({id: 'module:ng.service:$http#get'})).toEqual([ '$http#get', 'service:$http#get', 'ng.$http#get', 'module:ng.$http#get', 'ng.service:$http#get', 'module:ng.service:$http#get', 'get' ]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getDocFromAlias.js ================================================ /** * @dgService getDocFromAlias * @description Get an array of docs that match this alias, relative to the originating doc. * * @property {Array<(alias: string, originatingDoc: Doc, ambiguousDocs: Doc[]) => Doc[]>} disambiguators * a collection of functions that attempt to resolve ambiguous links. Each disambiguator returns * a new collection of docs with unwanted ambiguous docs removed (see links-package/service/disambiguators * for examples). */ module.exports = function getDocFromAlias(aliasMap) { getDocFromAlias.disambiguators = []; return getDocFromAlias; function getDocFromAlias(alias, originatingDoc) { return getDocFromAlias.disambiguators.reduce( // Run the disambiguators while there is more than 1 doc found (docs, disambiguator) => docs.length > 1 ? disambiguator(alias, originatingDoc, docs) : docs, // Start with the docs that match the alias aliasMap.getDocs(alias) ); } }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getDocFromAlias.spec.js ================================================ var testPackage = require('../../helpers/test-package'); var Dgeni = require('dgeni'); var getDocFromAlias, aliasMap; describe('getDocFromAlias', () => { beforeEach(() => { var dgeni = new Dgeni([testPackage('links-package', true)]); var injector = dgeni.configureInjector(); aliasMap = injector.get('aliasMap'); getDocFromAlias = injector.get('getDocFromAlias'); }); it('should return an array of docs that match the alias', () => { var doc1 = {aliases: ['a', 'b', 'c']}; var doc2 = {aliases: ['a', 'b']}; var doc3 = {aliases: ['a']}; aliasMap.addDoc(doc1); aliasMap.addDoc(doc2); aliasMap.addDoc(doc3); expect(getDocFromAlias('a')).toEqual([doc1, doc2, doc3]); expect(getDocFromAlias('b')).toEqual([doc1, doc2]); expect(getDocFromAlias('c')).toEqual([doc1]); }); it('should filter ambiguous docs by calling each disambiguator', () => { getDocFromAlias.disambiguators = [ (alias, originatingDoc, docs) => docs.filter(doc => doc.name.indexOf('X') !== -1), // only if X appears in name (alias, originatingDoc, docs) => docs.filter(doc => doc.name.indexOf('Y') !== -1) // only if Y appears in name ]; var doc1 = {name: 'X', aliases: ['a', 'b', 'c']}; var doc2 = {name: 'Y', aliases: ['a', 'b']}; var doc3 = {name: 'XY', aliases: ['a', 'c']}; aliasMap.addDoc(doc1); aliasMap.addDoc(doc2); aliasMap.addDoc(doc3); // doc1 and doc2 get removed as they don't both have X and Y in name expect(getDocFromAlias('a')).toEqual([doc3]); // doc2 gets removed as it has no X; then we have only one doc left so second disambiguator never runs expect(getDocFromAlias('b')).toEqual([doc1]); // doc1 gets removed as it has no Y; then we have only one doc left (which would anyway pass 2nd disambiguator) expect(getDocFromAlias('c')).toEqual([doc3]); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getLinkInfo.js ================================================ var path = require('canonical-path'); /** * @dgService getLinkInfo * @description * Get link information to a document that matches the given url * @kind function * @param {String} url The url to match * @param {String} title An optional title to return in the link information * @return {Object} The link information * * @property {boolean} relativeLinks Whether we expect the links to be relative to the originating doc */ module.exports = function getLinkInfo(getDocFromAlias, encodeCodeBlock, log) { return getLinkInfoImpl; function getLinkInfoImpl(url, title, currentDoc) { var linkInfo = {url: url, type: 'url', valid: true, title: title || url}; if (!url) { throw new Error('Invalid url'); } var docs = getDocFromAlias(url, currentDoc); if (!getLinkInfoImpl.useFirstAmbiguousLink && docs.length > 1) { linkInfo.valid = false; linkInfo.errorType = 'ambiguous'; linkInfo.error = 'Ambiguous link: "' + url + '".\n' + docs.reduce(function(msg, doc) { return msg + '\n "' + doc.id + '" (' + doc.docType + ') : (' + doc.path + ' / ' + doc.fileInfo.relativePath + ')'; }, 'Matching docs: '); } else if (docs.length >= 1) { linkInfo.url = docs[0].path; linkInfo.title = title || docs[0].title || docs[0].name && encodeCodeBlock(docs[0].name, true); linkInfo.type = 'doc'; if (getLinkInfoImpl.relativeLinks && currentDoc && currentDoc.path) { var currentFolder = path.dirname(currentDoc.path); var docFolder = path.dirname(linkInfo.url); var relativeFolder = path.relative(path.join('/', currentFolder), path.join('/', docFolder)); linkInfo.url = path.join(relativeFolder, path.basename(linkInfo.url)); log.debug(currentDoc.path, docs[0].path, linkInfo.url); } } else if (url.indexOf('#') > 0) { var pathAndHash = url.split('#'); linkInfo = getLinkInfoImpl(pathAndHash[0], title, currentDoc); linkInfo.url = linkInfo.url + '#' + pathAndHash[1]; return linkInfo; } else if (url.indexOf('/') === -1 && url.indexOf('#') !== 0) { linkInfo.valid = false; linkInfo.errorType = 'missing'; linkInfo.error = 'Invalid link (does not match any doc): "' + url + '"'; } else { linkInfo.title = title || ((url.indexOf('#') === 0) ? url.substring(1) : path.basename(url, '.html')); } if (linkInfo.title === undefined) { linkInfo.valid = false; linkInfo.errorType = 'no-title'; linkInfo.error = 'The link is missing a title'; } return linkInfo; } }; ================================================ FILE: apps/rxjs.dev/tools/transforms/links-package/services/getLinkInfo.spec.js ================================================ const testPackage = require('../../helpers/test-package'); const Dgeni = require('dgeni'); let getLinkInfo, aliasMap; describe('getLinkInfo', () => { beforeEach(function() { var dgeni = new Dgeni([testPackage('links-package', true)]); var injector = dgeni.configureInjector(); aliasMap = injector.get('aliasMap'); getLinkInfo = injector.get('getLinkInfo'); }); it('should use the title if specified', () => { aliasMap.addDoc({ docType: 'guide', title: 'Browser Support', name: 'browser-support', id: 'guide/browser-support', aliases: ['guide/browser-support', 'browser-support'], path: 'guide/browser-support' }); const currentDoc = { }; const linkInfo = getLinkInfo('browser-support', '"Browser Support Guide"', currentDoc); expect(linkInfo.title).toBe('"Browser Support Guide"'); }); it('should set the link to invalid if the title is `undefined`', () => { aliasMap.addDoc({ docType: 'guide', id: 'guide/browser-support', aliases: ['guide/browser-support', 'browser-support'], path: 'guide/browser-support' }); const currentDoc = { }; const linkInfo = getLinkInfo('browser-support', undefined, currentDoc); expect(linkInfo.valid).toBe(false); expect(linkInfo.errorType).toEqual('no-title'); expect(linkInfo.error).toEqual('The link is missing a title'); }); it('should use the target document title if available and no title is specified', () => { aliasMap.addDoc({ docType: 'guide', title: 'Browser Support', id: 'guide/browser-support', aliases: ['guide/browser-support', 'browser-support'], path: 'guide/browser-support' }); const currentDoc = { }; const linkInfo = getLinkInfo('browser-support', undefined, currentDoc); expect(linkInfo.valid).toBe(true); expect(linkInfo.title).toEqual('Browser Support'); }); it('should prefer the target doc title over name if available and no title is specified', () => { aliasMap.addDoc({ docType: 'guide', title: 'Browser Support', name: 'browser-support', id: 'guide/browser-support', aliases: ['guide/browser-support', 'browser-support'], path: 'guide/browser-support' }); const currentDoc = { }; const linkInfo = getLinkInfo('browser-support', undefined, currentDoc); expect(linkInfo.valid).toBe(true); expect(linkInfo.title).toEqual('Browser Support'); }); it('should use the target document name as a code block if available and no title is specified', () => { aliasMap.addDoc({ docType: 'api', name: 'CurrencyPipe', id: 'common/CurrencyPipe', aliases: ['common/CurrencyPipe', 'CurrencyPipe'], path: 'api/common/CurrencyPipe' }); const currentDoc = { }; const linkInfo = getLinkInfo('CurrencyPipe', undefined, currentDoc); expect(linkInfo.valid).toBe(true); expect(linkInfo.title).toEqual('CurrencyPipe'); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/index.js ================================================ var Package = require('dgeni').Package; /** * @dgPackage remark * @description Overrides the renderMarkdown service with an implementation based on remark */ module.exports = new Package('remark', ['nunjucks']) .factory(require('./services/markedNunjucksFilter')) .factory(require('./services/renderMarkdown')); ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/services/handlers/code.js ================================================ /** * Render markdown code blocks as `` tags */ module.exports = function code(h, node) { var value = node.value ? ('\n' + node.value + '\n') : ''; var lang = node.lang && node.lang.match(/^[^ \t]+(?=[ \t]|$)/); var props = {}; if (lang) { props.language = lang; } return h(node, 'code-example', props, [{ type: 'text', value }]); }; ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/services/markedNunjucksFilter.js ================================================ /** * Convert the value, as markdown, into HTML * @param headingMappings A map of headings to convert (e.g. from h3 to h4). */ module.exports = function markedNunjucksFilter(renderMarkdown) { return { name: 'marked', process: function(str, headingMappings) { return str && renderMarkdown(str, headingMappings); } }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/services/plugins/mapHeadings.js ================================================ const visit = require('unist-util-visit'); function headingToLevel(heading) { const match = /^h(\d+)/.exec(heading); return match ? match[1] : '0'; } function parseMappings(mappings) { const mapping = {}; Object.keys(mappings).forEach(key => mapping[headingToLevel(key)] = headingToLevel(mappings[key])); return mapping; } module.exports = function mapHeadings(mappings) { const headings = parseMappings(mappings || {}); return () => ast => { const nodesToFix = []; Object.keys(headings).forEach(heading => { visit(ast, 'heading', node => { if (node.depth === Number(heading)) { nodesToFix.push(node); } }); }); // Update the depth of the matched nodes nodesToFix.forEach(node => node.depth = headings[node.depth]); return ast; }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/services/renderMarkdown.js ================================================ const remark = require('remark'); const html = require('remark-html'); const code = require('./handlers/code'); const mapHeadings = require('./plugins/mapHeadings'); /** * @dgService renderMarkdown * @description * Render the markdown in the given string as HTML. * @param headingMap A map of headings to convert. * E.g. `{h3: 'h4'} will map heading 3 level into heading 4. */ module.exports = function renderMarkdown() { return function renderMarkdownImpl(content, headingMap) { const renderer = remark() .use(inlineTagDefs) .use(noIndentedCodeBlocks) .use(plainHTMLBlocks) // USEFUL DEBUGGING CODE // .use(() => tree => { // console.log(require('util').inspect(tree, { colors: true, depth: 4 })); // }) .use(mapHeadings(headingMap)) .use(html, { handlers: { code }, sanitize: false }); return renderer.processSync(content).toString(); }; /** * Teach remark not to render indented codeblocks */ function noIndentedCodeBlocks() { const blockMethods = this.Parser.prototype.blockMethods; blockMethods.splice(blockMethods.indexOf('indentedCode'), 1); } /** * Teach remark about inline tags, so that it neither wraps block level * tags in paragraphs nor processes the text within the tag. */ function inlineTagDefs() { const Parser = this.Parser; const inlineTokenizers = Parser.prototype.inlineTokenizers; const inlineMethods = Parser.prototype.inlineMethods; const blockTokenizers = Parser.prototype.blockTokenizers; const blockMethods = Parser.prototype.blockMethods; blockTokenizers.inlineTag = tokenizeInlineTag; blockMethods.splice(blockMethods.indexOf('paragraph'), 0, 'inlineTag'); inlineTokenizers.inlineTag = tokenizeInlineTag; inlineMethods.splice(blockMethods.indexOf('text'), 0, 'inlineTag'); tokenizeInlineTag.notInLink = true; tokenizeInlineTag.locator = inlineTagLocator; function tokenizeInlineTag(eat, value, silent) { const match = /^\{@[^\s}]+[^}]*\}/.exec(value); if (match) { if (silent) { return true; } return eat(match[0])({ 'type': 'inlineTag', 'value': match[0] }); } } function inlineTagLocator(value, fromIndex) { return value.indexOf('{@', fromIndex); } } /** * Teach remark that some HTML blocks never include markdown */ function plainHTMLBlocks() { const plainBlocks = ['code-example', 'code-tabs']; // Create matchers for each block const anyBlockMatcher = new RegExp('^' + createOpenMatcher(`(${plainBlocks.join('|')})`)); const Parser = this.Parser; const blockTokenizers = Parser.prototype.blockTokenizers; const blockMethods = Parser.prototype.blockMethods; blockTokenizers.plainHTMLBlocks = tokenizePlainHTMLBlocks; blockMethods.splice(blockMethods.indexOf('html'), 0, 'plainHTMLBlocks'); function tokenizePlainHTMLBlocks(eat, value, silent) { const openMatch = anyBlockMatcher.exec(value); if (openMatch) { const blockName = openMatch[1]; try { const fullMatch = matchRecursiveRegExp(value, createOpenMatcher(blockName), createCloseMatcher(blockName))[0]; if (silent || !fullMatch) { // either we are not eating (silent) or the match failed return !!fullMatch; } return eat(fullMatch[0])({ type: 'html', value: fullMatch[0] }); } catch(e) { this.file.fail('Unmatched plain HTML block tag ' + e.message); } } } } }; /** * matchRecursiveRegExp * * (c) 2007 Steven Levithan * MIT License * * Accepts a string to search, a left and right format delimiter * as regex patterns, and optional regex flags. Returns an array * of matches, allowing nested instances of left/right delimiters. * Use the "g" flag to return all matches, otherwise only the * first is returned. Be careful to ensure that the left and * right format delimiters produce mutually exclusive matches. * Backreferences are not supported within the right delimiter * due to how it is internally combined with the left delimiter. * When matching strings whose format delimiters are unbalanced * to the left or right, the output is intentionally as a * conventional regex library with recursion support would * produce, e.g. "<" and ">" both produce ["x"] when using * "<" and ">" as the delimiters (both strings contain a single, * balanced instance of ""). * * examples: * matchRecursiveRegExp("test", "\\(", "\\)") * returns: [] * matchRecursiveRegExp(">>t<>", "<", ">", "g") * returns: ["t<>", ""] * matchRecursiveRegExp("
test
", "]*>", "", "gi") * returns: ["test"] */ function matchRecursiveRegExp(str, left, right, flags) { 'use strict'; const matchPos = rgxFindMatchPos(str, left, right, flags); const results = []; for (var i = 0; i < matchPos.length; ++i) { results.push([ str.slice(matchPos[i].wholeMatch.start, matchPos[i].wholeMatch.end), str.slice(matchPos[i].match.start, matchPos[i].match.end), str.slice(matchPos[i].left.start, matchPos[i].left.end), str.slice(matchPos[i].right.start, matchPos[i].right.end) ]); } return results; } function rgxFindMatchPos(str, left, right, flags) { 'use strict'; flags = flags || ''; const global = flags.indexOf('g') > -1; const bothMatcher = new RegExp(left + '|' + right, 'g' + flags.replace(/g/g, '')); const leftMatcher = new RegExp(left, flags.replace(/g/g, '')); const pos = []; let index, match, start, end; let count = 0; while ((match = bothMatcher.exec(str))) { if (leftMatcher.test(match[0])) { if (!(count++)) { index = bothMatcher.lastIndex; start = index - match[0].length; } } else if (count) { if (!--count) { end = match.index + match[0].length; var obj = { left: {start: start, end: index}, match: {start: index, end: match.index}, right: {start: match.index, end: end}, wholeMatch: {start: start, end: end} }; pos.push(obj); if (!global) { return pos; } } } } if (count) { throw new Error(str.slice(start, index)); } return pos; } function createOpenMatcher(elementNameMatcher) { const attributeName = '[a-zA-Z_:][a-zA-Z0-9:._-]*'; const unquoted = '[^"\'=<>`\\u0000-\\u0020]+'; const singleQuoted = '\'[^\']*\''; const doubleQuoted = '"[^"]*"'; const attributeValue = '(?:' + unquoted + '|' + singleQuoted + '|' + doubleQuoted + ')'; const attribute = '(?:\\s+' + attributeName + '(?:\\s*=\\s*' + attributeValue + ')?)'; return `<${elementNameMatcher}${attribute}*\\s*>`; } function createCloseMatcher(elementNameMatcher) { return ``; } ================================================ FILE: apps/rxjs.dev/tools/transforms/remark-package/services/renderMarkdown.spec.js ================================================ const renderMarkdownFactory = require('./renderMarkdown'); // prettier-ignore describe('remark: renderMarkdown service', () => { let renderMarkdown; beforeEach(() => { renderMarkdown = renderMarkdownFactory(); }); it('should convert markdown to HTML', () => { const content = '# heading 1\n' + '\n' + 'A paragraph with **bold** and _italic_.\n' + '\n' + '* List item 1\n' + '* List item 2'; const output = renderMarkdown(content); expect(output).toEqual( '

heading 1

\n' + '

A paragraph with bold and italic.

\n' + '
    \n' + '
  • List item 1
  • \n' + '
  • List item 2
  • \n' + '
\n'); }); it('should not process markdown inside inline tags', () => { const content = '* list item {@link some_url_path}'; const output = renderMarkdown(content); expect(output).toEqual('
    \n
  • list item {@link some_url_path}
  • \n
\n'); }); it('should not format the contents of tags marked as unformatted ', () => { const content = '\n\n **abc**\n\n def\n\n\n\n\n **abc**\n\n def\n'; const output = renderMarkdown(content); expect(output).toEqual('\n\n **abc**\n\n def\n\n\n\n **abc**\n\n def\n\n'); }); it('should handle recursive tags marked as unformatted', () => { const content = '\n\n \n\n **abc**\n\n def\n\n\n\n\n\nhij\n\n\n\nklm'; const output = renderMarkdown(content); expect(output).toEqual('\n\n \n\n **abc**\n\n def\n\n\n\n\n

hij

\n\n\nklm\n'); }); it('should raise an error if a tag marked as unformatted is not closed', () => { const content = '\n\n **abc**\n\n def\n\n\n\n\n **abc**\n\n def\n'; expect(() => renderMarkdown(content)).toThrowError('Unmatched plain HTML block tag '); }); it('should not remove spaces after anchor tags', () => { var input = 'A aa aaa aaaa aaaaa aaaaaa aaaaaaa aaaaaaaa aaaaaaaaa aaaaaaaaaa aaaaaaaaaaa\n' + '[foo](path/to/foo) bbb.'; var output = '

' + 'A aa aaa aaaa aaaaa aaaaaa aaaaaaa aaaaaaaa aaaaaaaaa aaaaaaaaaa aaaaaaaaaaa\n' + 'foo bbb.' + '

\n'; expect(renderMarkdown(input)).toEqual(output); }); it('should not format indented text as code', () => { const content = 'some text\n\n indented text\n\nother text'; const output = renderMarkdown(content); expect(output).toEqual('

some text

\n

indented text

\n

other text

\n'); }); it('should format triple backtick code blocks as `code-example` tags', () => { const content = '```ts\n' + ' class MyClass {\n' + ' method1() { ... }\n' + ' }\n' + '```'; const output = renderMarkdown(content); expect(output).toEqual( '\n' + ' class MyClass {\n' + ' method1() { ... }\n' + ' }\n' + '\n' ); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/README.md ================================================ # Decision Tree Generator ## Purpose Manage a decision tree in YAML for choosing an operator and generate JSON to be consumed by the docs web app. ## Goals - Port the first version of the decision-tree-widget into Angular - Flatten the JSON structure and make it easy to work with in the docs web app - Consume URI paths and other relevant from the docs generation task vai Dgeni - Keep the decision tree work scalable and easy to work with by keeping the linked list structure in the YAML tree ## Prior Art Version 1 was in the old docs site and used YAML, snabbdom, RxJS, and hyperscript-helpers. The YAML for version 1 version was ported into the new version with minor tweaks. ## Tech - Node - TypeScript - TS-Node - Jest - YAML ## Dependencies Generating the JSON requires: - The decision tree YAML, located in `/src` - The generated `api-list.json`, which can be generated by running `yarn docs` at the root level of the `apps/rxjs.dev` ## Setup & Build ```shell npm i && yarn build ``` ## Development Any changes to the YAML tree or any of the TypeScript scripts will generate a new JSON tree ```shell yarn watch ``` ## Distribution After a `yarn build` the JSON is output to `apps/rxjs.dev/src/generated/app/decision-tree-data.json` to be consumed by the web application. There's also an npm script at the root level of the `apps/rxjs.dev` to generate the JSON tree: `docs-decision-tree`. ## Testing Run a watch task when writing tests ```shell yarn test:watch ``` Full test ```shell yarn test ``` Run coverage ```shell yarn test:coverage yarn test:watch:coverage ``` ## TODO - Consider moving this work into a Dgeni package so it can be generated in the same way the other doc information is generated ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/index.ts ================================================ import { readFile } from 'fs/promises'; import { parse } from 'yamljs'; import { TreeNodeRaw, build, flattenApiList } from './src/lib'; module.exports = function decisionTreeGenerator(log: { warn: (message: string) => void }) { return { $runBefore: ['rendering-docs'], $runAfter: ['generateApiListDoc'], $validate: { decisionTreeFile: { presence: true }, outputFolder: { presence: true }, }, $process: async function (this: any, docs: any[]) { const apiListDoc = docs.find((doc) => doc.docType === 'api-list-data'); if (!apiListDoc) { throw new Error('Can not find api-list-data for decision tree generation'); } const yamlContent = await readFile(this.decisionTreeFile, { encoding: 'utf8' }); const decisionTreeJson: TreeNodeRaw[] = parse(yamlContent); const flattenedApiList = flattenApiList(apiListDoc.data); const jsonContent = build(flattenedApiList, decisionTreeJson, log); docs.push({ docType: 'decision-tree-data', template: 'json-doc.template.json', path: this.outputFolder + '/decision-tree-data.json', outputPath: this.outputFolder + '/decision-tree-data.json', data: jsonContent, }); return docs; }, }; }; ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/addUniqueId.spec.ts ================================================ import { addUniqueId } from './addUniqueId'; import { TreeNode } from './interfaces'; import { mockRawTreeNodes } from './fixtures'; describe('addUniqueId', () => { describe('when called with three raw nodes', () => { let tree: TreeNode[]; const baseProperties = jasmine.objectContaining({ id: jasmine.any(String), label: jasmine.any(String), depth: jasmine.any(Number), }); beforeEach(() => { tree = addUniqueId(mockRawTreeNodes); }); describe('and one of the nodes is a child of another', () => { it('should not flatten the tree and return the same number of top level nodes', () => { expect(tree.length).toBe(mockRawTreeNodes.length); }); it('should return an array of tree nodes that have unique ids', () => { tree.forEach((node) => { expect(node).toEqual(baseProperties); if (!node.children) { expect(node.options).toBeUndefined(); } else { expect(node).toEqual( jasmine.objectContaining({ children: jasmine.any(Array), options: jasmine.any(Array), }) ); node.children.forEach((child) => { expect(child).toEqual(baseProperties); }); } }); }); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/addUniqueId.ts ================================================ import { TreeNode, TreeNodeRaw } from './interfaces'; import { generateUniqueId } from './generateUniqueId'; /** * Recursively walks the tree and adds unique ids. * It also aggregates nested nodes new unique IDs in an options field. * Depth is added to better determine later if it's an inital question * * @export * @param {Tree} tree * @param {number} [depth=0] * @requires generateUniqueId * @returns {Tree} */ export function addUniqueId(tree: TreeNodeRaw[], depth = 0): TreeNode[] { return tree.map(node => { let treeNode: TreeNode; treeNode = { label: node.label, id: generateUniqueId(), depth // used later in extractInitialSequence to determine the initial options }; if (node.children) { const children = addUniqueId(node.children, depth + 1); treeNode = { ...treeNode, children, options: children.map(({ id }) => id) }; } if (node.method) { treeNode = { ...treeNode, method: node.method }; } return treeNode; }); } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/build.spec.ts ================================================ import { build } from './build'; import { mockFlatApiList, mockRawTreeNodes } from './fixtures'; import { treeNodeCount } from './helpers'; describe('build', () => { const tree = build(mockFlatApiList, mockRawTreeNodes, {warn: () => {}}); it('should return a flat map of all nodes and one additional initial node', () => { expect(tree.initial).toBeDefined() expect(Object.keys(tree).length).toBe(treeNodeCount(mockRawTreeNodes) + 1); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/build.ts ================================================ import { addUniqueId } from './addUniqueId'; import { extractInitialSequence } from './extractInitialSequence'; import { FlattenedApiList, DecisionTree, TreeNodeRaw } from './interfaces'; import { decisionTreeReducer } from './decisionTreeReducer'; /** * Main build script, outputs the decision tree. * * @export * @param {FlattenedApiList} apiList * @param {Tree} tree * @requires addUniqueId * @requires extractInitialSequence * @requires decisionTreeReducer * @returns {DecisionTree} */ export function build(apiList: FlattenedApiList, tree: TreeNodeRaw[], log: { warn: (message: string) => void }): DecisionTree { const nodesWithUniqueIds = addUniqueId(tree); const initialOption = extractInitialSequence(nodesWithUniqueIds); return { ...decisionTreeReducer(nodesWithUniqueIds, apiList, log), [initialOption.id]: { ...initialOption }, }; } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/decisionTreeReducer.spec.ts ================================================ import { decisionTreeReducer } from './decisionTreeReducer'; import { mockFlatApiList, mockRawTreeNodes } from './fixtures'; import { addUniqueId } from './addUniqueId'; import { rawNodesWithMethodCount } from './helpers'; describe('decisionTreeReducer', () => { const tree = decisionTreeReducer(addUniqueId(mockRawTreeNodes), mockFlatApiList, { warn: jasmine.createSpy() }); describe('all nodes', () => { const baseProperties = jasmine.objectContaining({ id: jasmine.any(String), label: jasmine.any(String), }); it('should have base properties', () => { for (const key in tree) { if (tree.hasOwnProperty(key)) { expect(tree[key]).toEqual(baseProperties); } } }); describe('that have options', () => { it('should have an options property that is an array of strings', () => { for (const key in tree) { if (tree.hasOwnProperty(key) && tree[key].options) { tree[key].options?.forEach((option) => { expect(typeof option).toBe('string'); }); } } }); }); describe('when a node does not have options', () => { it('should not have an options property', () => { for (const key in tree) { if (tree.hasOwnProperty(key) && !tree[key].options) { expect(tree[key].options).toBeUndefined(); } } }); it('should have a docType and a path', () => { for (const key in tree) { if (tree.hasOwnProperty(key) && !tree[key].options) { expect(tree[key].docType).toBeDefined(); expect(tree[key].path).toBeDefined(); } } }); describe('and the node does not exist in the API list', () => { const treeNodesMissingInApiList = [ ...mockRawTreeNodes, { label: 'foo', }, ]; it('should call a console.log', () => { const spy = jasmine.createSpy('warn'); decisionTreeReducer(addUniqueId(treeNodesMissingInApiList), mockFlatApiList, { warn: spy }); expect(spy).toHaveBeenCalled(); }); }); }); describe('when any raw node had a method', () => { const rawCount = rawNodesWithMethodCount(mockRawTreeNodes); it('should have a method property', () => { let count = 0; for (const key in tree) { if (tree.hasOwnProperty(key) && tree[key].method) { count++; } } expect(count).toBe(rawCount); }); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/decisionTreeReducer.ts ================================================ import { DecisionTree, FlattenedApiList, FlattenedApiNode, TreeNode } from './interfaces'; /** * Recursively walks the tree and pulls relevant information from the API list. * Helps build the view model. * * @export * @param {Tree} tree * @param {FlattenedApiList} apiList * @returns {DecisionTree} */ export function decisionTreeReducer(tree: TreeNode[], apiList: FlattenedApiList, log: { warn: (message: string) => void }): DecisionTree { return tree.reduce((acc, curr) => { let nested; let treeNode: TreeNode = { // there might not be options, grab what we know is available id: curr.id, label: curr.label, }; if (curr.options) { // we are still deciding treeNode = { ...treeNode, options: curr.options, }; } if (!curr.options) { // we found the function/operator we want to use const apiNode: FlattenedApiNode = apiList[treeNode.label! as keyof FlattenedApiList]; if (!apiNode) { log.warn(`Decision Tree Generator - (reducer) - warning: Label does not exist in API List: ${treeNode.label}`); } treeNode = { ...treeNode, ...apiNode, // helps to build uri, used in Angular template }; } if (curr.method) { // if we need to point at a method of a class, like Observable.create, helps to build uri treeNode = { ...treeNode, method: curr.method, }; } if (curr.children) { // there are children of the current node, recursively walk the paths to continue building the decision tree data nested = decisionTreeReducer(curr.children, apiList, log); } return { ...acc, ...nested, [treeNode.id]: treeNode, }; }, {}); } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/extractInitialSequence.spec.ts ================================================ import { extractInitialSequence } from './extractInitialSequence'; import { addUniqueId } from './addUniqueId'; import { mockRawTreeNodes } from './fixtures'; const tree = addUniqueId(mockRawTreeNodes); const initialSequence = extractInitialSequence(tree); describe('extractInitialSequence', () => { describe('when given a tree that has passed through addUniqueId', () => { it('will return an object that has an id of initial', () => { expect(initialSequence).toEqual({ id: 'initial', options: jasmine.any(Array) }); }); it('it will return a number of options equal to the length of the original tree', () => { expect(initialSequence.options.length).toBe(mockRawTreeNodes.length); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/extractInitialSequence.ts ================================================ import { TreeNode } from './interfaces'; /** * Strip out initial sequence and add to tree * * @export * @param {Tree} tree * @returns {{id: string, options: string[]}} */ export function extractInitialSequence(tree: TreeNode[]): {id: string, options: string[]} { return { id: 'initial', options: tree.filter(node => !node.depth).map(node => node.id) }; } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/fixtures.ts ================================================ import { TreeNodeRaw, FlattenedApiList, ApiListNode } from './interfaces'; export const mockRawTreeNodes: TreeNodeRaw[] = [ { label: 'map' }, { label: 'just a label', children: [ { label: 'yet another label', children: [ { label: 'concat' } ] } ] }, { label: 'Observable', method: 'fakeMethod' } ]; export const mockFlatApiList = { map: { docType: 'function', path: 'fakePath' }, mapTo: { docType: 'function', path: 'fakePath' }, concat: { docType: 'function', path: 'fakePath' }, Observable: { docType: 'class', path: 'fakePath' } } as FlattenedApiList; // TODO consider using the real API list export const mockRawApiListWithDeprecatedRefs: ApiListNode[] = [ { name: 'foo', title: 'foo', items: [ { name: 'empty', title: 'EMPTY', path: 'api/index/function/empty', docType: 'function', stability: 'deprecated', securityRisk: false }, { name: 'empty', title: 'EMPTY', path: 'api/index/const/EMPTY', docType: 'const', stability: '', securityRisk: false }, { name: 'concat', title: 'concat', path: 'api/index/function/concat', docType: 'function', stability: '', securityRisk: false } ] }, { name: 'bar', title: 'bar', items: [ { name: 'never', title: 'NEVER', path: 'api/index/function/never', docType: 'function', stability: 'deprecated', securityRisk: false }, { name: 'never', title: 'NEVER', path: 'api/index/const/NEVER', docType: 'const', stability: '', securityRisk: false }, { name: 'map', title: 'map', path: 'api/index/function/map', docType: 'function', stability: '', securityRisk: false } ] } ]; ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/flattenApiList.spec.ts ================================================ import { flattenApiList } from './flattenApiList'; import { mockRawApiListWithDeprecatedRefs } from './fixtures'; import { validApiRefCount } from './helpers'; describe('flattenApiList', () => { describe('when a API reference is deprecated', () => { const flattenedApiList = flattenApiList(mockRawApiListWithDeprecatedRefs); const validRefCount = validApiRefCount(mockRawApiListWithDeprecatedRefs); it('should return a flat list with only stable refs', () => { expect(Object.keys(flattenedApiList).length).toBe(validRefCount); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/flattenApiList.ts ================================================ import { ApiListNode, FlattenedApiList } from './interfaces'; import { isStable } from './helpers'; /** * Flattens API List from the docs generation into a map with relevant properties. * Makes navigation easier. * * @export * @param {ApiListNode[]} [apiList=[]] * @requires isStable * @returns {FlattenedApiList} * @todo create better type lenses - inference is not working well here */ export function flattenApiList(apiList: ApiListNode[]): FlattenedApiList { return apiList.reduce((acc, curr): FlattenedApiList => { return { ...acc, ...curr.items.reduce((acc, curr): FlattenedApiList => { if (isStable(curr.stability)) { return { ...acc, [curr.title]: { path: curr.path, docType: curr.docType, } }; } return { ...acc, }; }, {} as FlattenedApiList), }; }, {} as FlattenedApiList); } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/generateUniqueId.spec.ts ================================================ import { generateUniqueId } from './generateUniqueId'; describe('generateUniqueId', () => { describe('when called', () => { it('will generate a unique string', () => { const x = generateUniqueId(); expect(x).not.toBe(generateUniqueId()); expect(typeof x).toBe('string'); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/generateUniqueId.ts ================================================ import { randomBytes } from 'crypto'; /** * Generates a unique ID for the decision tree nodes * * @export * @requires crypto:randomByes * @returns {string} */ export function generateUniqueId(): string { return randomBytes(2).toString('hex'); } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/helpers.spec.ts ================================================ import { isStable } from './helpers'; describe('isStable', () => { describe('when passed anything but the string "deprecated"', () => { it('will return true', () => { expect(isStable('')).toBeTruthy(); }); }); describe('when passed the string "deprecated"', () => { it('will return false', () => { expect(isStable('deprecated')).toBeFalsy(); }); }); }); ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/helpers.ts ================================================ import { ApiListNode, TreeNodeRaw } from './interfaces'; export type Omit = Pick>; /** * The model for an API list item has a stability property. * If the API reference is deprecated it will not be stable. * We don't want to point people to deprecated API references. * * @export * @param {string} stability * @returns {boolean} */ export function isStable(stability: string): boolean { return stability !== 'deprecated'; } /** * Recursively count the number of tree nodes * * @export * @param {*} tree * @returns */ export function treeNodeCount(tree: TreeNodeRaw[]) { return tree.reduce((acc: number, curr) => { let childSum: number; if (curr.children) { childSum = treeNodeCount(curr.children); return ++acc + childSum; } return ++acc; }, 0); } /** * Recursively count the number of nodes with a method * * @export * @param {*} tree * @returns */ export function rawNodesWithMethodCount(tree: TreeNodeRaw[]): number { return tree.filter((node) => { let childHadMethod = false; if (node.method) { return node; } if (node.children) { childHadMethod = rawNodesWithMethodCount(node.children) > 0; } return childHadMethod; }).length; } /** * Recursively count valid API references * Deprecated API refs are invalid * * @export * @param {*} apiList * @returns */ export function validApiRefCount(apiList: ApiListNode[]): number { return apiList.reduce((acc, curr) => { const itemCount = curr.items.reduce((a, node) => { if (node.stability === 'deprecated') { return a; } return ++a; }, 0); return acc + itemCount; }, 0); } ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/index.ts ================================================ export * from './addUniqueId'; export * from './build'; export * from './extractInitialSequence'; export * from './flattenApiList'; export * from './generateUniqueId'; export * from './interfaces'; export * from './decisionTreeReducer'; ================================================ FILE: apps/rxjs.dev/tools/transforms/rxjs-decision-tree-generator/src/lib/interfaces.ts ================================================ import { Omit } from './helpers'; export type DocType = | 'all' | 'class' | 'const' | 'enum' | 'function' | 'interface' | 'type-alias'; export type ApiUnion = | 'audit' | 'auditTime' | 'bindCallback' | 'bindNodeCallback' | 'buffer' | 'bufferCount' | 'bufferTime' | 'bufferToggle' | 'bufferWhen' | 'catchError' | 'combineLatest' | 'concat' | 'concatMap' | 'concatMapTo' | 'count' | 'debounce' | 'debounceTime' | 'defer' | 'delay' | 'delayWhen' | 'distinct' | 'distinctUntilChanged' | 'distinctUntilKeyChanged' | 'elementAt' | 'EMPTY' | 'exhaustMap' | 'expand' | 'filter' | 'finalize' | 'first' | 'forkJoin' | 'from' | 'fromEvent' | 'fromEventPattern' | 'generate' | 'groupBy' | 'ignoreElements' | 'interval' | 'last' | 'map' | 'mapTo' | 'materialize' | 'merge' | 'mergeMap' | 'mergeMapTo' | 'mergeScan' | 'NEVER' | 'Observable' | 'observeOn' | 'of' | 'pairwise' | 'partition' | 'pipe' | 'race' | 'range' | 'reduce' | 'repeat' | 'repeatWhen' | 'retry' | 'retryWhen' | 'scan' | 'share' | 'single' | 'skip' | 'skipLast' | 'skipUntil' | 'skipWhile' | 'startWith' | 'subscribeOn' | 'switchMap' | 'switchMapTo' | 'take' | 'takeLast' | 'takeUntil' | 'takeWhile' | 'tap' | 'throttle' | 'throttleTime' | 'throwError' | 'timeInterval' | 'timeout' | 'timeoutWith' | 'timer' | 'toArray' | 'window' | 'windowCount' | 'windowTime' | 'windowToggle' | 'windowWhen' | 'withLatestFrom' | 'zip'; export interface ApiListItem { docType: DocType; name: string; path: string; securityRisk: boolean; stability: string; title: ApiUnion; } export interface ApiListNode { items: ApiListItem[]; name: string; title: string; } export interface FlattenedApiNode { docType: DocType; path: string; } export type FlattenedApiList = { [K in ApiUnion]: FlattenedApiNode; }; export interface TreeNodeRaw { label: string; children?: TreeNodeRaw[]; method?: string; } export interface TreeNode { id: string; label?: string; children?: TreeNode[]; depth?: number; docType?: DocType; method?: string; options?: string[]; path?: string; } export interface DecisionTree { [key: string]: Omit; } ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/README.md ================================================ This folder contains the dgeni templates that are used to generate the API docs Generally there is a template for each docType. Templates can extend and/or include other templates. Templates can also import macros from other template files. # Template inheritance When extending a template, parent must declare blocks that can be overridden by the child. The template extension hierarchy looks like this (with declared blocks in parentheses): - layout/base.template.html (base) - module.template.html - layout/api-base.template.html (jumpNav, jumpNavLinks, whatItDoes, infoBar, securityConsiderations, deprecationNotes, howToUse, details) - class.template.html - directive.template.html - enum.template.html - var.template.html - const.template.html - let.template.html - decorator.template.html - function.template.html - interface.template.html - type-alias.template.html - pipe.template.html # Doc Properties It is useful to know what properties are available on each doc type when working with the templates. The `typescript` Dgeni package is now written in TypeScript and there is a class for each of the types of API document. See https://github.com/angular/dgeni-packages/tree/master/typescript/src/api-doc-types. This is a good place to go to see what properties you can use in the templates. ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/base.template.html ================================================ {% import "lib/githubLinks.html" as github -%} {% set comma = joiner(',') %} {% set slash = joiner('/') %}
mode_edit code
{% for crumb in doc.breadCrumbs %}{% if not loop.last %} {$ slash() $} {% if crumb.path %}{$ crumb.text $}{% else %}{$ crumb.text $}{% endif %}{% endif %}{% endfor %}

{$ doc.name $}

{% if doc.deprecated !== undefined %}{% endif %} {% if doc.experimental !== undefined %}{% endif %} {% if doc.stable !== undefined %}{% endif %} {% if doc.pipeOptions.pure === 'false' %}{% endif %} {% if doc.isOperator %}{% endif %}
{% block body %}{% endblock %}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/class.template.html ================================================ {% import "lib/memberHelpers.html" as memberHelpers -%} {% import "lib/descendants.html" as descendants -%} {% import "lib/paramList.html" as params -%} {% extends 'export-base.template.html' -%} {% block overview %} {% include "includes/class-overview.html" %} {% endblock %} {% block details %} {% block additional %}{% endblock %} {% include "includes/description.html" %} {$ memberHelpers.renderProperties(doc.staticProperties, 'static-properties', 'static-property', 'Static Properties') $} {$ memberHelpers.renderMethodDetails(doc.staticMethods, 'static-methods', 'static-method', 'Static Methods') $} {% if doc.constructorDoc %}

Constructor

{$ memberHelpers.renderMethodDetail(doc.constructorDoc, 'constructor') $}{% endif %} {$ memberHelpers.renderProperties(doc.properties, 'instance-properties', 'instance-property', 'Properties') $} {$ memberHelpers.renderMethodDetails(doc.methods, 'instance-methods', 'instance-method', 'Methods') $} {% block annotations %}{% include "includes/annotations.html" %}{% endblock %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/const.template.html ================================================ {% extends 'var.template.html' -%} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/decorator.template.html ================================================ {% import "lib/memberHelpers.html" as memberHelper -%} {% import "lib/paramList.html" as params -%} {% extends 'export-base.template.html' %} {% block overview %}{% include "includes/decorator-overview.html" %}{% endblock %} {% block details %} {% include "includes/description.html" %} {$ memberHelper.renderProperties(doc.members, 'metadata-members', 'metadata-member', 'Options') $} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/deprecation.template.html ================================================ {% block overview %}

Deprecations

The API listed below will be removed in the next major release!

{% for deprecation in doc.data %} {% endfor %}
{$ deprecation.name $} {$ deprecation.text $}
{% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/directive.template.html ================================================ {% import "lib/directiveHelpers.html" as directiveHelper -%} {% import "lib/paramList.html" as params -%} {% extends 'class.template.html' -%} {% block overview %}{% include "includes/directive-overview.html" %}{% endblock %} {% block additional -%} {% include "includes/selectors.html" %} {$ directiveHelper.renderBindings(doc.inputs, 'inputs', 'input', 'Inputs') $} {$ directiveHelper.renderBindings(doc.outputs, 'outputs', 'output', 'Outputs') $} {% include "includes/export-as.html" %} {% endblock %} {% block annotations %}{% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/enum.template.html ================================================ {% extends 'class.template.html' -%} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/export-base.template.html ================================================ {% extends 'base.template.html' -%} {% block body %} {% include "includes/renamed-exports.html" %}

{$ doc.shortDescription | marked $}

{% include "includes/security-notes.html" %} {% include "includes/deprecation.html" %} {% block overview %}{% endblock %} {% block details %}{% endblock %} {% include "includes/usageNotes.html" %} {% include "includes/see-also.html" %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/function.template.html ================================================ {% import "lib/memberHelpers.html" as memberHelpers -%} {% import "lib/paramList.html" as params -%} {% extends 'export-base.template.html' -%} {% block overview %} {% if doc.overloads.length > 0 and doc.overloads.length < 3 -%} {% for overload in doc.overloads -%} {$ memberHelpers.renderOverloadInfo(overload, 'function-overload', doc) $} {% if not loop.last %}
{% endif %} {% endfor -%} {% else %} {$ memberHelpers.renderOverloadInfo(doc, 'function-overload', doc) $} {% endif %} {% endblock %} {% block details %} {% include "includes/description.html" %} {% if doc.overloads.length >= 3 %}

Overloads

{% for overload in doc.overloads %} {% endfor %}
{$ memberHelpers.renderOverloadInfo(overload, 'function-overload', doc) $}
{% endif %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/annotations.html ================================================ {%- if doc.decorators.length %}

Annotations

{%- for decorator in doc.decorators %} @{$ decorator.name $}({$ decorator.arguments $}) {% if not decorator.notYetDocumented %}{$ decorator.description | marked $}{% endif %} {% endfor %}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/class-overview.html ================================================ {% import "lib/memberHelpers.html" as memberHelper -%}
{% if doc.isAbstract %}abstract {% endif%}{$ doc.docType $} {$ doc.name $}{$ doc.typeParams | escape $}{$ memberHelper.renderHeritage(doc) $} {{$ memberHelper.renderMembers(doc) $} } {$ descendants.renderDescendants(doc, 'class', 'Subclasses') $}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/decorator-overview.html ================================================ {% import "lib/memberHelpers.html" as memberHelper -%} {% if doc.members.length %}
@{$ doc.name $}{$ doc.typeParams | escape $}({ {$ memberHelper.renderMembers(doc) $} })
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/deprecation.html ================================================ {% if doc.deprecated %}

Deprecation Notes

{$ doc.deprecated | marked $}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/description.html ================================================ {% if doc.description %}

Description

{$ doc.description | trimBlankLines | marked $}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/directive-overview.html ================================================ {% import "lib/memberHelpers.html" as memberHelper -%}
{% for decorator in doc.decorators %} @{$ decorator.name $}({$ decorator.arguments $}){% endfor %} class {$ doc.name $}{$ doc.typeParams | escape $}{$ memberHelper.renderHeritage(doc) $} { {%- if doc.statics.length %}{% for member in doc.statics %}{% if not member.internal %} {$ memberHelper.renderMemberSyntax(member, 1) $}{% endif %}{% endfor %}{% endif -%} {$ memberHelper.renderMembers(doc) $} }
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/export-as.html ================================================ {%- if doc.exportAs %}

Exported as

{$ doc.exportAs $}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/info-bar.html ================================================ {% import "lib/githubLinks.html" as github -%}
{% if doc.ngModule %} {% endif %}
npm Package @angular/{$ doc.moduleDoc.id.split('/')[0] $}
Module import { {$ doc.name $} } from '@angular/{$ doc.moduleDoc.id $}';
Source {$ github.githubViewLink(doc, versionInfo) $}
NgModule {@link {$ doc.ngModule $}}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/interface-overview.html ================================================ {% import "lib/memberHelpers.html" as memberHelper -%}
interface {$ doc.name $}{$ doc.typeParams | escape $}{$ memberHelper.renderHeritage(doc) $} {{$ memberHelper.renderMembers(doc) $} } {$ descendants.renderDescendants(doc, 'interface', 'Child Interfaces') $} {$ descendants.renderDescendants(doc, 'class', 'Class Implementations') $}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/metadata.html ================================================ {% if doc.members.length %}

Metadata Properties

{% for metadata in doc.members %}{% if not metadata.internal %}
{$ metadata.name $}{$ params.paramList(metadata.parameters) | trim $}{$ params.returnType(metadata.type) $} {%- if not metadata.notYetDocumented %}{$ metadata.description | marked $}{% endif -%}
{% if not loop.last %}
{% endif %} {% endif %}{% endfor %}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/pipe-overview.html ================================================ {% import "lib/memberHelpers.html" as memberHelpers -%} {% import "lib/paramList.html" as params -%}
{{ {$ doc.valueParam.name $}_expression | {$ doc.pipeName $} {%- for param in doc.pipeParams %} {%- if param.isOptional or param.defaultValue !== undefined %} [{% endif %} : {$ param.name $} {%- endfor %} {%- for param in doc.pipeParams %} {%- if param.isOptional or param.defaultValue !== undefined %} ]{% endif %} {%- endfor %} }} {% if doc.valueParam.type %}

Input Value

{$ params.renderParameters([doc.valueParam], 'pipe-parameters', 'pipe-parameter', true) $} {% endif %} {% if doc.pipeParams.length %}

Parameters

{$ params.renderParameters(doc.pipeParams, 'pipe-parameters', 'pipe-parameter', true) $} {% endif %}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/renamed-exports.html ================================================ {% if doc.renamedDuplicates %}
Aliased as {% for d in doc.renamedDuplicates %} {$ d.name $}{% if not loop.last %}, {% endif %} {% endfor %}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/security-notes.html ================================================ {% if doc.security %}

Security Risk

{$ doc.security | marked $}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/see-also.html ================================================ {%- if doc.see.length %}

See Also

    {% for see in doc.see %}
  • {$ see | marked $}
  • {% endfor %}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/selectors.html ================================================ {%- if doc.selector %}

Selectors

{%- for selector in doc.selector.split(',') %} {$ selector $}{% endfor %}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/includes/usageNotes.html ================================================ {% if doc.usageNotes %}

Usage Notes

{$ doc.usageNotes | marked $}
{% endif %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/interface.template.html ================================================ {% import "lib/paramList.html" as params -%} {% import "lib/memberHelpers.html" as memberHelper -%} {% import "lib/descendants.html" as descendants -%} {% extends 'export-base.template.html' -%} {% block overview %}{% include "includes/interface-overview.html" %}{% endblock %} {% block details %} {% include "includes/description.html" %} {$ memberHelper.renderProperties(doc.properties, 'instance-properties', 'instance-property', 'Properties') $} {$ memberHelper.renderMethodDetails(doc.methods, 'instance-methods', 'instance-method', 'Methods') $} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/let.template.html ================================================ {% extends 'var.template.html' -%} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/lib/descendants.html ================================================ {% macro renderDescendantList(descendants, docType, recursed) %} {% if descendants.length %}
    {% for descendant in descendants %}
  • {$ descendant.name $} {$ renderDescendantList(descendant.descendants | filterByPropertyValue('docType', docType), docType, recursed) $}
  • {% endfor %}
{% endif %} {% endmacro -%} {%- macro renderDescendants(doc, docType, title='', recursed=true) %} {% set descendants = doc.descendants | filterByPropertyValue('docType', docType) %} {% if descendants.length %}
{% if title %}

{$ title $}

{% endif %} {$ renderDescendantList(descendants, docType, recursed) $}
{% endif %} {% endmacro %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/lib/directiveHelpers.html ================================================ {% macro renderBindings(bindings, cssContainerClass, cssItemClass, title) -%} {% if bindings.length %}

{$ title $}

{% for binding in bindings %}
{$ binding.bindingName $} bound to {$ binding.memberDoc.containerDoc.name $}.{$ binding.propertyName $} {#{$ binding.memberDoc.description | trimBlankLines | marked $}#}
{% endfor %}
{% endif %} {%- endmacro %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/lib/githubLinks.html ================================================ {% macro githubViewHref(doc, versionInfo) -%} https://github.com/{$ versionInfo.gitRepoInfo.owner $}/{$ versionInfo.gitRepoInfo.repo $}/tree/{$ versionInfo.currentVersion.isSnapshot and versionInfo.currentVersion.SHA or versionInfo.currentVersion.raw $}/src/{$ doc.fileInfo.realProjectRelativePath $}#L{$ doc.startingLine + 1 $}-L{$ doc.endingLine + 1 $} {%- endmacro %} {% macro githubEditHref(doc, versionInfo) -%} https://github.com/{$ versionInfo.gitRepoInfo.owner $}/{$ versionInfo.gitRepoInfo.repo $}/edit/master/src/{$ doc.fileInfo.realProjectRelativePath $}?message=docs( {%- if doc.moduleDoc %}{$ doc.moduleDoc.id.split('/')[0] $} {%- elseif doc.docType === 'module' %}{$ doc.id.split('/')[0] $} {%- else %}...{%- endif -%} )%3A%20describe%20your%20change...#L{$ doc.startingLine + 1 $}-L{$ doc.endingLine + 1 $} {%- endmacro %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/lib/memberHelpers.html ================================================ {% import "lib/paramList.html" as params -%} {%- macro renderHeritage(exportDoc) -%} {%- if exportDoc.extendsClauses.length %} extends {% for clause in exportDoc.extendsClauses -%} {$ clause.text | escape $}{% if not loop.last %}, {% endif -%} {% endfor %}{% endif %} {%- if exportDoc.implementsClauses.length %} implements {% for clause in exportDoc.implementsClauses -%} {$ clause.text | escape $}{% if not loop.last %}, {% endif -%} {% endfor %}{% endif %} {%- endmacro -%} {%- macro renderMembers(doc) -%} {%- for member in doc.staticProperties %}{% if not member.internal %} {$ renderMemberSyntax(member, 1) $}{% endif %}{% endfor -%} {% for member in doc.staticMethods %}{% if not member.internal %} {$ renderMemberSyntax(member, 1) $}{% endif %}{% endfor -%} {% if doc.constructorDoc and not doc.constructorDoc.internal %} {$ renderMemberSyntax(doc.constructorDoc, 1) $}{% endif -%} {% for member in doc.properties %}{% if not member.internal %} {$ renderMemberSyntax(member, 1) $}{% endif %}{% endfor -%} {% for member in doc.methods %}{% if not member.internal %} {$ renderMemberSyntax(member, 1) $}{% endif %}{% endfor -%} {%- for ancestor in doc.extendsClauses %}{% if ancestor.doc %} // inherited from {$ ancestor.doc.id $}{$ renderMembers(ancestor.doc) $}{% endif %}{% endfor -%} {%- endmacro -%} {%- macro renderMemberSyntax(member, truncateLines) -%} {%- if member.accessibility !== 'public' %}{$ member.accessibility $} {% endif -%} {%- if member.isAbstract %}abstract {% endif -%} {%- if member.isStatic %}static {% endif -%} {%- if (member.isGetAccessor or member.isReadonly) and not member.isSetAccessor %}get {% endif -%} {%- if member.isSetAccessor and not member.isGetAccessor %}set {% endif -%} {$ member.name $}{$ member.typeParameters | escape $}{% if not member.isGetAccessor %}{$ params.paramList(member.parameters, truncateLines) | trim $}{% endif %} {%- if member.isOptional %}?{% endif -%} {$ params.returnType(member.type) | trim | truncateCode(truncateLines) $} {%- endmacro -%} {%- macro renderOverloadInfo(overload, cssClass, method) -%} {$ renderMemberSyntax(overload) $} {% if overload.shortDescription and (overload.shortDescription != method.shortDescription) %}
{$ overload.shortDescription | marked $}
{% endif %}

Parameters

{$ params.renderParameters(overload.parameterDocs, cssClass + '-parameters', cssClass + '-parameter', true) $} {% if overload.type or overload.returns.type %}

Returns

{% marked %}`{$ (overload.type or overload.returns.type) $}`{% if overload.returns %}: {$ overload.returns.description $}{% endif %}{% endmarked %} {% endif %} {% if overload.throws.length %}

Throws

{% for error in overload.throws %} {% marked %}`{$ (error.typeList or 'Error') $}` {$ error.description $}{% endmarked %} {% endfor %} {% endif %} {% if overload.description and (overload.description != method.description) -%}
{$ overload.description | marked $}
{%- endif %} {%- endmacro -%} {%- macro renderMethodDetail(method, cssClass) -%} {% if method.name !== 'constructor' %}{% endif %} {% if method.shortDescription %}{% endif %} {% if method.overloads.length == 0 %} {% elseif method.overloads.length < 3 -%} {% for overload in method.overloads -%} {% endfor -%} {% else -%} {% endif %} {% if method.description %}{% endif %}

{% if method.isCallMember %}call signature {% elseif method.isNewMember %}construct signature {% else %}{$ method.name $}() {% endif %}

{$ method.shortDescription | marked $}
{$ renderOverloadInfo(method, cssClass + '-overload', method) $}
{$ renderOverloadInfo(overload, cssClass + '-overload', method) $}

{$ method.overloads.length $} overloads...

{% for overload in method.overloads %} {$ renderOverloadInfo(overload, cssClass + '-overload', method) $} {% if not loop.last %}
{% endif %} {% endfor %}
{$ method.description | marked $}
{% endmacro -%} {%- macro renderMethodDetails(methods, containerClass, itemClass, headingText) -%} {% set nonInternalMethods = methods | filterByPropertyValue('internal', undefined) %} {% if nonInternalMethods.length %}

{$ headingText $}

{% for member in nonInternalMethods %} {$ renderMethodDetail(member, itemClass) $} {% endfor %}
{% endif %} {%- endmacro -%} {%- macro renderProperties(properties, containerClass, propertyClass, headingText) -%} {% set nonInternalProperties = properties | filterByPropertyValue('internal', undefined) %} {% if nonInternalProperties.length -%}

{$ headingText $}

{% for property in nonInternalProperties %} {% endfor %}
PropertyTypeDescription
{$ property.name $} {%- if (property.isGetAccessor or property.isReadonly) and not property.isSetAccessor %}Read-only.{% endif %} {% if property.shortDescription %}{$ property.shortDescription | marked $}{% endif %} {$ (property.description or property.constructorParamDoc.description) | marked $} {% if property.constructorParamDoc %} Declared in constructor.{% endif %}
{%- endif -%} {%- endmacro -%} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/lib/paramList.html ================================================ {% macro paramList(params, truncateLines) -%} {%- if params -%} ({%- for param in params -%} {$ param | escape | truncateCode(truncateLines) $}{% if not loop.last %}, {% endif %} {%- endfor %}) {%- endif %} {%- endmacro -%} {% macro returnType(returnType) -%} {%- if returnType %}: {$ returnType | escape $}{% endif -%} {%- endmacro -%} {%- macro renderParameters(parameters, containerClass, parameterClass, showType) -%} {%- if parameters.length -%} {% for parameter in parameters %} {% if showType %}{% endif %} {% endfor %}
{$ parameter.name $} {$ parameter.type | escape $} {% marked %} {% if parameter.isOptional or parameter.defaultValue !== undefined %}Optional. Default is `{$ parameter.defaultValue === undefined and 'undefined' or parameter.defaultValue $}`.{% endif %} {% if parameter.description | trim %}{$ parameter.description $} {% elseif not showType and parameter.type %}

Type: {$ parameter.type | escape $}.

{% endif %} {% endmarked %}
{%- else -%}

There are no parameters.

{%- endif -%} {%- endmacro -%} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/module.template.html ================================================ {% extends 'base.template.html' -%} {% block body -%} {% include "includes/deprecation.html" %} {% include "includes/description.html" %}
    {% for export in doc.exports -%} {% if not export.duplicateOf %}
  • {$ export.name $}
  • {% endif %} {%- endfor %}
{%- endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/pipe.template.html ================================================ {% extends 'export-base.template.html' -%} {% block overview %} {% include "includes/pipe-overview.html" %} {% endblock %} {% block details %} {% include "includes/description.html" %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/type-alias.template.html ================================================ {% extends 'export-base.template.html' %} {% block overview %}
type {$ doc.name $}{$ doc.typeParameters | escape $}{% if doc.typeDefinition %} = {$ doc.typeDefinition | escape $}{% endif %};
{% endblock %} {% block details %} {% include "includes/description.html" %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/value-module.template.html ================================================ {% extends 'interface.template.html' %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/api/var.template.html ================================================ {% extends 'export-base.template.html' %} {% block overview %} const {$ doc.name $}: {$ (doc.type | escape) or 'any' $}; {% endblock %} {% block details %} {% include "includes/description.html" %} {% endblock %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/content.template.html ================================================ {% if doc.title %}{$ ('# ' + doc.title.trim()) | marked $}{% endif %}
{$ doc.description | marked $}
================================================ FILE: apps/rxjs.dev/tools/transforms/templates/data-module.template.js ================================================ /* tslint:disable quotemark */ /* TODO: rework this so that it has single quotes */ export const {$ doc.serviceName $} = {$ doc.value | json $}; ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/example-region.template.html ================================================ {$ doc.contents | escape $} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/json-doc.template.json ================================================ {$ doc.data | json $} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/overview-dump.template.html ================================================ {% import "api/lib/githubLinks.html" as github -%} {% import "api/lib/memberHelpers.html" as members -%} {% macro goToCode(doc) %}code{% endmacro %} {% macro label(test, class, text) %}{% if test %}{% endif %}{% endmacro %} {% macro renderLabels(doc) -%} {$ label(doc.notYetDocumented, 'no-doc', 'UNDOCUMENTED') $} {%- for tag in doc.tags.tags %}{$ label(tag.tagDef.deprecated, 'deprecated', '@' + tag.tagDef.name + ' deprecated') $}{% endfor %} {% endmacro %} {% macro renderMember(member) -%}
{$ goToCode(member) $}

{$ members.renderMemberSyntax(member, 1) $}

{$ renderLabels(member) $}
{% endmacro -%}

Documentation Status Report

{% for module in doc.modules %}

{$ module.id $}{%- if module.public %} (public){% endif %}

{% for export in module.exports %}
{$ goToCode(export) $}

{$ export.docType $} {$ export.name $}

{$ renderLabels(export) $}
{%- for member in export.staticProperties %}{% if not member.internal %} {$ renderMember(member) $}{% endif %}{% endfor -%} {% for member in export.staticMethods %}{% if not member.internal %} {$ renderMember(member) $}{% endif %}{% endfor -%} {% if export.constructorDoc and not export.constructorexport.internal %} {$ renderMember(export.constructorDoc) $}{% endif -%} {% for member in export.properties %}{% if not member.internal %} {$ renderMember(member) $}{% endif %}{% endfor -%} {% for member in export.methods %}{% if not member.internal %} {$ renderMember(member) $}{% endif %}{% endfor -%}
{% endfor %}
{% endfor %} ================================================ FILE: apps/rxjs.dev/tools/transforms/templates/sitemap.template.xml ================================================ {%- for url in doc.urls %} https://rxjs.dev/{$ url $} {% endfor %} ================================================ FILE: apps/rxjs.dev/tools/transforms/test.js ================================================ /* * Use this script to run the tests for the doc generation * We cannot use the Jasmine CLI directly because it doesn't seem to * understand the glob and only runs one spec file. * * Equally we cannot use a jasmine.json config file because it doesn't * allow us to set the projectBaseDir, which means that you have to run * jasmine CLI from this directory. * * Using a file like this gives us full control and keeps the package.json * file clean and simple. */ const Jasmine = require('jasmine'); const jasmine = new Jasmine({ projectBaseDir: __dirname }); jasmine.loadConfig({ spec_files: ['**/*.spec.{js,ts}'] }); jasmine.execute(); ================================================ FILE: apps/rxjs.dev/tsconfig.app.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "noPropertyAccessFromIndexSignature": false, "outDir": "./out-tsc/app", "types": [ "trusted-types" ], "plugins": [ { "name": "tsec", "exemptionConfig": "./security-exemptions.json" } ] }, "files": [ "src/main.ts", "src/polyfills.ts" ], "include": [ "src/**/*.d.ts" ] } ================================================ FILE: apps/rxjs.dev/tsconfig.docs.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs", "noUnusedParameters": false, "noUnusedLocals": false, "target": "es6" } } ================================================ FILE: apps/rxjs.dev/tsconfig.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "compileOnSave": false, "compilerOptions": { "baseUrl": "src", "outDir": "./out-tsc", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, // NOTE: Intentionally deviate from default Angular CLI settings // (due to many violations and uglier syntax). "noPropertyAccessFromIndexSignature": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "sourceMap": true, "declaration": false, "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, "target": "es2017", "module": "es2020", "lib": [ "es2020", "dom" ], "skipLibCheck": true, // disabled because this is on by default in tsc 2.7 breaking our codebase - we need to refactor "strictPropertyInitialization": false }, "exclude": [ "aio-builds-setup", "content", "dist", "node_modules", "out-tsc", "scripts" ], "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "disableTypeScriptVersionCheck": true, "strictInjectionParameters": true, "strictInputAccessModifiers": true, "strictTemplates": true } } ================================================ FILE: apps/rxjs.dev/tsconfig.spec.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "noUnusedParameters": false, "outDir": "./out-tsc/spec", "types": [ "jasmine", "node" ] }, "files": [ "src/test.ts", "src/polyfills.ts" ], "include": [ "src/testing/**/*.ts", "src/**/*.spec.ts", "src/**/*.d.ts" ] } ================================================ FILE: apps/rxjs.dev/tsconfig.worker.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/worker", "types": [ "lunr" ], "lib": [ "es2018", "webworker" ] }, "include": [ "src/**/*.worker.ts" ] } ================================================ FILE: nx.json ================================================ { "affected": { "defaultBase": "master" }, "nxCloudAccessToken": "OWE4MTMzMTEtNDZlZi00MWMwLWJkYmEtN2EwYTQ1ZWNjMzRkfHJlYWQ=", "targetDefaults": { "build": { "dependsOn": ["^build"], "inputs": ["production", "^production"], "cache": true }, "test:circular": { "dependsOn": ["build"] }, "test": { "cache": true }, "lint": { "inputs": ["default", "{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/tools/eslint-rules/**/*"], "cache": true } }, "namedInputs": { "default": ["{projectRoot}/**/*", "sharedGlobals"], "production": ["default", "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", "!{projectRoot}/.eslintrc.json"], "sharedGlobals": [ "{workspaceRoot}/github/workflows/*.yml", { "runtime": "node -e 'console.log(`${process.platform}-${process.arch}`)'" }, { "runtime": "node --version" }, { "runtime": "npm --version" }, { "runtime": "yarn --version" } ] }, "release": { "projects": ["packages/*"], "releaseTagPattern": "{version}", "changelog": { "workspaceChangelog": { "createRelease": "github", "file": false }, "projectChangelogs": true }, "version": { "generatorOptions": { "currentVersionResolver": "git-tag", "specifierSource": "conventional-commits" } } } } ================================================ FILE: package.json ================================================ { "private": true, "workspaces": { "packages": [ "packages/*", "apps/rxjs.dev" ], "nohoist": [ "**/@types/jasmine", "**/@types/jasminewd2", "**/@types/mocha", "**/core-js" ] }, "license": "Apache-2.0", "engines": { "node": "^18.13.0 || ^20.9.0" }, "packageManager": "yarn@1.22.21", "scripts": { "prepare-packages": "yarn nx test @rxjs/observable && yarn nx run-many -t build,lint,test:circular,dtslint,copy_common_package_files --exclude rxjs.dev", "release": "node scripts/release.js" }, "devDependencies": { "@nx/eslint-plugin": "17.3.2", "@nx/js": "17.3.2", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "cz-conventional-changelog": "1.2.0", "eslint": "^8.56.0", "husky": "^4.2.5", "lint-staged": "^10.2.11", "nx": "17.3.2", "ts-node": "^10.9.2", "tshy": "^1.11.0", "typescript": "~5.3.3", "validate-commit-msg": "2.14.0", "vitest": "^1.2.1" }, "husky": { "hooks": { "pre-commit": "lint-staged", "commit-msg": "validate-commit-msg" } }, "config": { "commitizen": { "path": "cz-conventional-changelog" } }, "lint-staged": { "*.js": "eslint --cache --fix", "(src|spec)/**/*.ts": [ "eslint --fix", "prettier --write" ], "*.{js,css,md}": "prettier --write" } } ================================================ FILE: packages/observable/.eslintrc.json ================================================ { "extends": ["../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "parserOptions": { "project": ["./packages/observable/tsconfig.json"] }, "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} }, { "files": ["./package.json"], "parser": "jsonc-eslint-parser", "rules": { "@nx/dependency-checks": ["error"] } } ] } ================================================ FILE: packages/observable/.tshy/browser.json ================================================ { "extends": "./build.json", "include": [ "../src/**/*.ts", "../src/**/*.mts", "../src/**/*.tsx" ], "exclude": [], "compilerOptions": { "outDir": "../.tshy-build/browser" } } ================================================ FILE: packages/observable/.tshy/build.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "rootDir": "../src", "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext" } } ================================================ FILE: packages/observable/.tshy/commonjs.json ================================================ { "extends": "./build.json", "include": [ "../src/**/*.ts", "../src/**/*.cts", "../src/**/*.tsx" ], "exclude": [ "../src/**/*.mts" ], "compilerOptions": { "outDir": "../.tshy-build/commonjs" } } ================================================ FILE: packages/observable/.tshy/esm.json ================================================ { "extends": "./build.json", "include": [ "../src/**/*.ts", "../src/**/*.mts", "../src/**/*.tsx" ], "exclude": [], "compilerOptions": { "outDir": "../.tshy-build/esm" } } ================================================ FILE: packages/observable/.tshy/webpack.json ================================================ { "extends": "./build.json", "include": [ "../src/**/*.ts", "../src/**/*.cts", "../src/**/*.tsx" ], "exclude": [ "../src/**/*.mts" ], "compilerOptions": { "outDir": "../.tshy-build/webpack" } } ================================================ FILE: packages/observable/package.json ================================================ { "name": "@rxjs/observable", "version": "8.0.0-alpha.14", "license": "Apache-2.0", "bugs": { "url": "https://github.com/ReactiveX/RxJS/issues" }, "homepage": "https://rxjs.dev", "author": "Ben Lesh ", "files": [ "dist" ], "main": "./dist/commonjs/index.js", "types": "./dist/commonjs/index.d.ts", "type": "module", "scripts": { "build": "tshy", "lint": "eslint ./src", "test": "vitest --run", "test:watch": "vitest" }, "repository": { "type": "git", "url": "https://github.com/ReactiveX/rxjs.git", "directory": "packages/observable" }, "keywords": [ "Rx", "RxJS", "ReactiveX", "ReactiveExtensions", "Streams", "Observables", "Observable", "Stream" ], "tshy": { "exports": { "./package.json": "./package.json", ".": "./src/index.ts" }, "esmDialects": [ "browser" ], "commonjsDialects": [ "webpack" ] }, "exports": { "./package.json": "./package.json", ".": { "browser": { "types": "./dist/browser/index.d.ts", "default": "./dist/browser/index.js" }, "webpack": { "types": "./dist/webpack/index.d.ts", "default": "./dist/webpack/index.js" }, "import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" }, "require": { "types": "./dist/commonjs/index.d.ts", "default": "./dist/commonjs/index.js" } } } } ================================================ FILE: packages/observable/src/index.ts ================================================ export { Observable, Subscriber, Subscription, UnsubscriptionError, config, from, isObservable, operate } from './observable.js'; export type { GlobalConfig, SubscriberOverrides } from './observable.js'; // TODO: reevaluate these as part of public API of @rxjs/observable? They aren't exported from rxjs so feel more internal? export { COMPLETE_NOTIFICATION, ObservableInputType, createNotification, errorNotification, fromArrayLike, getObservableInputType, isArrayLike, isFunction, isPromise, nextNotification, readableStreamLikeToAsyncGenerator, subscribeToArray, } from './observable.js'; ================================================ FILE: packages/observable/src/observable.spec.ts ================================================ import { describe, expect, it, vi } from 'vitest'; import { Observable, Subscription, config } from './observable.js'; function expectFullObserver(val: any) { expect(val).to.be.a('object'); expect(val.next).to.be.a('function'); expect(val.error).to.be.a('function'); expect(val.complete).to.be.a('function'); expect(val.closed).to.be.a('boolean'); } /** @test {Observable} */ describe('Observable', () => { it('should be constructed with a subscriber function', () => new Promise((done) => { const source = new Observable(function (observer) { expectFullObserver(observer); observer.next(1); observer.complete(); }); source.subscribe({ next: function (x) { expect(x).to.equal(1); }, complete: done, }); })); it('should send errors thrown in the constructor down the error path', () => new Promise((done) => { new Observable(() => { throw new Error('this should be handled'); }).subscribe({ error(err) { expect(err).to.exist.and.be.instanceof(Error).and.have.property('message', 'this should be handled'); done(); }, }); })); describe('forEach', () => { it('should handle a synchronous throw from the next handler', () => { const expected = new Error('I told, you Bobby Boucher, threes are the debil!'); const syncObservable = new Observable((observer) => { observer.next(1); observer.next(2); observer.next(3); observer.next(4); }); const results: Array = []; return syncObservable .forEach((x) => { results.push(x); if (x === 3) { throw expected; } }) .then( () => { throw new Error('should not be called'); }, (err) => { results.push(err); // The error should unsubscribe from the source, meaning we // should not see the number 4. expect(results).to.deep.equal([1, 2, 3, expected]); } ); }); it('should handle an asynchronous throw from the next handler and tear down', () => { const expected = new Error('I told, you Bobby Boucher, twos are the debil!'); const asyncObservable = new Observable((observer) => { let i = 1; const id = setInterval(() => observer.next(i++), 1); return () => { clearInterval(id); }; }); const results: Array = []; return asyncObservable .forEach((x) => { results.push(x); if (x === 2) { throw expected; } }) .then( () => { throw new Error('should not be called'); }, (err) => { results.push(err); expect(results).to.deep.equal([1, 2, expected]); } ); }); }); describe('subscribe', () => { it('should be synchronous', () => { let subscribed = false; let nexted: string; let completed: boolean; const source = new Observable((observer) => { subscribed = true; observer.next('wee'); expect(nexted).to.equal('wee'); observer.complete(); expect(completed).to.be.true; }); expect(subscribed).to.be.false; let mutatedByNext = false; let mutatedByComplete = false; source.subscribe({ next: (x) => { nexted = x; mutatedByNext = true; }, complete: () => { completed = true; mutatedByComplete = true; }, }); expect(mutatedByNext).to.be.true; expect(mutatedByComplete).to.be.true; }); it('should work when subscribe is called with no arguments', () => { const source = new Observable((subscriber) => { subscriber.next('foo'); subscriber.complete(); }); source.subscribe(); }); it('should run unsubscription logic when an error is sent asynchronously and subscribe is called with no arguments', () => new Promise((done, fail) => { const fakeTimer = vi.useFakeTimers(); let unsubscribeCalled = false; const source = new Observable((observer) => { const id = setInterval(() => { observer.error(0); }, 1); return () => { clearInterval(id); unsubscribeCalled = true; }; }); source.subscribe({ error() { /* noop: expected error */ }, }); setTimeout(() => { let err; let errHappened = false; try { expect(unsubscribeCalled).to.be.true; } catch (e) { err = e; errHappened = true; } finally { if (!errHappened) { done(); } else { fail(err); } } }, 100); fakeTimer.advanceTimersByTime(110); vi.useRealTimers(); })); it('should return a Subscription that calls the unsubscribe function returned by the subscriber', () => { let unsubscribeCalled = false; const source = new Observable(() => { return () => { unsubscribeCalled = true; }; }); const sub = source.subscribe(() => { //noop }); expect(sub instanceof Subscription).to.be.true; expect(unsubscribeCalled).to.be.false; expect(sub.unsubscribe).to.be.a('function'); sub.unsubscribe(); expect(unsubscribeCalled).to.be.true; }); it('should finalize even with a synchronous thrown error', () => { let called = false; const badObservable = new Observable((subscriber) => { subscriber.add(() => { called = true; }); throw new Error('bad'); }); badObservable.subscribe({ error: () => { /* do nothing */ }, }); expect(called).to.be.true; }); it('should handle empty string sync errors', () => { const badObservable = new Observable(() => { throw ''; }); let caught = false; badObservable.subscribe({ error: (err) => { caught = true; expect(err).to.equal(''); }, }); expect(caught).to.be.true; }); }); it('should emit an error for unhandled synchronous exceptions from something like a stack overflow', () => { const source = new Observable(() => { const boom = (): unknown => boom(); boom(); }); let thrownError: any = undefined; source.subscribe({ error: (err) => (thrownError = err), }); expect(thrownError).to.be.an.instanceOf(RangeError); expect(thrownError.message).to.equal('Maximum call stack size exceeded'); }); describe('pipe', () => { it('should not swallow internal errors', () => new Promise((done) => { config.onStoppedNotification = (notification) => { expect(notification.kind).to.equal('E'); expect(notification).to.have.property('error', 'bad'); config.onStoppedNotification = null; done(); }; new Observable((subscriber) => { subscriber.error('test'); throw 'bad'; }).subscribe({ error: (err) => { expect(err).to.equal('test'); }, }); })); }); describe('As an async iterable', () => { it('should be able to be used with for-await-of', async () => { const source = new Observable((subscriber) => { subscriber.next(1); subscriber.next(2); subscriber.next(3); subscriber.complete(); }); const results: number[] = []; for await (const value of source) { results.push(value); } expect(results).to.deep.equal([1, 2, 3]); }); it('should unsubscribe if the for-await-of loop is broken', async () => { let activeSubscriptions = 0; const source = new Observable((subscriber) => { activeSubscriptions++; subscriber.next(1); subscriber.next(2); // NOTE that we are NOT calling `subscriber.complete()` here. // therefore the teardown below would never be called naturally // by the observable unless it was unsubscribed. return () => { activeSubscriptions--; }; }); const results: number[] = []; for await (const value of source) { results.push(value); break; } expect(results).to.deep.equal([1]); expect(activeSubscriptions).to.equal(0); }); it('should unsubscribe if the for-await-of loop is broken with a thrown error', async () => { const source = new Observable((subscriber) => { subscriber.next(1); subscriber.next(2); subscriber.next(3); subscriber.complete(); }); const results: number[] = []; try { for await (const value of source) { results.push(value); throw new Error('wee'); } } catch { // Ignore } expect(results).to.deep.equal([1]); }); it('should cause the async iterator to throw if the observable errors', async () => { const source = new Observable((subscriber) => { subscriber.next(1); subscriber.next(2); subscriber.error(new Error('wee')); }); const results: number[] = []; let thrownError: any; try { for await (const value of source) { results.push(value); } } catch (err: any) { thrownError = err; } expect(thrownError?.message).to.equal('wee'); expect(results).to.deep.equal([1, 2]); }); it('should unsubscribe from the source observable if `return` is called on the generator returned by Symbol.asyncIterator', async () => { let state = 'idle'; const source = new Observable((subscriber) => { state = 'subscribed'; return () => { state = 'unsubscribed'; }; }); const asyncIterator = source[Symbol.asyncIterator](); expect(state).to.equal('idle'); asyncIterator.next(); expect(state).to.equal('subscribed'); asyncIterator.return(); expect(state).to.equal('unsubscribed'); }); it('should unsubscribe from the source observable if `throw` is called on the generator returned by Symbol.asyncIterator', async () => { let state = 'idle'; const source = new Observable((subscriber) => { state = 'subscribed'; subscriber.next(0); return () => { state = 'unsubscribed'; }; }); const asyncIterator = source[Symbol.asyncIterator](); expect(state).to.equal('idle'); await asyncIterator.next(); expect(state).to.equal('subscribed'); try { await asyncIterator.throw(new Error('wee!')); } catch (err: any) { expect(err.message).to.equal('wee!'); } expect(state).to.equal('unsubscribed'); }); }); }); ================================================ FILE: packages/observable/src/observable.ts ================================================ import type { TeardownLogic, UnaryFunction, Subscribable, Observer, OperatorFunction, Unsubscribable, SubscriptionLike, ObservableNotification, ObservableInput, ObservedValueOf, ReadableStreamLike, InteropObservable, CompleteNotification, ErrorNotification, NextNotification, } from './types.js'; /** * An error thrown when one or more errors have occurred during the * `unsubscribe` of a {@link Subscription}. */ export class UnsubscriptionError extends Error { /** * @deprecated Internal implementation detail. Do not construct error instances. * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269 */ constructor(public errors: any[]) { super( errors ? `${errors.length} errors occurred during unsubscription: ${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\n ')}` : '' ); this.name = 'UnsubscriptionError'; } } /** * Represents a disposable resource, such as the execution of an Observable. A * Subscription has one important method, `unsubscribe`, that takes no argument * and just disposes the resource held by the subscription. * * Additionally, subscriptions may be grouped together through the `add()` * method, which will attach a child Subscription to the current Subscription. * When a Subscription is unsubscribed, all its children (and its grandchildren) * will be unsubscribed as well. */ export class Subscription implements SubscriptionLike { public static EMPTY = (() => { const empty = new Subscription(); empty.closed = true; return empty; })(); /** * A flag to indicate whether this Subscription has already been unsubscribed. */ public closed = false; /** * The list of registered finalizers to execute upon unsubscription. Adding and removing from this * list occurs in the {@link #add} and {@link #remove} methods. */ private _finalizers: Set> | null = null; /** * @param initialTeardown A function executed first as part of the finalization * process that is kicked off when {@link #unsubscribe} is called. */ constructor(private initialTeardown?: () => void) {} /** * Disposes the resources held by the subscription. May, for instance, cancel * an ongoing Observable execution or cancel any other type of work that * started when the Subscription was created. */ unsubscribe(): void { let errors: any[] | undefined; if (!this.closed) { this.closed = true; const { initialTeardown: initialFinalizer } = this; if (isFunction(initialFinalizer)) { try { initialFinalizer(); } catch (e) { errors = e instanceof UnsubscriptionError ? e.errors : [e]; } } const { _finalizers } = this; if (_finalizers) { this._finalizers = null; for (const finalizer of _finalizers) { try { execFinalizer(finalizer); } catch (err) { errors = errors ?? []; if (err instanceof UnsubscriptionError) { errors.push(...err.errors); } else { errors.push(err); } } } } if (errors) { throw new UnsubscriptionError(errors); } } } /** * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called * when this subscription is unsubscribed. If this subscription is already {@link #closed}, * because it has already been unsubscribed, then whatever finalizer is passed to it * will automatically be executed (unless the finalizer itself is also a closed subscription). * * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed * subscription to a any subscription will result in no operation. (A noop). * * Adding a subscription to itself, or adding `null` or `undefined` will not perform any * operation at all. (A noop). * * `Subscription` instances that are added to this instance will automatically remove themselves * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove * will need to be removed manually with {@link #remove} * * @param teardown The finalization logic to add to this subscription. */ add(teardown: TeardownLogic): void { // Only add the finalizer if it's not undefined // and don't add a subscription to itself. if (teardown && teardown !== this) { if (this.closed) { // If this subscription is already closed, // execute whatever finalizer is handed to it automatically. execFinalizer(teardown); } else { if (teardown && 'add' in teardown) { // If teardown is a subscription, we can make sure that if it // unsubscribes first, it removes itself from this subscription. teardown.add(() => { this.remove(teardown); }); } this._finalizers ??= new Set(); this._finalizers.add(teardown); } } } /** * Removes a finalizer from this subscription that was previously added with the {@link #add} method. * * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves * from every other `Subscription` they have been added to. This means that using the `remove` method * is not a common thing and should be used thoughtfully. * * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance * more than once, you will need to call `remove` the same number of times to remove all instances. * * All finalizer instances are removed to free up memory upon unsubscription. * * TIP: In instances you're adding and removing _Subscriptions from other Subscriptions_, you should * be sure to unsubscribe or otherwise get rid of the child subscription reference as soon as you remove it. * The child subscription has a reference to the parent it was added to via closure. In most cases, this * a non-issue, as child subscriptions are rarely long-lived. * * @param teardown The finalizer to remove from this subscription */ remove(teardown: Exclude): void { this._finalizers?.delete(teardown); } } // Even though Subscription only conditionally implements `Symbol.dispose` // if it's available, we still need to declare it here so that TypeScript // knows that it exists on the prototype when it is available. export interface Subscription { [Symbol.dispose](): void; } if (typeof Symbol.dispose === 'symbol') { Subscription.prototype[Symbol.dispose] = Subscription.prototype.unsubscribe; } function execFinalizer(finalizer: Unsubscribable | (() => void)) { if (isFunction(finalizer)) { finalizer(); } else { finalizer.unsubscribe(); } } export interface SubscriberOverrides { /** * If provided, this function will be called whenever the {@link Subscriber}'s * `next` method is called, with the value that was passed to that call. If * an error is thrown within this function, it will be handled and passed to * the destination's `error` method. * @param value The value that is being observed from the source. */ next?: (value: T) => void; /** * If provided, this function will be called whenever the {@link Subscriber}'s * `error` method is called, with the error that was passed to that call. If * an error is thrown within this function, it will be handled and passed to * the destination's `error` method. * @param err An error that has been thrown by the source observable. */ error?: (err: any) => void; /** * If provided, this function will be called whenever the {@link Subscriber}'s * `complete` method is called. If an error is thrown within this function, it * will be handled and passed to the destination's `error` method. */ complete?: () => void; /** * If provided, this function will be called after all teardown has occurred * for this {@link Subscriber}. This is generally used for cleanup purposes * during operator development. */ finalize?: () => void; } /** * Implements the {@link Observer} interface and extends the * {@link Subscription} class. While the {@link Observer} is the public API for * consuming the values of an {@link Observable}, all Observers get converted to * a Subscriber, in order to provide Subscription-like capabilities such as * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for * implementing operators, but it is rarely used as a public API. */ export class Subscriber extends Subscription implements Observer { /** @internal */ protected isStopped: boolean = false; /** @internal */ protected destination: Observer; /** @internal */ protected readonly _nextOverride: ((value: T) => void) | null = null; /** @internal */ protected readonly _errorOverride: ((err: any) => void) | null = null; /** @internal */ protected readonly _completeOverride: (() => void) | null = null; /** @internal */ protected readonly _onFinalize: (() => void) | null = null; /** * @deprecated Do not create instances of `Subscriber` directly. Use {@link operate} instead. */ constructor(destination?: Subscriber | Partial> | ((value: T) => void) | null); /** * @internal */ constructor(destination: Subscriber | Partial> | ((value: any) => void) | null, overrides: SubscriberOverrides); /** * Creates an instance of an RxJS Subscriber. This is the workhorse of the library. * * If another instance of Subscriber is passed in, it will automatically wire up unsubscription * between this instance and the passed in instance. * * If a partial or full observer is passed in, it will be wrapped and appropriate safeguards will be applied. * * If a next-handler function is passed in, it will be wrapped and appropriate safeguards will be applied. * * @param destination A subscriber, partial observer, or function that receives the next value. * @deprecated Do not create instances of `Subscriber` directly. Use {@link operate} instead. */ constructor(destination?: Subscriber | Partial> | ((value: T) => void) | null, overrides?: SubscriberOverrides) { super(); // The only way we know that error reporting safety has been applied is if we own it. this.destination = destination instanceof Subscriber ? destination : createSafeObserver(destination); this._nextOverride = overrides?.next ?? null; this._errorOverride = overrides?.error ?? null; this._completeOverride = overrides?.complete ?? null; this._onFinalize = overrides?.finalize ?? null; // It's important - for performance reasons - that all of this class's // members are initialized and that they are always initialized in the same // order. This will ensure that all Subscriber instances have the // same hidden class in V8. This, in turn, will help keep the number of // hidden classes involved in property accesses within the base class as // low as possible. If the number of hidden classes involved exceeds four, // the property accesses will become megamorphic and performance penalties // will be incurred - i.e. inline caches won't be used. // // The reasons for ensuring all instances have the same hidden class are // further discussed in this blog post from Benedikt Meurer: // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/ this._next = this._nextOverride ? overrideNext : this._next; this._error = this._errorOverride ? overrideError : this._error; this._complete = this._completeOverride ? overrideComplete : this._complete; // Automatically chain subscriptions together here. // if destination appears to be one of our subscriptions, we'll chain it. if (hasAddAndUnsubscribe(destination)) { destination.add(this); } } /** * The {@link Observer} callback to receive notifications of type `next` from * the Observable, with a value. The Observable may call this method 0 or more * times. * @param value The `next` value. */ next(value: T): void { if (this.isStopped) { handleStoppedNotification(nextNotification(value), this); } else { this._next(value!); } } /** * The {@link Observer} callback to receive notifications of type `error` from * the Observable, with an attached `Error`. Notifies the Observer that * the Observable has experienced an error condition. * @param err The `error` exception. */ error(err?: any): void { if (this.isStopped) { handleStoppedNotification(errorNotification(err), this); } else { this.isStopped = true; this._error(err); } } /** * The {@link Observer} callback to receive a valueless notification of type * `complete` from the Observable. Notifies the Observer that the Observable * has finished sending push-based notifications. */ complete(): void { if (this.isStopped) { handleStoppedNotification(COMPLETE_NOTIFICATION, this); } else { this.isStopped = true; this._complete(); } } unsubscribe(): void { if (!this.closed) { this.isStopped = true; super.unsubscribe(); this._onFinalize?.(); } } protected _next(value: T): void { this.destination.next(value); } protected _error(err: any): void { try { this.destination.error(err); } finally { this.unsubscribe(); } } protected _complete(): void { try { this.destination.complete(); } finally { this.unsubscribe(); } } } /** * The {@link GlobalConfig} object for RxJS. It is used to configure things * like how to react on unhandled errors. */ export const config: GlobalConfig = { onUnhandledError: null, onStoppedNotification: null, }; /** * The global configuration object for RxJS, used to configure things * like how to react on unhandled errors. Accessible via {@link config} * object. */ export interface GlobalConfig { /** * A registration point for unhandled errors from RxJS. These are errors that * cannot were not handled by consuming code in the usual subscription path. For * example, if you have this configured, and you subscribe to an observable without * providing an error handler, errors from that subscription will end up here. This * will _always_ be called asynchronously on another job in the runtime. This is because * we do not want errors thrown in this user-configured handler to interfere with the * behavior of the library. */ onUnhandledError: ((err: any) => void) | null; /** * A registration point for notifications that cannot be sent to subscribers because they * have completed, errored or have been explicitly unsubscribed. By default, next, complete * and error notifications sent to stopped subscribers are noops. However, sometimes callers * might want a different behavior. For example, with sources that attempt to report errors * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead. * This will _always_ be called asynchronously on another job in the runtime. This is because * we do not want errors thrown in this user-configured handler to interfere with the * behavior of the library. */ onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null; } function overrideNext(this: Subscriber, value: T): void { try { this._nextOverride!(value); } catch (error) { this.destination.error(error); } } function overrideError(this: Subscriber, err: any): void { try { this._errorOverride!(err); } catch (error) { this.destination.error(error); } finally { this.unsubscribe(); } } function overrideComplete(this: Subscriber): void { try { this._completeOverride!(); } catch (error) { this.destination.error(error); } finally { this.unsubscribe(); } } class ConsumerObserver implements Observer { constructor(private partialObserver: Partial>) {} next(value: T): void { const { partialObserver } = this; if (partialObserver.next) { try { partialObserver.next(value); } catch (error) { reportUnhandledError(error); } } } error(err: any): void { const { partialObserver } = this; if (partialObserver.error) { try { partialObserver.error(err); } catch (error) { reportUnhandledError(error); } } else { reportUnhandledError(err); } } complete(): void { const { partialObserver } = this; if (partialObserver.complete) { try { partialObserver.complete(); } catch (error) { reportUnhandledError(error); } } } } function createSafeObserver(observerOrNext?: Partial> | ((value: T) => void) | null): Observer { return new ConsumerObserver(!observerOrNext || isFunction(observerOrNext) ? { next: observerOrNext ?? undefined } : observerOrNext); } /** * A handler for notifications that cannot be sent to a stopped subscriber. * @param notification The notification being sent. * @param subscriber The stopped subscriber. */ function handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) { const { onStoppedNotification } = config; onStoppedNotification && setTimeout(() => onStoppedNotification(notification, subscriber)); } function hasAddAndUnsubscribe(value: any): value is Subscription { return value && isFunction(value.unsubscribe) && isFunction(value.add); } export interface OperateConfig extends SubscriberOverrides { /** * The destination subscriber to forward notifications to. This is also the * subscriber that will receive unhandled errors if your `next`, `error`, or `complete` * overrides throw. */ destination: Subscriber; } /** * Creates a new {@link Subscriber} instance that passes notifications on to the * supplied `destination`. The overrides provided in the `config` argument for * `next`, `error`, and `complete` will be called in such a way that any * errors are caught and forwarded to the destination's `error` handler. The returned * `Subscriber` will be "chained" to the `destination` such that when `unsubscribe` is * called on the `destination`, the returned `Subscriber` will also be unsubscribed. * * Advanced: This ensures that subscriptions are properly wired up prior to starting the * subscription logic. This prevents "synchronous firehose" scenarios where an * inner observable from a flattening operation cannot be stopped by a downstream * terminal operator like `take`. * * This is a utility designed to be used to create new operators for observables. * * For examples, please see our code base. * * @param config The configuration for creating a new subscriber for an operator. * @returns A new subscriber that is chained to the destination. */ export function operate({ destination, ...subscriberOverrides }: OperateConfig) { return new Subscriber(destination, subscriberOverrides); } // Ensure that `Symbol.dispose` is defined in TypeScript declare global { interface SymbolConstructor { readonly dispose: unique symbol; } } /** * A representation of any set of values over any amount of time. This is the most basic building block * of RxJS. */ export class Observable implements Subscribable { /** * @param subscribe The function that is called when the Observable is * initially subscribed to. This function is given a Subscriber, to which new values * can be `next`ed, or an `error` method can be called to raise an error, or * `complete` can be called to notify of a successful completion. */ constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) { if (subscribe) { this._subscribe = subscribe; } } /** * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit. * * Use it when you have all these Observables, but still nothing is happening. * * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It * might be for example a function that you passed to Observable's constructor, but most of the time it is * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often * the thought. * * Apart from starting the execution of an Observable, this method allows you to listen for values * that an Observable emits, as well as for when it completes or errors. You can achieve this in two * of the following ways. * * The first way is creating an object that implements {@link Observer} interface. It should have methods * defined by that interface, but note that it should be just a regular JavaScript object, which you can create * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also * that your object does not have to implement all methods. If you find yourself creating a method that doesn't * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens, * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead, * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide * an `error` method to avoid missing thrown errors. * * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods. * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer, * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`, * since `subscribe` recognizes these functions by where they were placed in function call. When it comes * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously. * * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events * and you also handled emissions internally by using operators (e.g. using `tap`). * * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object. * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable. * * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously. * It is an Observable itself that decides when these functions will be called. For example {@link of} * by default emits all its values synchronously. Always check documentation for how given Observable * will behave when subscribed and if its default behavior can be modified with a `scheduler`. * * #### Examples * * Subscribe with an {@link guide/observer Observer} * * ```ts * import { of } from 'rxjs'; * * const sumObserver = { * sum: 0, * next(value) { * console.log('Adding: ' + value); * this.sum = this.sum + value; * }, * error() { * // We actually could just remove this method, * // since we do not really care about errors right now. * }, * complete() { * console.log('Sum equals: ' + this.sum); * } * }; * * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes. * .subscribe(sumObserver); * * // Logs: * // 'Adding: 1' * // 'Adding: 2' * // 'Adding: 3' * // 'Sum equals: 6' * ``` * * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated}) * * ```ts * import { of } from 'rxjs' * * let sum = 0; * * of(1, 2, 3).subscribe( * value => { * console.log('Adding: ' + value); * sum = sum + value; * }, * undefined, * () => console.log('Sum equals: ' + sum) * ); * * // Logs: * // 'Adding: 1' * // 'Adding: 2' * // 'Adding: 3' * // 'Sum equals: 6' * ``` * * Cancel a subscription * * ```ts * import { interval } from 'rxjs'; * * const subscription = interval(1000).subscribe({ * next(num) { * console.log(num) * }, * complete() { * // Will not be called, even when cancelling subscription. * console.log('completed!'); * } * }); * * setTimeout(() => { * subscription.unsubscribe(); * console.log('unsubscribed!'); * }, 2500); * * // Logs: * // 0 after 1s * // 1 after 2s * // 'unsubscribed!' after 2.5s * ``` * * @param observerOrNext Either an {@link Observer} with some or all callback methods, * or the `next` handler that is called for each value emitted from the subscribed Observable. * @return A subscription reference to the registered handlers. */ subscribe(observerOrNext?: Partial> | ((value: T) => void) | null): Subscription { const subscriber = observerOrNext instanceof Subscriber ? observerOrNext : new Subscriber(observerOrNext); subscriber.add(this._trySubscribe(subscriber)); return subscriber; } /** @internal */ protected _trySubscribe(sink: Subscriber): TeardownLogic { try { return this._subscribe(sink); } catch (err) { // We don't need to return anything in this case, // because it's just going to try to `add()` to a subscription // above. sink.error(err); } } /** * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with * APIs that expect promises, like `async/await`. You cannot unsubscribe from this. * * **WARNING**: Only use this with observables you *know* will complete. If the source * observable does not complete, you will end up with a promise that is hung up, and * potentially all of the state of an async function hanging out in memory. To avoid * this situation, look into adding something like {@link timeout}, {@link take}, * {@link takeWhile}, or {@link takeUntil} amongst others. * * #### Example * * ```ts * import { interval, take } from 'rxjs'; * * const source$ = interval(1000).pipe(take(4)); * * async function getTotal() { * let total = 0; * * await source$.forEach(value => { * total += value; * console.log('observable -> ' + value); * }); * * return total; * } * * getTotal().then( * total => console.log('Total: ' + total) * ); * * // Expected: * // 'observable -> 0' * // 'observable -> 1' * // 'observable -> 2' * // 'observable -> 3' * // 'Total: 6' * ``` * * @param next A handler for each value emitted by the observable. * @return A promise that either resolves on observable completion or * rejects with the handled error. */ forEach(next: (value: T) => void): Promise { return new Promise((resolve, reject) => { const subscriber = new Subscriber({ next: (value: T) => { try { next(value); } catch (err) { reject(err); subscriber.unsubscribe(); } }, error: reject, complete: resolve, }); this.subscribe(subscriber); }); } /** @internal */ protected _subscribe(_subscriber: Subscriber): TeardownLogic { return; } /** * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable * @return This instance of the observable. */ [Symbol.observable ?? '@@observable']() { return this; } pipe(): Observable; pipe(op1: UnaryFunction, A>): A; pipe(op1: UnaryFunction, A>, op2: UnaryFunction): B; pipe(op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction): C; pipe(op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction): D; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction ): E; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction ): F; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction, op7: UnaryFunction ): G; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction, op7: UnaryFunction, op8: UnaryFunction ): H; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction, op7: UnaryFunction, op8: UnaryFunction, op9: UnaryFunction ): I; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction, op7: UnaryFunction, op8: UnaryFunction, op9: UnaryFunction, ...operations: OperatorFunction[] ): Observable; pipe( op1: UnaryFunction, A>, op2: UnaryFunction, op3: UnaryFunction, op4: UnaryFunction, op5: UnaryFunction, op6: UnaryFunction, op7: UnaryFunction, op8: UnaryFunction, op9: UnaryFunction, ...operations: UnaryFunction[] ): unknown; /** * Used to stitch together functional operators into a chain. * * ## Example * * ```ts * import { interval, filter, map, scan } from 'rxjs'; * * interval(1000) * .pipe( * filter(x => x % 2 === 0), * map(x => x + x), * scan((acc, x) => acc + x) * ) * .subscribe(x => console.log(x)); * ``` * * @return The Observable result of all the operators having been called * in the order they were passed in. */ pipe(...operations: UnaryFunction[]): unknown { return operations.reduce(pipeReducer, this as any); } /** * Observable is async iterable, so it can be used in `for await` loop. This method * of subscription is cancellable by breaking the for await loop. Although it's not * recommended to use Observable's AsyncIterable contract outside of `for await`, if * you're consuming the Observable as an AsyncIterable, and you're _not_ using `for await`, * you can use the `throw` or `return` methods on the `AsyncGenerator` we return to * cancel the subscription. Note that the subscription to the observable does not start * until the first value is requested from the AsyncIterable. * * Functionally, this is equivalent to using a {@link concatMap} with an `async` function. * That means that while the body of the `for await` loop is executing, any values that arrive * from the observable source will be queued up, so they can be processed by the `for await` * loop in order. So, like {@link concatMap} it's important to understand the speed your * source emits at, and the speed of the body of your `for await` loop. * * ## Example * * ```ts * import { interval } from 'rxjs'; * * async function main() { * // Subscribe to the observable using for await. * for await (const value of interval(1000)) { * console.log(value); * * if (value > 5) { * // Unsubscribe from the interval if we get a value greater than 5 * break; * } * } * } * * main(); * ``` */ [Symbol.asyncIterator](): AsyncGenerator { let subscription: Subscription | undefined; let hasError = false; let error: unknown; let completed = false; const values: T[] = []; const deferreds: [(value: IteratorResult) => void, (reason: unknown) => void][] = []; const handleError = (err: unknown) => { hasError = true; error = err; while (deferreds.length) { const [_, reject] = deferreds.shift()!; reject(err); } }; const handleComplete = () => { completed = true; while (deferreds.length) { const [resolve] = deferreds.shift()!; resolve({ value: undefined, done: true }); } }; return { next: (): Promise> => { if (!subscription) { // We only want to start the subscription when the user starts iterating. subscription = this.subscribe({ next: (value) => { if (deferreds.length) { const [resolve] = deferreds.shift()!; resolve({ value, done: false }); } else { values.push(value); } }, error: handleError, complete: handleComplete, }); } // If we already have some values in our buffer, we'll return the next one. if (values.length) { return Promise.resolve({ value: values.shift()!, done: false }); } // This was already completed, so we're just going to return a done result. if (completed) { return Promise.resolve({ value: undefined, done: true }); } // There was an error, so we're going to return an error result. if (hasError) { return Promise.reject(error); } // Otherwise, we need to make them wait for a value. return new Promise((resolve, reject) => { deferreds.push([resolve, reject]); }); }, throw: (err): Promise> => { subscription?.unsubscribe(); // NOTE: I did some research on this, and as of Feb 2023, Chrome doesn't seem to do // anything with pending promises returned from `next()` when `throw()` is called. // However, for consumption of observables, I don't want RxJS taking the heat for that // quirk/leak of the type. So we're going to reject all pending promises we've nexted out here. handleError(err); return Promise.reject(err); }, return: (): Promise> => { subscription?.unsubscribe(); // NOTE: I did some research on this, and as of Feb 2023, Chrome doesn't seem to do // anything with pending promises returned from `next()` when `throw()` is called. // However, for consumption of observables, I don't want RxJS taking the heat for that // quirk/leak of the type. So we're going to resolve all pending promises we've nexted out here. handleComplete(); return Promise.resolve({ value: undefined, done: true }); }, [Symbol.asyncIterator]() { return this; }, }; } } function pipeReducer(prev: any, fn: UnaryFunction) { return fn(prev); } /** * Handles an error on another job either with the user-configured {@link onUnhandledError}, * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc. * * This should be called whenever there is an error that is out-of-band with the subscription * or when an error hits a terminal boundary of the subscription and no error handler was provided. * * @param err the error to report */ export function reportUnhandledError(err: any) { setTimeout(() => { const { onUnhandledError } = config; if (onUnhandledError) { // Execute the user-configured error handler. onUnhandledError(err); } else { // Throw so it is picked up by the runtime's uncaught error mechanism. throw err; } }); } /** * Creates an Observable from an Array, an array-like object, a Promise, an iterable object, or an Observable-like object. * * Converts almost anything to an Observable. * * ![](from.png) * * `from` converts various other objects and data types into Observables. It also converts a Promise, an array-like, or an * iterable * object into an Observable that emits the items in that promise, array, or iterable. A String, in this context, is treated * as an array of characters. Observable-like objects (contains a function named with the ES2015 Symbol for Observable) can also be * converted through this operator. * * ## Examples * * Converts an array to an Observable * * ```ts * import { from } from 'rxjs'; * * const array = [10, 20, 30]; * const result = from(array); * * result.subscribe(x => console.log(x)); * * // Logs: * // 10 * // 20 * // 30 * ``` * * Convert an infinite iterable (from a generator) to an Observable * * ```ts * import { from, take } from 'rxjs'; * * function* generateDoubles(seed) { * let i = seed; * while (true) { * yield i; * i = 2 * i; // double it * } * } * * const iterator = generateDoubles(3); * const result = from(iterator).pipe(take(10)); * * result.subscribe(x => console.log(x)); * * // Logs: * // 3 * // 6 * // 12 * // 24 * // 48 * // 96 * // 192 * // 384 * // 768 * // 1536 * ``` * * @see {@link fromEvent} * @see {@link fromEventPattern} * @see {@link scheduled} * * @param input A subscription object, a Promise, an Observable-like, * an Array, an iterable, async iterable, or an array-like object to be converted. */ export function from>(input: O): Observable>; export function from(input: ObservableInput): Observable { const type = getObservableInputType(input); switch (type) { case ObservableInputType.Own: return input as Observable; case ObservableInputType.InteropObservable: return fromInteropObservable(input); case ObservableInputType.ArrayLike: return fromArrayLike(input as ArrayLike); case ObservableInputType.Promise: return fromPromise(input as PromiseLike); case ObservableInputType.AsyncIterable: return fromAsyncIterable(input as AsyncIterable); case ObservableInputType.Iterable: return fromIterable(input as Iterable); case ObservableInputType.ReadableStreamLike: return fromReadableStreamLike(input as ReadableStreamLike); } } /** * Creates an RxJS Observable from an object that implements `Symbol.observable`. * @param obj An object that properly implements `Symbol.observable`. */ function fromInteropObservable(obj: any) { return new Observable((subscriber: Subscriber) => { const obs = obj[Symbol.observable ?? '@@observable'](); if (isFunction(obs.subscribe)) { return obs.subscribe(subscriber); } // Should be caught by observable subscribe function error handling. throw new TypeError('Provided object does not correctly implement Symbol.observable'); }); } /** * Synchronously emits the values of an array like and completes. * This is exported because there are creation functions and operators that need to * make direct use of the same logic, and there's no reason to make them run through * `from` conditionals because we *know* they're dealing with an array. * @param array The array to emit values from */ export function fromArrayLike(array: ArrayLike) { return new Observable((subscriber: Subscriber) => { subscribeToArray(array, subscriber); }); } export function fromPromise(promise: PromiseLike) { return new Observable((subscriber: Subscriber) => { promise .then( (value) => { if (!subscriber.closed) { subscriber.next(value); subscriber.complete(); } }, (err: any) => subscriber.error(err) ) .then(null, reportUnhandledError); }); } function fromIterable(iterable: Iterable) { return new Observable((subscriber: Subscriber) => { for (const value of iterable) { subscriber.next(value); if (subscriber.closed) { return; } } subscriber.complete(); }); } function fromAsyncIterable(asyncIterable: AsyncIterable) { return new Observable((subscriber: Subscriber) => { process(asyncIterable, subscriber).catch((err) => subscriber.error(err)); }); } function fromReadableStreamLike(readableStream: ReadableStreamLike) { return fromAsyncIterable(readableStreamLikeToAsyncGenerator(readableStream)); } async function process(asyncIterable: AsyncIterable, subscriber: Subscriber) { for await (const value of asyncIterable) { subscriber.next(value); // A side-effect may have closed our subscriber, // check before the next iteration. if (subscriber.closed) { return; } } subscriber.complete(); } /** * Subscribes to an ArrayLike with a subscriber * @param array The array or array-like to subscribe to * @param subscriber */ export function subscribeToArray(array: ArrayLike, subscriber: Subscriber) { // Loop over the array and emit each value. Note two things here: // 1. We're making sure that the subscriber is not closed on each loop. // This is so we don't continue looping over a very large array after // something like a `take`, `takeWhile`, or other synchronous unsubscription // has already unsubscribed. // 2. In this form, reentrant code can alter that array we're looping over. // This is a known issue, but considered an edge case. The alternative would // be to copy the array before executing the loop, but this has // performance implications. const length = array.length; for (let i = 0; i < length; i++) { if (subscriber.closed) { return; } // TODO(JamesHenry): discuss this added ! with Ben subscriber.next(array[i]!); } subscriber.complete(); } export enum ObservableInputType { Own, InteropObservable, ArrayLike, Promise, AsyncIterable, Iterable, ReadableStreamLike, } export function getObservableInputType(input: unknown): ObservableInputType { if (input instanceof Observable) { return ObservableInputType.Own; } if (isInteropObservable(input)) { return ObservableInputType.InteropObservable; } if (isArrayLike(input)) { return ObservableInputType.ArrayLike; } if (isPromise(input)) { return ObservableInputType.Promise; } if (isAsyncIterable(input)) { return ObservableInputType.AsyncIterable; } if (isIterable(input)) { return ObservableInputType.Iterable; } if (isReadableStreamLike(input)) { return ObservableInputType.ReadableStreamLike; } throw new TypeError( `You provided ${ input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'` } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.` ); } /** * Returns true if the object is a function. * @param value The value to check */ export function isFunction(value: any): value is (...args: any[]) => any { return typeof value === 'function'; } function isAsyncIterable(obj: any): obj is AsyncIterable { return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]); } export async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator { const reader = readableStream.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) { return; } yield value!; } } finally { reader.releaseLock(); } } function isReadableStreamLike(obj: any): obj is ReadableStreamLike { // We don't want to use instanceof checks because they would return // false for instances from another Realm, like an