├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .huskyrc.js ├── .lintstagedrc.js ├── .storybook ├── addons.ts ├── config.ts ├── global.scss ├── shim.d.ts ├── tsconfig.json └── webpack.config.ts ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── angular.json ├── deploy.sh ├── now.json ├── package.json ├── src ├── directives │ ├── async.directive.ts │ ├── directives.module.ts │ ├── public-api.ts │ └── var.directive.ts ├── modules │ ├── public-api.ts │ └── translate │ │ ├── README.md │ │ ├── constants.ts │ │ ├── helpers.ts │ │ ├── i18n.core.en.ts │ │ ├── i18n.core.zh.ts │ │ ├── public-api.ts │ │ ├── tokens.ts │ │ ├── translate.module.ts │ │ ├── translate.pipe.ts │ │ ├── translate.service.ts │ │ └── types.ts ├── ng-package.json ├── package.json ├── public-api.ts ├── tsconfig.lib.json ├── types │ ├── helpers.ts │ └── public-api.ts └── utils │ ├── README.md │ ├── constants.ts │ ├── decorators.md │ ├── decorators.ts │ ├── helpers.ts │ ├── operators.ts │ ├── public-api.ts │ └── tokens.ts ├── stories ├── .eslintrc ├── directives │ └── directives.stories.ts ├── modules │ ├── modules.stories.ts │ └── translate │ │ └── translate.component.ts ├── shared │ └── shared.module.ts ├── shim.d.ts └── utils │ ├── decorators │ ├── component.ts │ ├── module.ts │ └── template.html │ └── utils.stories.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@1stg/eslint-config/loose', 3 | settings: { 4 | node: { 5 | allowModules: ['@rxts/ngrx', 'lodash'], 6 | }, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "21 10 * * 4" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | storybook-static 4 | *.log 5 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@1stg/husky-config') 2 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@1stg/lint-staged') 2 | -------------------------------------------------------------------------------- /.storybook/addons.ts: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register' 2 | import '@storybook/addon-knobs/register' 3 | import '@storybook/addon-links/register' 4 | import '@storybook/addon-notes/register-panel' 5 | import '@storybook/addon-storysource/register' 6 | -------------------------------------------------------------------------------- /.storybook/config.ts: -------------------------------------------------------------------------------- 1 | import { setConsoleOptions } from '@storybook/addon-console' 2 | import { withKnobs } from '@storybook/addon-knobs' 3 | import { addDecorator, configure } from '@storybook/angular' 4 | 5 | addDecorator(withKnobs) 6 | 7 | setConsoleOptions({ 8 | panelExclude: [], 9 | }) 10 | 11 | function loadStories() { 12 | const req = require.context('../stories', true, /\.stories\.ts$/) 13 | req.keys().forEach(filename => req(filename)) 14 | } 15 | 16 | configure(loadStories, module) 17 | -------------------------------------------------------------------------------- /.storybook/global.scss: -------------------------------------------------------------------------------- 1 | @import '~sanitize.css'; 2 | -------------------------------------------------------------------------------- /.storybook/shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@storybook/addon-console' { 2 | export const setConsoleOptions: (options: { panelExclude: RegExp[] }) => void 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin' 2 | import { Configuration } from 'webpack' 3 | import webpackMerge from 'webpack-merge' 4 | 5 | export default ({ config }: { config: Configuration }) => 6 | webpackMerge(config, { 7 | resolve: { 8 | plugins: [new TsconfigPathsPlugin()], 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.stories\.tsx?$/, 14 | loader: '@storybook/source-loader', 15 | options: { 16 | parser: 'typescript', 17 | }, 18 | enforce: 'pre', 19 | }, 20 | ], 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: --lts 4 | 5 | cache: yarn 6 | 7 | before_install: 8 | - curl -o- -L https://yarnpkg.com/install.sh | bash 9 | - export PATH="$HOME/.yarn/bin:$PATH" 10 | - git config --global user.name 'JounQin' 11 | - git config --global user.email 'admin@1stg.me' 12 | 13 | script: 14 | - yarn lint 15 | - yarn build 16 | 17 | deploy: 18 | - provider: script 19 | skip_cleanup: true 20 | script: bash deploy.sh 21 | on: 22 | branch: master 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.4.2](https://github.com/rx-ts/ngrx/compare/v0.4.1...v0.4.2) (2019-09-12) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * usage in aot mode, more strict lint rules ([86ec714](https://github.com/rx-ts/ngrx/commit/86ec714)) 11 | 12 | ### [0.4.1](https://github.com/rx-ts/ngrx/compare/v0.4.0...v0.4.1) (2019-08-26) 13 | 14 | ## [0.4.0](https://github.com/rx-ts/ngrx/compare/v0.3.2...v0.4.0) (2019-08-25) 15 | 16 | 17 | ### Features 18 | 19 | * add TranslateModule, close [#19](https://github.com/rx-ts/ngrx/issues/19) ([b254663](https://github.com/rx-ts/ngrx/commit/b254663)) 20 | 21 | ### [0.3.2](https://github.com/rx-ts/ngrx/compare/v0.3.0...v0.3.2) (2019-08-09) 22 | 23 | ### Features 24 | 25 | - no need to wrap retryTimes into Observable ([edb8b1b](https://github.com/rx-ts/ngrx/commit/edb8b1b)) 26 | 27 | ### [0.3.1](https://github.com/rx-ts/ngrx/compare/v0.3.0...v0.3.1) (2019-07-21) 28 | 29 | ### Bug Fixes 30 | 31 | - ensure viewRef existence and detectChanges ([bfa92cd](https://github.com/rx-ts/ngrx/commit/bfa92cd)) 32 | 33 | ## [0.3.0](https://github.com/rx-ts/ngrx/compare/v0.2.0...v0.3.0) (2019-07-21) 34 | 35 | ### Bug Fixes 36 | 37 | - dispose last subscription automatically ([50bc4a1](https://github.com/rx-ts/ngrx/commit/50bc4a1)) 38 | - use finalize operator instead of complete handler ([573f9c8](https://github.com/rx-ts/ngrx/commit/573f9c8)) 39 | 40 | ### Features 41 | 42 | - add support of retry on error ([3380c1c](https://github.com/rx-ts/ngrx/commit/3380c1c)) 43 | 44 | ## [0.2.0](https://github.com/rx-ts/ngrx/compare/v0.1.1...v0.2.0) (2019-07-20) 45 | 46 | ### Features 47 | 48 | - add rxAsync directive ([dca6939](https://github.com/rx-ts/ngrx/commit/dca6939)) 49 | 50 | ### [0.1.1](https://github.com/rx-ts/ngrx/compare/v0.1.0...v0.1.1) (2019-07-18) 51 | 52 | ### Bug Fixes 53 | 54 | - improve build config and tree shaking ([ce97050](https://github.com/rx-ts/ngrx/commit/ce97050)) 55 | 56 | ## 0.1.0 (2019-07-18) 57 | 58 | ### Bug Fixes 59 | 60 | - storybook build error on CI ([182b825](https://github.com/rx-ts/ngrx/commit/182b825)) 61 | 62 | ### Features 63 | 64 | - add basic usage of ObservableInput, ValueHook and VarDirective([#3](https://github.com/rx-ts/ngrx/issues/3)) ([608603c](https://github.com/rx-ts/ngrx/commit/608603c)) 65 | - first blood, basic structure ([eb2322d](https://github.com/rx-ts/ngrx/commit/eb2322d)) 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 RxTS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @rxts/ngrx 2 | 3 | [![Travis](https://img.shields.io/travis/com/rx-ts/ngrx.svg)](https://travis-ci.com/rx-ts/ngrx) 4 | [![David](https://img.shields.io/david/rx-ts/ngrx.svg)](https://david-dm.org/rx-ts/ngrx) 5 | [![David Dev](https://img.shields.io/david/dev/rx-ts/ngrx.svg)](https://david-dm.org/rx-ts/ngrx?type=dev) 6 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 7 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | 9 | 🎉 Angular + RxJS + TypeScript = 🔥 10 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "defaultProject": "ngrx", 5 | "projects": { 6 | "ngrx": { 7 | "root": "src", 8 | "projectType": "library", 9 | "architect": { 10 | "build": { 11 | "builder": "@angular-devkit/build-ng-packagr:build", 12 | "options": { 13 | "tsConfig": "src/tsconfig.lib.json", 14 | "project": "src/ng-package.json" 15 | } 16 | } 17 | } 18 | }, 19 | "storybook": { 20 | "root": "stories", 21 | "projectType": "application", 22 | "architect": { 23 | "build": { 24 | "builder": "@angular-devkit/build-angular", 25 | "options": { 26 | "tsConfig": "tsconfig.json", 27 | "outputPath": "dist", 28 | "styles": [".storybook/global.scss"], 29 | "scripts": [] 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | "cli": { 36 | "warnings": { 37 | "typescriptMismatch": false 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | git remote set-url origin "https://user:$GH_TOKEN@github.com/$TRAVIS_REPO_SLUG.git" 6 | npm set //registry.npmjs.org/:_authToken "$NPM_TOKEN" 7 | 8 | git fetch origin "$TRAVIS_BRANCH":"$TRAVIS_BRANCH" 9 | git checkout "$TRAVIS_BRANCH" 10 | 11 | PKG_VERSION=$(jq -r '.version' src/package.json) 12 | 13 | git fetch origin v"$PKG_VERSION" || { 14 | yarn global add standard-version 15 | standard-version -a --release-as "$PKG_VERSION" 16 | tmp=$(mktemp) 17 | jq '.version = "0.0.0"' package.json > "$tmp" 18 | \mv -f "$tmp" package.json 19 | git commit --amend --no-edit 20 | git tag -a v"$PKG_VERSION" -m "chore(release): $PKG_VERSION" 21 | git push --follow-tags origin "$TRAVIS_BRANCH" 22 | npm publish dist --access=public 23 | } 24 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx", 3 | "version": 2, 4 | "github": { 5 | "silent": true 6 | }, 7 | "builds": [ 8 | { 9 | "src": "package.json", 10 | "use": "@now/static-build", 11 | "config": { 12 | "outputDirectory": "storybook-static" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx", 3 | "version": "0.4.2", 4 | "description": "🎉 Angular + RxJS + TypeScript = 🔥", 5 | "repository": "git@github.com:rx-ts/ngrx.git", 6 | "author": "JounQin ", 7 | "license": "MIT", 8 | "private": true, 9 | "workspaces": [ 10 | "src" 11 | ], 12 | "scripts": { 13 | "build": "ng build", 14 | "dev": "start-storybook -p 9001", 15 | "lint": "run-p lint:*", 16 | "lint:es": "eslint . --ext md,js,ts -f friendly", 17 | "lint:scss": "stylelint .storybook/**/*.scss", 18 | "lint:ts": "tslint -p . -t stylish", 19 | "now-build": "build-storybook", 20 | "postinstall": "yarn-deduplicate" 21 | }, 22 | "devDependencies": { 23 | "@1stg/app-config": "^0.6.1", 24 | "@1stg/lib-config": "^0.6.1", 25 | "@1stg/tslint-config": "^0.9.2", 26 | "@angular-devkit/build-angular": "^0.1102.19", 27 | "@angular-devkit/build-ng-packagr": "^0.1002.0", 28 | "@angular/cli": "^8.3.29", 29 | "@angular/common": "^8.2.14", 30 | "@angular/compiler": "^8.2.14", 31 | "@angular/compiler-cli": "^8.2.14", 32 | "@angular/core": "^8.2.14", 33 | "@angular/forms": "^8.2.14", 34 | "@angular/platform-browser": "^8.2.14", 35 | "@angular/platform-browser-dynamic": "^8.2.14", 36 | "@babel/core": "^7.24.5", 37 | "@commitlint/cli": "^8.3.6", 38 | "@storybook/addon-actions": "^5.3.21", 39 | "@storybook/addon-console": "^1.2.3", 40 | "@storybook/addon-knobs": "^5.3.21", 41 | "@storybook/addon-links": "^5.3.21", 42 | "@storybook/addon-notes": "^5.3.21", 43 | "@storybook/addon-storysource": "^5.3.21", 44 | "@storybook/angular": "^5.3.21", 45 | "@storybook/source-loader": "^5.3.21", 46 | "@types/lodash-es": "^4.17.12", 47 | "@types/node": "^13.13.52", 48 | "@types/storybook__addon-knobs": "^5.2.1", 49 | "@types/webpack": "^4.41.38", 50 | "@types/webpack-env": "^1.18.4", 51 | "@types/webpack-merge": "^4.1.5", 52 | "lodash-es": "^4.17.21", 53 | "ng-packagr": "^5.7.1", 54 | "npm-run-all2": "^5.0.2", 55 | "rxjs": "^6.6.7", 56 | "sanitize.css": "^11.0.1", 57 | "ts-node": "^8.10.2", 58 | "tsickle": "^0.46.3", 59 | "tslib": "^1.14.1", 60 | "tslint": "^6.1.3", 61 | "webpack-merge": "^4.2.2", 62 | "yarn-deduplicate": "^1.1.1", 63 | "zone.js": "^0.11.4" 64 | }, 65 | "resolutions": { 66 | "typescript": "~3.9.10" 67 | }, 68 | "browserslist": [ 69 | "extends @1stg/browserslist-config/modern" 70 | ], 71 | "commitlint": { 72 | "extends": [ 73 | "@1stg" 74 | ] 75 | }, 76 | "eslintIgnore": [ 77 | "!/.*.js", 78 | "dist", 79 | "storybook-static", 80 | "CHANGELOG.md" 81 | ], 82 | "prettier": "@1stg/prettier-config/angular", 83 | "remarkConfig": { 84 | "plugins": [ 85 | "@1stg/remark-config" 86 | ] 87 | }, 88 | "renovate": { 89 | "extends": [ 90 | "github>1stG/configs" 91 | ] 92 | }, 93 | "standard-version": { 94 | "skip": { 95 | "tag": true 96 | } 97 | }, 98 | "stylelint": { 99 | "extends": "@1stg/stylelint-config/scss" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/directives/async.directive.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http' 2 | import { 3 | Directive, 4 | Input, 5 | OnDestroy, 6 | OnInit, 7 | TemplateRef, 8 | ViewContainerRef, 9 | ViewRef, 10 | } from '@angular/core' 11 | import { Observable, Subject, Subscription, combineLatest } from 'rxjs' 12 | import { finalize, retry, startWith, takeUntil } from 'rxjs/operators' 13 | 14 | import { Callback, Nullable } from '../types/public-api' 15 | import { ObservableInput } from '../utils/public-api' 16 | 17 | export interface AsyncDirectiveContext { 18 | $implicit: T 19 | loading: boolean 20 | error: Nullable 21 | reload: Callback 22 | } 23 | 24 | @Directive({ 25 | selector: '[rxAsync]', 26 | }) 27 | export class AsyncDirective 28 | implements OnInit, OnDestroy { 29 | @ObservableInput() 30 | @Input('rxAsyncContext') 31 | private readonly context$!: Observable 32 | 33 | @ObservableInput() 34 | @Input('rxAsyncFetcher') 35 | private readonly fetcher$!: Observable>> 36 | 37 | @ObservableInput() 38 | @Input('rxAsyncParams') 39 | private readonly params$!: Observable

40 | 41 | @Input('rxAsyncRefetch') 42 | private readonly refetch$$ = new Subject() 43 | 44 | @Input('rxAsyncRetryTimes') 45 | private readonly retryTimes?: number 46 | 47 | private readonly destroy$$ = new Subject() 48 | private readonly reload$$ = new Subject() 49 | 50 | private readonly context = { 51 | reload: this.reload.bind(this), 52 | } as AsyncDirectiveContext 53 | 54 | private viewRef: Nullable 55 | private sub: Nullable 56 | 57 | constructor( 58 | private readonly templateRef: TemplateRef, 59 | private readonly viewContainerRef: ViewContainerRef, 60 | ) {} 61 | 62 | reload() { 63 | this.reload$$.next() 64 | } 65 | 66 | ngOnInit() { 67 | combineLatest([ 68 | this.context$, 69 | this.fetcher$, 70 | this.params$, 71 | this.refetch$$.pipe(startWith(null as void)), 72 | this.reload$$.pipe(startWith(null as void)), 73 | ]) 74 | .pipe(takeUntil(this.destroy$$)) 75 | .subscribe(([context, fetcher, params]) => { 76 | this.disposeSub() 77 | 78 | Object.assign(this.context, { 79 | loading: true, 80 | error: null, 81 | }) 82 | 83 | this.sub = fetcher 84 | .call(context, params) 85 | .pipe( 86 | retry(this.retryTimes), 87 | finalize(() => { 88 | this.context.loading = false 89 | if (this.viewRef) { 90 | this.viewRef.detectChanges() 91 | } 92 | }), 93 | ) 94 | .subscribe( 95 | data => (this.context.$implicit = data), 96 | error => (this.context.error = error), 97 | ) 98 | 99 | if (this.viewRef) { 100 | return this.viewRef.markForCheck() 101 | } 102 | 103 | this.viewRef = this.viewContainerRef.createEmbeddedView( 104 | this.templateRef, 105 | this.context, 106 | ) 107 | }) 108 | } 109 | 110 | ngOnDestroy() { 111 | this.disposeSub() 112 | 113 | this.destroy$$.next() 114 | this.destroy$$.complete() 115 | 116 | if (this.viewRef) { 117 | this.viewRef.destroy() 118 | this.viewRef = null 119 | } 120 | } 121 | 122 | disposeSub() { 123 | if (this.sub) { 124 | this.sub.unsubscribe() 125 | this.sub = null 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/directives/directives.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { NgModule } from '@angular/core' 3 | 4 | import { AsyncDirective } from './async.directive' 5 | import { VarDirective } from './var.directive' 6 | 7 | const EXPORTABLE_DIRECTIVES = [AsyncDirective, VarDirective] 8 | 9 | @NgModule({ 10 | imports: [CommonModule], 11 | declarations: EXPORTABLE_DIRECTIVES, 12 | exports: EXPORTABLE_DIRECTIVES, 13 | }) 14 | export class RxDirectivesModule {} 15 | -------------------------------------------------------------------------------- /src/directives/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './async.directive' 2 | export * from './directives.module' 3 | export * from './var.directive' 4 | -------------------------------------------------------------------------------- /src/directives/var.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | Input, 4 | OnDestroy, 5 | OnInit, 6 | TemplateRef, 7 | ViewContainerRef, 8 | ViewRef, 9 | } from '@angular/core' 10 | import { Observable, Subject } from 'rxjs' 11 | import { skipWhile, switchMap, takeUntil } from 'rxjs/operators' 12 | 13 | import { ObservableInput } from '../utils/public-api' 14 | 15 | export type VarDirectiveContext = T & { 16 | $implicit: T 17 | } 18 | 19 | @Directive({ 20 | selector: '[rxVar]', 21 | }) 22 | export class VarDirective implements OnInit, OnDestroy { 23 | @ObservableInput() 24 | @Input('rxVar') 25 | private readonly rxVar$!: Observable 26 | 27 | @ObservableInput(false, true) 28 | @Input('rxVarNullable') 29 | private readonly rxVarNullable$!: Observable 30 | 31 | private readonly destroy$$ = new Subject() 32 | private readonly context = {} as VarDirectiveContext 33 | private viewRef?: ViewRef 34 | 35 | constructor( 36 | private readonly templateRef: TemplateRef, 37 | private readonly viewContainerRef: ViewContainerRef, 38 | ) {} 39 | 40 | ngOnInit() { 41 | this.rxVarNullable$ 42 | .pipe( 43 | takeUntil(this.destroy$$), 44 | switchMap(nullable => 45 | nullable ? this.rxVar$ : this.rxVar$.pipe(skipWhile(_ => _ == null)), 46 | ), 47 | ) 48 | .subscribe(variable => { 49 | this.context.$implicit = variable 50 | 51 | if (variable != null && typeof variable === 'object') { 52 | Object.assign(this.context, variable) 53 | } 54 | 55 | if (this.viewRef) { 56 | return this.viewRef.markForCheck() 57 | } 58 | 59 | this.viewRef = this.viewContainerRef.createEmbeddedView( 60 | this.templateRef, 61 | this.context, 62 | ) 63 | }) 64 | } 65 | 66 | ngOnDestroy() { 67 | this.destroy$$.next() 68 | this.destroy$$.complete() 69 | if (this.viewRef) { 70 | this.viewRef.destroy() 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/modules/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './translate/public-api' 2 | -------------------------------------------------------------------------------- /src/modules/translate/README.md: -------------------------------------------------------------------------------- 1 | # 翻译模块 2 | 3 | ## 基本类型 4 | 5 | ```ts 6 | export interface Translation { 7 | [key: string]: Arrayable 8 | } 9 | 10 | export type Translations = Partial> 11 | 12 | export type TranslateKey = string | Partial> 13 | 14 | export interface TranslateOptions { 15 | locale?: Locale // 当前区域,默认从 localStorage 或浏览器默认设置中获取 16 | defaultLocale?: Locale // 解析翻译时如果当前区域未找到,继续回退尝试的区域 17 | locales?: Locale[] // 可用区域列表,调用 `toggleLocale` 时自动滚动循环设置区域 18 | translations?: Translations // 初始翻译包 19 | loose?: boolean // 是否启用宽松模式,如果当前区域找不到翻译时回退尝试国家区域,即 `zh-* -> zh`, `en-* -> en` 20 | remoteTranslations?: Translations // 提前加载的远程翻译包,解析翻译时优先级高于 translations 21 | remoteUrl?: string // 远程翻译包解析链接,如果是绝对链接将直接请求,否则使用 baseHref 拼接后请求,优先级高于 remoteTranslations,如果未提供 remoteTranslations 将默认尝试从 DEFAULT_REMOTE_URL 获取,否则默认不从远程获取 22 | } 23 | ``` 24 | 25 | ## `TranslateService` 26 | 27 | - `get(key: TranslateKey, data?: unknown, ignoreNonExist?: boolean)`: 28 | 根据翻译 key 和上下文数据 data 获取翻译内容,翻译项不存在直接返回 key 文本,ignoreNonExist 开发环境是否忽视不存在的翻译项 29 | - `toggleLocale()`: 根据 `locales` 循环切换当前区域设置 30 | - `fetchTranslation(remoteUrl: string, locale?: string)`: 从远程 url 模板和区域获取翻译包 31 | - `Observable` 属性列表: 32 | - `locale$` 33 | - `defaultLocale$` 34 | - `remoteLoaded$` 35 | - 响应式属性列表: 36 | - `locale` 37 | - `defaultLocale` 38 | - 只读属性: `remoteLoaded` 39 | 40 | ## `TranslatePipe` 41 | 42 | 自动响应 `TranslateService#locale` 和 `TranslateService#defaultLocale` 的变化,支持静态语言包和服务端返回的动态语言对象 43 | 44 | ```html 45 | {{ 'hello' | translate:'world' }} 46 | 47 | {{ { en: 'Hello World!', zh: '你好世界!' } | translate }} 48 | 49 | ``` 50 | -------------------------------------------------------------------------------- /src/modules/translate/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_REMOTE_URL = 'static/i18n.{locale}.json' 2 | 3 | export const LOCALE_STORAGE = '__LOCALE__' 4 | 5 | export const LOCALE_PLACEHOLDER_REGEX = /{+\s*locale\s*}+/ 6 | 7 | export enum Locale { 8 | ZH = 'zh', 9 | EN = 'en', 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/translate/helpers.ts: -------------------------------------------------------------------------------- 1 | import { head } from 'lodash' 2 | 3 | import { Nullable } from '../../types/public-api' 4 | 5 | import { LOCALE_STORAGE } from './constants' 6 | 7 | declare global { 8 | interface NavigatorLanguage { 9 | browserLanguage?: string 10 | userLanguage?: string 11 | } 12 | } 13 | 14 | export const getBrowserLang = () => 15 | head(navigator.languages) || 16 | navigator.language || 17 | navigator.browserLanguage || 18 | navigator.userLanguage 19 | 20 | export const getLang = ( 21 | LOCALES: Nullable, 22 | ): T | undefined => { 23 | const lang = (localStorage.getItem(LOCALE_STORAGE) || getBrowserLang()) as T 24 | return !LOCALES || LOCALES.length === 0 || LOCALES.includes(lang) 25 | ? lang 26 | : LOCALES[0] 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/translate/i18n.core.en.ts: -------------------------------------------------------------------------------- 1 | import { Translation } from './types' 2 | 3 | export const en: Translation = { 4 | language: 'English', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/translate/i18n.core.zh.ts: -------------------------------------------------------------------------------- 1 | import { Translation } from './types' 2 | 3 | export const zh: Translation = { 4 | language: '中文', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/translate/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './helpers' 3 | export * from './tokens' 4 | export * from './translate.module' 5 | export * from './translate.pipe' 6 | export * from './translate.service' 7 | export * from './types' 8 | -------------------------------------------------------------------------------- /src/modules/translate/tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core' 2 | 3 | import { Locale } from './constants' 4 | import { Translations } from './types' 5 | 6 | export const TOKEN_LOCALE = new InjectionToken( 7 | 'initialize and runtime locale', 8 | ) 9 | 10 | export const TOKEN_DEFAULT_LOCALE = new InjectionToken( 11 | 'fallback locale if translation for current locale found', 12 | ) 13 | 14 | export const TOKEN_LOCALES = new InjectionToken( 15 | 'available locale list', 16 | ) 17 | 18 | export const TOKEN_TRANSLATIONS = new InjectionToken( 19 | 'multiple custom translations list, frozen, the later the higher privilege', 20 | ) 21 | 22 | export const TOKEN_REMOTE_TRANSLATIONS = new InjectionToken( 23 | 'custom loaded remote translations with highest privilege as `TOKEN_REMOTE_URL`', 24 | ) 25 | 26 | export const TOKEN_LOOSE = new InjectionToken( 27 | 'whether to use loose mode which represents treat `locale-*` as `locale`', 28 | ) 29 | 30 | export const TOKEN_REMOTE_URL = new InjectionToken( 31 | 'extra remote i18n translations url with highest privilege', 32 | ) 33 | -------------------------------------------------------------------------------- /src/modules/translate/translate.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { ModuleWithProviders, NgModule } from '@angular/core' 3 | 4 | import { Locale } from './constants' 5 | import { getBrowserLang, getLang } from './helpers' 6 | import { en } from './i18n.core.en' 7 | import { zh } from './i18n.core.zh' 8 | import { 9 | TOKEN_DEFAULT_LOCALE, 10 | TOKEN_LOCALE, 11 | TOKEN_LOCALES, 12 | TOKEN_LOOSE, 13 | TOKEN_REMOTE_TRANSLATIONS, 14 | TOKEN_REMOTE_URL, 15 | TOKEN_TRANSLATIONS, 16 | } from './tokens' 17 | import { TranslatePipe } from './translate.pipe' 18 | import { TranslateService } from './translate.service' 19 | import { TranslateOptions, Translations } from './types' 20 | 21 | const EXPORTABLE = [TranslatePipe] 22 | 23 | export const DEFAULT_LOCALES = Object.values(Locale) 24 | export const DEFAULT_LOCALE = getLang(DEFAULT_LOCALES) 25 | export const FALLBACK_LOCALE = getBrowserLang() 26 | export const CORE_TRANSLATIONS = Object.freeze({ zh, en }) 27 | 28 | @NgModule({ 29 | imports: [CommonModule], 30 | declarations: EXPORTABLE, 31 | exports: EXPORTABLE, 32 | }) 33 | export class TranslateModule { 34 | static forRoot(options: TranslateOptions = {}): ModuleWithProviders { 35 | return { 36 | ngModule: TranslateModule, 37 | providers: [ 38 | { 39 | provide: TOKEN_LOCALES, 40 | useValue: options.locales || DEFAULT_LOCALES, 41 | }, 42 | { 43 | provide: TOKEN_LOCALE, 44 | useValue: options.locale || DEFAULT_LOCALE, 45 | }, 46 | { 47 | provide: TOKEN_DEFAULT_LOCALE, 48 | useValue: options.defaultLocale || FALLBACK_LOCALE, 49 | }, 50 | { 51 | provide: TOKEN_TRANSLATIONS, 52 | useValue: CORE_TRANSLATIONS, 53 | multi: true, 54 | }, 55 | { 56 | provide: TOKEN_LOOSE, 57 | useValue: options.loose, 58 | }, 59 | { 60 | provide: TOKEN_REMOTE_TRANSLATIONS, 61 | useValue: options.remoteTranslations, 62 | multi: true, 63 | }, 64 | { 65 | provide: TOKEN_TRANSLATIONS, 66 | useValue: options.translations, 67 | multi: true, 68 | }, 69 | { 70 | provide: TOKEN_REMOTE_URL, 71 | useValue: options.remoteUrl, 72 | }, 73 | TranslateService, 74 | ], 75 | } 76 | } 77 | 78 | static forChild(translations: Translations): ModuleWithProviders { 79 | return { 80 | ngModule: TranslateModule, 81 | providers: [ 82 | { 83 | provide: TOKEN_TRANSLATIONS, 84 | useValue: Object.freeze(translations), 85 | multi: true, 86 | }, 87 | ], 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/modules/translate/translate.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | OnDestroy, 4 | Pipe, 5 | PipeTransform, 6 | } from '@angular/core' 7 | import { isEqual } from 'lodash' 8 | import { Subject, Subscription, merge } from 'rxjs' 9 | import { filter, first, takeUntil } from 'rxjs/operators' 10 | 11 | import { Nullable } from '../../types/public-api' 12 | 13 | import { TranslateService } from './translate.service' 14 | import { TranslateKey } from './types' 15 | 16 | @Pipe({ 17 | name: 'translate', 18 | pure: false, 19 | }) 20 | export class TranslatePipe implements PipeTransform, OnDestroy { 21 | private value!: string 22 | private lastKey?: Nullable 23 | private lastData?: unknown 24 | private lastRemoteLoaded? = this.translate.remoteLoaded 25 | private onChange?: Nullable 26 | 27 | private readonly destroy$$ = new Subject() 28 | 29 | constructor( 30 | private readonly cdr: ChangeDetectorRef, 31 | private readonly translate: TranslateService, 32 | ) { 33 | this.translate.remoteLoaded$ 34 | .pipe( 35 | filter(_ => !!_), 36 | first(), 37 | takeUntil(this.destroy$$), 38 | ) 39 | .subscribe(() => this.cdr.markForCheck()) 40 | } 41 | 42 | transform(key: TranslateKey, data?: unknown, ignoreNonExist = false) { 43 | const { remoteLoaded } = this.translate 44 | const isLoading = remoteLoaded === false 45 | if ( 46 | isEqual(key, this.lastKey) && 47 | isEqual(data, this.lastData) && 48 | (isEqual(remoteLoaded, this.lastRemoteLoaded) || !remoteLoaded) 49 | ) { 50 | return this.value 51 | } 52 | 53 | this.lastData = data 54 | this.lastRemoteLoaded = remoteLoaded 55 | this.updateValue(key, data, ignoreNonExist, isLoading) 56 | this.dispose() 57 | 58 | this.onChange = merge(this.translate.locale$, this.translate.defaultLocale$) 59 | .pipe(takeUntil(this.destroy$$)) 60 | .subscribe(() => { 61 | if (this.lastKey) { 62 | this.lastKey = null 63 | this.updateValue(key, data, ignoreNonExist, isLoading) 64 | } 65 | }) 66 | 67 | return this.value 68 | } 69 | 70 | ngOnDestroy() { 71 | this.destroy$$.next() 72 | this.destroy$$.complete() 73 | } 74 | 75 | private updateValue( 76 | key: TranslateKey, 77 | data?: unknown, 78 | ignoreNonExist = false, 79 | isLoading = false, 80 | ) { 81 | const value = this.translate.get(key, data, ignoreNonExist || isLoading) 82 | // avoid text slashing on remote loading 83 | if (!isLoading || key !== value) { 84 | this.value = value 85 | this.lastKey = key 86 | this.cdr.markForCheck() 87 | } 88 | } 89 | 90 | private dispose() { 91 | if (this.onChange) { 92 | this.onChange.unsubscribe() 93 | this.onChange = null 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/modules/translate/translate.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-magic-numbers */ 2 | import { Inject, Injectable, OnDestroy, isDevMode } from '@angular/core' 3 | import { get, head, isPlainObject, template } from 'lodash' 4 | import { EMPTY, Observable, Subject, forkJoin, throwError } from 'rxjs' 5 | import { ajax } from 'rxjs/ajax' 6 | import { catchError, filter, finalize, map, takeUntil } from 'rxjs/operators' 7 | 8 | import { Nullable } from '../../types/public-api' 9 | import { 10 | ObservableInput, 11 | TEMPLATE_OPTIONS, 12 | TOKEN_BASE_HREF, 13 | isAbsoluteUrl, 14 | } from '../../utils/public-api' 15 | 16 | import { LOCALE_PLACEHOLDER_REGEX, LOCALE_STORAGE, Locale } from './constants' 17 | import { 18 | TOKEN_DEFAULT_LOCALE, 19 | TOKEN_LOCALE, 20 | TOKEN_LOCALES, 21 | TOKEN_LOOSE, 22 | TOKEN_REMOTE_TRANSLATIONS, 23 | TOKEN_REMOTE_URL, 24 | TOKEN_TRANSLATIONS, 25 | } from './tokens' 26 | import { TranslateKey, Translation, Translations } from './types' 27 | 28 | @Injectable() 29 | export class TranslateService implements OnDestroy { 30 | get remoteLoaded() { 31 | return this._remoteLoaded 32 | } 33 | 34 | @ObservableInput(true) 35 | readonly locale$!: Observable 36 | 37 | @ObservableInput(true) 38 | readonly defaultLocale$!: Observable 39 | 40 | @ObservableInput('_remoteLoaded') 41 | readonly remoteLoaded$!: Observable 42 | 43 | private _remoteLoaded?: boolean 44 | 45 | private readonly destroy$$ = new Subject() 46 | 47 | constructor( 48 | @Inject(TOKEN_LOCALES) 49 | private readonly locales: Locale[], 50 | @Inject(TOKEN_LOCALE) 51 | public locale: Locale, 52 | @Inject(TOKEN_DEFAULT_LOCALE) 53 | public defaultLocale: Locale, 54 | @Inject(TOKEN_TRANSLATIONS) 55 | private readonly translationsList: Nullable>>, 56 | @Inject(TOKEN_LOOSE) 57 | private readonly loose: boolean, 58 | @Inject(TOKEN_BASE_HREF) 59 | private readonly baseHref: string, 60 | @Inject(TOKEN_REMOTE_TRANSLATIONS) 61 | private remoteTranslationsList: Nullable>>, 62 | @Inject(TOKEN_REMOTE_URL) 63 | private readonly remoteUrl?: string, 64 | ) { 65 | this._watchLocale() 66 | this._fetchTranslations() 67 | } 68 | 69 | ngOnDestroy() { 70 | this.destroy$$.next() 71 | this.destroy$$.complete() 72 | } 73 | 74 | /** 75 | * 根据翻译 @param key 和上下文数据 @param data 获取翻译内容,翻译项不存在直接返回 key 文本 76 | * @param ignoreNonExist 开发环境是否忽视不存在的翻译项 77 | */ 78 | get(key: TranslateKey, data?: unknown, ignoreNonExist = false) { 79 | const translation = this._get( 80 | typeof key === 'string' ? key : this._getValue(key), 81 | typeof key !== 'string' || ignoreNonExist, 82 | ) 83 | if (data != null && typeof data !== 'object') { 84 | data = [data] 85 | } 86 | return template(translation, TEMPLATE_OPTIONS)(data as object) 87 | } 88 | 89 | /** 90 | * 根据 `locales` 循环切换当前区域设置 91 | */ 92 | toggleLocale() { 93 | const index = this.locales.indexOf(this.locale) 94 | if (index === -1) { 95 | if (isDevMode()) { 96 | throw new TypeError('`locales` has not been initialized correctly') 97 | } 98 | return 99 | } 100 | const nextLocale = this.locales[ 101 | index === this.locales.length - 1 ? 0 : index + 1 102 | ] 103 | 104 | if (!nextLocale || this.locale === nextLocale) { 105 | return 106 | } 107 | 108 | this.locale = nextLocale 109 | } 110 | 111 | /** 112 | * 从远程 url 模板和区域获取翻译包 113 | */ 114 | fetchTranslation(remoteUrl: string): Observable 115 | fetchTranslation(remoteUrl: string, locale: string): Observable 116 | fetchTranslation(remoteUrl: string, locale?: string) { 117 | if (isDevMode() && LOCALE_PLACEHOLDER_REGEX.exec(remoteUrl) && !locale) { 118 | throw new TypeError( 119 | '`locale` is required sine the provided remote url contains locale placeholder', 120 | ) 121 | } 122 | return ajax.getJSON( 123 | locale ? remoteUrl.replace(LOCALE_PLACEHOLDER_REGEX, locale) : remoteUrl, 124 | ) 125 | } 126 | 127 | private _watchLocale() { 128 | this.locale$ 129 | .pipe(takeUntil(this.destroy$$)) 130 | .subscribe(locale => localStorage.setItem(LOCALE_STORAGE, locale)) 131 | } 132 | 133 | // eslint-disable-next-line sonarjs/cognitive-complexity 134 | private _fetchTranslations() { 135 | const { baseHref } = this 136 | let { remoteUrl } = this 137 | 138 | if (!remoteUrl) { 139 | return 140 | } 141 | 142 | this._remoteLoaded = false 143 | remoteUrl = head(remoteUrl.split(/#/)) 144 | const isAbsolute = isAbsoluteUrl(remoteUrl) 145 | if (isDevMode()) { 146 | let errorMessage 147 | if (!isAbsolute && (!baseHref || !isAbsoluteUrl(baseHref))) { 148 | errorMessage = 'absolute base href is required for relative remote url' 149 | } else if (remoteUrl.split('?')[0].includes('./')) { 150 | errorMessage = 151 | 'do not use any dot with slash for relative url which should always base from base href' 152 | } 153 | if (errorMessage) { 154 | throw new TypeError(errorMessage) 155 | } 156 | } 157 | 158 | if (!isAbsolute) { 159 | remoteUrl = 160 | (baseHref.endsWith('/') ? baseHref : baseHref + '/') + remoteUrl 161 | } 162 | 163 | ;(LOCALE_PLACEHOLDER_REGEX.exec(remoteUrl) 164 | ? forkJoin( 165 | this.locales.map(locale => 166 | this.fetchTranslation(remoteUrl, locale).pipe( 167 | catchError(error => { 168 | if (this.loose) { 169 | const looseLocale = this._getLooseLocale(locale) 170 | if ( 171 | locale !== looseLocale && 172 | !this.locales.includes(looseLocale) 173 | ) { 174 | return this.fetchTranslation(remoteUrl, looseLocale) 175 | } 176 | } 177 | return isDevMode() ? throwError(error) : EMPTY 178 | }), 179 | filter(isPlainObject), 180 | map>>( 181 | translation => ({ 182 | [locale]: translation, 183 | }), 184 | ), 185 | ), 186 | ), 187 | // eslint-disable-next-line @typescript-eslint/unbound-method 188 | ).pipe(map(_ => _.reduce(Object.assign))) 189 | : this.fetchTranslation(remoteUrl).pipe( 190 | catchError(error => (isDevMode() ? throwError(error) : EMPTY)), 191 | ) 192 | ) 193 | .pipe( 194 | takeUntil(this.destroy$$), 195 | finalize(() => (this._remoteLoaded = true)), 196 | ) 197 | .subscribe(remoteTranslations => { 198 | if (!remoteTranslations) { 199 | return 200 | } 201 | if (!this.remoteTranslationsList) { 202 | this.remoteTranslationsList = [] 203 | } 204 | this.remoteTranslationsList.push(Object.freeze(remoteTranslations)) 205 | }) 206 | } 207 | 208 | private _getLooseLocale(locale: string) { 209 | return head(locale.split(/[-_]/)) as Locale 210 | } 211 | 212 | private _getValue( 213 | source: Nullable>>, 214 | locale = this.locale, 215 | ): Nullable { 216 | if (!source) { 217 | return 218 | } 219 | let value = source[locale] 220 | if (value == null && this.loose) { 221 | const looseLocale = this._getLooseLocale(locale) 222 | if (locale !== looseLocale) { 223 | value = source[looseLocale] 224 | } 225 | } 226 | if (value == null && locale !== this.defaultLocale) { 227 | return this._getValue(source, this.defaultLocale) 228 | } 229 | return value 230 | } 231 | 232 | private _getWithFallback( 233 | key: string, 234 | locale = this.locale, 235 | translations: Translations, 236 | ): Nullable { 237 | const value = get(this._getValue(translations, locale), key) 238 | if (value != null) { 239 | if (typeof value === 'object' && isDevMode()) { 240 | console.warn( 241 | `The translation for locale: \`${locale}\` and key:\`${key}\` is an object, which could be unexpected`, 242 | ) 243 | } 244 | return value.toString() 245 | } 246 | if (locale !== this.defaultLocale) { 247 | return this._getWithFallback(key, this.defaultLocale, translations) 248 | } 249 | } 250 | 251 | private _getBase( 252 | key: string, 253 | locale = this.locale, 254 | translationsList: Nullable, 255 | ) { 256 | if (!translationsList || translationsList.length === 0) { 257 | return 258 | } 259 | for (let i = translationsList.length; i > 0; i--) { 260 | const value = this._getWithFallback(key, locale, translationsList[i - 1]) 261 | if (value != null) { 262 | return value 263 | } 264 | } 265 | } 266 | 267 | private _get(key: string, ignoreNonExist = false, locale = this.locale) { 268 | let value = this._getBase(key, locale, this.remoteTranslationsList) 269 | if (value == null) { 270 | value = this._getBase(key, locale, this.translationsList) 271 | } 272 | if (value != null) { 273 | return value 274 | } 275 | if (isDevMode() && !ignoreNonExist) { 276 | console.warn( 277 | `No translation found for locale: \`${locale}\` and key:\`${key}\`, which could be unexpected`, 278 | ) 279 | } 280 | return key 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/modules/translate/types.ts: -------------------------------------------------------------------------------- 1 | import { Arrayable } from '../../types/public-api' 2 | 3 | import { Locale } from './constants' 4 | 5 | export interface Translation { 6 | // tslint:disable-next-line: max-union-size 7 | [key: string]: Arrayable 8 | } 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-type-alias 11 | export type Translations = Partial> 12 | 13 | export type TranslateKey = string | Partial> 14 | 15 | export interface TranslateOptions { 16 | locale?: Locale 17 | defaultLocale?: Locale 18 | locales?: Locale[] 19 | translations?: Translations 20 | loose?: boolean 21 | remoteTranslations?: Translations 22 | remoteUrl?: string 23 | } 24 | -------------------------------------------------------------------------------- /src/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist", 4 | "lib": { 5 | "entryFile": "./public-api.ts", 6 | "umdModuleIds": { 7 | "lodash": "_", 8 | "lodash-es": "_" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxts/ngrx", 3 | "version": "0.4.2", 4 | "description": "🎉 Angular + RxJS + TypeScript = 🔥", 5 | "repository": "git@github.com:rx-ts/ngrx.git", 6 | "author": "JounQin ", 7 | "license": "MIT", 8 | "peerDependencies": { 9 | "@angular/common": ">=7.2.16", 10 | "@angular/core": ">=7.2.16", 11 | "lodash-es": ">=4.17.21", 12 | "rxjs": ">=6.6.7" 13 | }, 14 | "sideEffects": false 15 | } 16 | -------------------------------------------------------------------------------- /src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './directives/public-api' 2 | export * from './modules/public-api' 3 | export * from './types/public-api' 4 | export * from './utils/public-api' 5 | -------------------------------------------------------------------------------- /src/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "declaration": true, 7 | "inlineSources": true 8 | }, 9 | "angularCompilerOptions": { 10 | "annotateForClosureCompiler": true, 11 | "disableTypeScriptVersionCheck": true, 12 | "enableResourceInlining": true, 13 | "fullTemplateTypeCheck": true, 14 | "skipTemplateCodegen": true, 15 | "strictInjectionParameters": true, 16 | "strictMetadataEmit": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/types/helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-type-alias */ 2 | export type AnyArray = T[] | readonly T[] 3 | 4 | export type Arrayable = [R] extends [never] 5 | ? T | T[] 6 | : R extends true 7 | ? Readonly | readonly T[] 8 | : R extends false 9 | ? T | Readonly | AnyArray 10 | : never 11 | 12 | export type Callback = (...params: T) => R 13 | 14 | export type Nullable = T | null | undefined 15 | -------------------------------------------------------------------------------- /src/types/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './helpers' 2 | -------------------------------------------------------------------------------- /src/utils/README.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | ## Decorators 4 | 5 | ./decorators.md 6 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { TemplateOptions } from 'lodash' 2 | 3 | export const TEMPLATE_OPTIONS: TemplateOptions = Object.freeze({ 4 | interpolate: /{{([\S\s]+?)}}/g, 5 | }) 6 | -------------------------------------------------------------------------------- /src/utils/decorators.md: -------------------------------------------------------------------------------- 1 | The `evens$`and `odds$` observables are passed as plain inputs, 2 | but that are transformed into observables again automatically by `ObservableInput` decorator. 3 | 4 | ```ts 5 | @Component({ 6 | selector: 'rx-decorator', 7 | templateUrl: 'template.html', 8 | }) 9 | export class DecoratorsComponent { 10 | @ObservableInput() 11 | @Input(true) 12 | evens$!: Observable 13 | 14 | @ObservableInput() 15 | @Input(true) 16 | odds$!: Observable 17 | 18 | merged$ = merge(this.evens$, this.odds$) 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /src/utils/decorators.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, ObservedValueOf } from 'rxjs' 2 | 3 | const checkDescriptor = (target: T, propertyKey: K) => { 4 | const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey) 5 | 6 | if (descriptor && !descriptor.configurable) { 7 | throw new TypeError(`property ${propertyKey} is not configurable`) 8 | } 9 | 10 | return { 11 | oGetter: descriptor && descriptor.get, 12 | oSetter: descriptor && descriptor.set, 13 | } 14 | } 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export function ValueHook( 18 | setter?: (this: T, value?: T[K]) => boolean | void, 19 | getter?: (this: T, value?: T[K]) => T[K], 20 | ) { 21 | return (target: T, propertyKey: K, _parameterIndex?: number) => { 22 | const { oGetter, oSetter } = checkDescriptor(target, propertyKey) 23 | 24 | const symbol = Symbol('private property hook') 25 | 26 | type Mixed = T & { 27 | [symbol]: T[K] 28 | } 29 | 30 | Object.defineProperty(target, propertyKey, { 31 | enumerable: true, 32 | configurable: true, 33 | get(this: Mixed) { 34 | return getter 35 | ? getter.call(this, this[symbol]) 36 | : oGetter 37 | ? oGetter.call(this) 38 | : this[symbol] 39 | }, 40 | set(this: Mixed, value: T[K]) { 41 | if ( 42 | value === this[propertyKey] || 43 | (setter && setter.call(this, value) === false) 44 | ) { 45 | return 46 | } 47 | if (oSetter) { 48 | oSetter.call(this, value) 49 | } 50 | this[symbol] = value 51 | }, 52 | }) 53 | } 54 | } 55 | 56 | export function ObservableInput< 57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 58 | T = any, 59 | OK extends keyof T = keyof T, 60 | K extends keyof T = keyof T 61 | >(propertyKey?: K | boolean, initialValue?: ObservedValueOf) { 62 | return (target: T, oPropertyKey: OK) => { 63 | if (!(oPropertyKey as string).endsWith('$')) { 64 | throw new TypeError( 65 | `property ${oPropertyKey} should be an observable and its name should end with $`, 66 | ) 67 | } 68 | 69 | const { oGetter, oSetter } = checkDescriptor(target, oPropertyKey) 70 | 71 | if (oGetter || oSetter) { 72 | throw new TypeError( 73 | `property ${oPropertyKey} should not define getter or setter`, 74 | ) 75 | } 76 | 77 | const symbol = Symbol('private property hook') 78 | 79 | // eslint-disable-next-line @typescript-eslint/no-type-alias 80 | type OT = ObservedValueOf 81 | 82 | type Mixed = T & { 83 | [symbol]?: BehaviorSubject 84 | } & Record> 85 | 86 | Object.defineProperty(target, oPropertyKey, { 87 | enumerable: true, 88 | configurable: true, 89 | get(this: Mixed) { 90 | return ( 91 | this[symbol] || (this[symbol] = new BehaviorSubject(initialValue)) 92 | ) 93 | }, 94 | set(this: Mixed, value: OT) { 95 | this[oPropertyKey].next(value) 96 | }, 97 | }) 98 | 99 | if (!propertyKey) { 100 | return 101 | } 102 | 103 | if (propertyKey === true) { 104 | propertyKey = (oPropertyKey as string).replace(/\$+$/, '') as K 105 | } 106 | 107 | if (Object.getOwnPropertyDescriptor(target, propertyKey)) { 108 | throw new TypeError( 109 | `property ${propertyKey} should not define any descriptor`, 110 | ) 111 | } 112 | 113 | Object.defineProperty(target, propertyKey, { 114 | enumerable: true, 115 | configurable: true, 116 | get(this: Mixed) { 117 | return this[oPropertyKey].getValue() 118 | }, 119 | set(this: Mixed, value: OT) { 120 | this[oPropertyKey].next(value) 121 | }, 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { last } from 'lodash' 2 | 3 | export const getBaseHref = () => { 4 | // The last base element has highest privilege 5 | const base = last(document.querySelectorAll('base')) 6 | // use `getAttribute` instead of `base.href` because the attribute could be void but results current whole location url 7 | return (base && base.getAttribute('href')) || '/' 8 | } 9 | 10 | export const isAbsoluteUrl = (url?: string) => /^(https?:\/)?\//.test(url) 11 | -------------------------------------------------------------------------------- /src/utils/operators.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'rxjs' 2 | import { publishReplay, refCount } from 'rxjs/operators' 3 | 4 | export const publishRef = (bufferSize = 1, windowTime?: number) => 5 | pipe(publishReplay(bufferSize, windowTime), refCount()) 6 | -------------------------------------------------------------------------------- /src/utils/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './decorators' 3 | export * from './helpers' 4 | export * from './operators' 5 | export * from './tokens' 6 | -------------------------------------------------------------------------------- /src/utils/tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core' 2 | 3 | import { getBaseHref } from './helpers' 4 | 5 | export const TOKEN_BASE_HREF = new InjectionToken( 6 | 'base href from DOM', 7 | { 8 | providedIn: 'root', 9 | factory: getBaseHref, 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /stories/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "@typescript-eslint/no-magic-numbers": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /stories/directives/directives.stories.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { HttpClient, HttpClientModule } from '@angular/common/http' 3 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core' 4 | import { RxDirectivesModule } from '@rxts/ngrx' 5 | import { number } from '@storybook/addon-knobs' 6 | import { storiesOf } from '@storybook/angular' 7 | import { EMPTY, Subject, interval } from 'rxjs' 8 | import { map, pairwise, take } from 'rxjs/operators' 9 | 10 | @Component({ 11 | selector: 'rx-async-directive-demo', 12 | template: ` 13 | 14 |

27 | 28 | loading: {{ loading }} error: {{ error | json }} 29 |
30 | todo: {{ todo | json }} 31 |
32 | `, 33 | preserveWhitespaces: false, 34 | changeDetection: ChangeDetectionStrategy.OnPush, 35 | }) 36 | class AsyncDirectiveComponent { 37 | context = this 38 | 39 | @Input() 40 | todoId = 1 41 | 42 | @Input() 43 | retryTimes = 0 44 | 45 | refetch$$ = new Subject() 46 | 47 | constructor(private readonly http: HttpClient) {} 48 | 49 | fetchTodo(todoId?: number) { 50 | return typeof todoId === 'number' 51 | ? this.http.get(`//jsonplaceholder.typicode.com/todos/${todoId}`) 52 | : EMPTY 53 | } 54 | } 55 | 56 | const moduleMetadata = { 57 | imports: [CommonModule, HttpClientModule, RxDirectivesModule], 58 | declarations: [AsyncDirectiveComponent], 59 | } 60 | 61 | storiesOf('Directives', module) 62 | .add('Async Directive', () => ({ 63 | moduleMetadata, 64 | template: /* HTML */ ` 65 | 69 | `, 70 | props: { 71 | todoId: number('todoId', 1), 72 | retryTimes: number('retryTimes', 0), 73 | }, 74 | })) 75 | .add( 76 | 'Var Directive', 77 | () => ({ 78 | moduleMetadata, 79 | template: /* HTML */ ` 80 | 83 | item:{{ item | json }} 84 |
85 | prev:{{ prev }} curr:{{ curr }} 86 |
87 | `, 88 | props: { 89 | item$: interval(1000).pipe( 90 | take(10), 91 | pairwise(), 92 | map(([prev, curr]) => ({ prev, curr })), 93 | ), 94 | }, 95 | }), 96 | { 97 | notes: 98 | 'You can use the implicit variable name, or destructuring assignment', 99 | }, 100 | ) 101 | -------------------------------------------------------------------------------- /stories/modules/modules.stories.ts: -------------------------------------------------------------------------------- 1 | import { TranslateModule } from '@rxts/ngrx' 2 | import markdown from '@rxts/ngrx/utils/decorators.md' 3 | import { storiesOf } from '@storybook/angular' 4 | 5 | import { SharedModule } from '../shared/shared.module' 6 | 7 | import { TranslateComponent } from './translate/translate.component' 8 | 9 | storiesOf('Modules', module).add( 10 | 'translate', 11 | () => ({ 12 | moduleMetadata: { 13 | imports: [ 14 | SharedModule, 15 | TranslateModule.forRoot({ 16 | translations: { 17 | zh: { 18 | // eslint-disable-next-line @typescript-eslint/camelcase 19 | toggle_locale: '切换区域', 20 | nested: { 21 | params: '证件号: {{ id }}, 姓名: {{ name }}', 22 | }, 23 | }, 24 | en: { 25 | nested: { 26 | params: 'ID: {{ id }}, name: {{ name }}', 27 | }, 28 | }, 29 | }, 30 | loose: true, 31 | }), 32 | ], 33 | declarations: [TranslateComponent], 34 | exports: [TranslateComponent], 35 | }, 36 | template: /* HTML */ ` 37 | 38 | `, 39 | }), 40 | { 41 | notes: { 42 | markdown, 43 | }, 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /stories/modules/translate/translate.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core' 2 | import { TranslateService } from '@rxts/ngrx' 3 | 4 | @Component({ 5 | selector: 'rx-translate', 6 | template: ` 7 | {{ 'language' | translate }} 8 | {{ 'nested.params' | translate: { id: 123, name: 'Peter' } }} 9 |
10 | 13 | `, 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class TranslateComponent { 17 | constructor(private readonly translate: TranslateService) {} 18 | 19 | toggleLocale() { 20 | this.translate.toggleLocale() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stories/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { HttpClientModule } from '@angular/common/http' 3 | import { NgModule } from '@angular/core' 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' 5 | 6 | const EXPORTABLE_MODULES = [ 7 | CommonModule, 8 | FormsModule, 9 | HttpClientModule, 10 | ReactiveFormsModule, 11 | ] 12 | 13 | @NgModule({ 14 | imports: EXPORTABLE_MODULES, 15 | exports: EXPORTABLE_MODULES, 16 | }) 17 | export class SharedModule {} 18 | -------------------------------------------------------------------------------- /stories/shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const markdown: string 3 | export = markdown 4 | } 5 | -------------------------------------------------------------------------------- /stories/utils/decorators/component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core' 2 | import { ObservableInput, ValueHook } from '@rxts/ngrx' 3 | import { 4 | Observable, 5 | ObservableInput as RxObservableInput, 6 | concat, 7 | merge, 8 | } from 'rxjs' 9 | import { switchMap } from 'rxjs/operators' 10 | 11 | export enum Strategy { 12 | Merge = 'merge', 13 | Concat = 'concat', 14 | } 15 | 16 | type Handler = ( 17 | v1: RxObservableInput, 18 | v2: RxObservableInput, 19 | ) => Observable 20 | 21 | export const STRATEGY_OPERATORS: Record = { 22 | // tslint:disable-next-line: deprecation 23 | [Strategy.Merge]: merge, 24 | // tslint:disable-next-line: deprecation 25 | [Strategy.Concat]: concat, 26 | } 27 | 28 | @Component({ 29 | selector: 'rx-decorator', 30 | templateUrl: 'template.html', 31 | preserveWhitespaces: false, 32 | changeDetection: ChangeDetectionStrategy.OnPush, 33 | }) 34 | export class DecoratorsComponent { 35 | @ObservableInput() 36 | @Input('evens') 37 | evens$!: Observable 38 | 39 | @ObservableInput() 40 | @Input('odds') 41 | odds$!: Observable 42 | 43 | @ObservableInput() 44 | @Input('strategy') 45 | strategy$!: Observable 46 | 47 | merged$ = this.strategy$.pipe( 48 | switchMap(strategy => 49 | STRATEGY_OPERATORS[strategy](this.evens$, this.odds$), 50 | ), 51 | ) 52 | 53 | @ValueHook(function() { 54 | this.hookSet++ 55 | }) 56 | @Input() 57 | hook?: string 58 | 59 | hookSet = 0 60 | } 61 | -------------------------------------------------------------------------------- /stories/utils/decorators/module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | 3 | import { SharedModule } from '../../shared/shared.module' 4 | 5 | import { DecoratorsComponent } from './component' 6 | 7 | const EXPORTABLE_COMPONENTS = [DecoratorsComponent] 8 | 9 | @NgModule({ 10 | imports: [SharedModule], 11 | declarations: EXPORTABLE_COMPONENTS, 12 | exports: EXPORTABLE_COMPONENTS, 13 | }) 14 | export class DecoratorsModule {} 15 | -------------------------------------------------------------------------------- /stories/utils/decorators/template.html: -------------------------------------------------------------------------------- 1 | evens$: {{ evens$ | async }} odds$: {{ odds$ | async }} 2 |
3 | merged$: {{ merged$ | async }} 4 | 5 |
6 | 7 | hook: {{ hook }} 8 |
9 | hookSet: {{ hookSet }} 10 | -------------------------------------------------------------------------------- /stories/utils/utils.stories.ts: -------------------------------------------------------------------------------- 1 | import markdown from '@rxts/ngrx/utils/decorators.md' 2 | import { select, text } from '@storybook/addon-knobs' 3 | import { storiesOf } from '@storybook/angular' 4 | import { interval, partition } from 'rxjs' 5 | import { shareReplay, take } from 'rxjs/operators' 6 | 7 | import { Strategy } from './decorators/component' 8 | import { DecoratorsModule } from './decorators/module' 9 | 10 | const [evens$, odds$] = partition( 11 | interval(1000).pipe(take(20)), 12 | i => i % 2 === 0, 13 | ) 14 | 15 | storiesOf('Utilities', module).add( 16 | 'Decorators', 17 | () => ({ 18 | moduleMetadata: { 19 | imports: [DecoratorsModule], 20 | }, 21 | template: /* HTML */ ` 22 | 28 | `, 29 | props: { 30 | evens$: evens$.pipe(shareReplay()), 31 | odds$: odds$.pipe(shareReplay()), 32 | strategy: select( 33 | 'strategy', 34 | [Strategy.Merge, Strategy.Concat], 35 | Strategy.Merge, 36 | ), 37 | hook: text('hook', 'Try to type, setter hook will be called'), 38 | }, 39 | }), 40 | { 41 | notes: { 42 | markdown, 43 | }, 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@1stg/tsconfig/angular", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "module": "commonjs", 6 | "paths": { 7 | "@rxts/ngrx": ["src/public-api"], 8 | "lodash": ["lodash-es"] 9 | } 10 | }, 11 | "include": [".storybook/**/*.ts", "src/**/*.ts", "stories/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@1stg/tslint-config/angular", "tslint-config-eslint/sonar"], 3 | "linterOptions": { 4 | "exclude": ["dist/**"] 5 | } 6 | } 7 | --------------------------------------------------------------------------------