├── .browserslistrc ├── .commitlintrc.js ├── .cspell.json ├── .czrc ├── .dockerignore ├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .htmlhintrc ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .ncurc.js ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .stylelintignore ├── .tool-versions ├── .versionrc.js ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── TODO.md ├── angular.json ├── cypress.config.ts ├── cypress ├── coverage.webpack.js ├── e2e │ ├── app.cy.ts │ └── app.e2e.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── docker ├── Dockerfile ├── docker-compose.csr.yml ├── docker-compose.ssr.yml ├── nginx │ └── nginx.conf └── pm2 │ └── process.json ├── jest.config.js ├── ngsw-config.json ├── nyc.config.js ├── package-lock.json ├── package.json ├── routes.txt ├── server.ts ├── src ├── app │ ├── app-routing.module.ts │ ├── app.browser.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.module.ts │ ├── app.server.module.ts │ ├── core │ │ ├── components │ │ │ ├── content │ │ │ │ ├── content.component.html │ │ │ │ ├── content.component.scss │ │ │ │ ├── content.component.spec.ts │ │ │ │ └── content.component.ts │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ │ ├── header │ │ │ │ ├── header.component.html │ │ │ │ ├── header.component.scss │ │ │ │ └── header.component.ts │ │ │ ├── index.ts │ │ │ └── navbar │ │ │ │ ├── navbar.component.html │ │ │ │ ├── navbar.component.scss │ │ │ │ └── navbar.component.ts │ │ ├── constants │ │ │ ├── index.ts │ │ │ ├── languages.ts │ │ │ └── menu-entries.ts │ │ ├── core.module.ts │ │ ├── enums │ │ │ ├── cookie.ts │ │ │ ├── header.ts │ │ │ ├── index.ts │ │ │ └── transfer-state-key.ts │ │ ├── guards │ │ │ ├── ensure-module-loaded-once.guard.ts │ │ │ ├── index.ts │ │ │ └── localize-route.guard.ts │ │ ├── handlers │ │ │ ├── global-error.handler.ts │ │ │ └── index.ts │ │ ├── interceptors │ │ │ ├── http-error.interceptor.ts │ │ │ └── index.ts │ │ ├── loaders │ │ │ ├── i18n-browser.loader.ts │ │ │ └── i18n-server.loader.ts │ │ ├── models │ │ │ ├── error-event.ts │ │ │ └── index.ts │ │ ├── resolvers │ │ │ ├── index.ts │ │ │ └── language.resolver.ts │ │ ├── services │ │ │ ├── app-bus.service.ts │ │ │ ├── app-initializer.service.ts │ │ │ ├── app-updater.service.ts │ │ │ ├── cookie.service.ts │ │ │ ├── i18n.service.ts │ │ │ ├── index.ts │ │ │ ├── logger.service.spec.ts │ │ │ ├── logger.service.ts │ │ │ └── platform.service.ts │ │ ├── strategies │ │ │ ├── custom-lazyload-image.strategy.ts │ │ │ ├── custom-page-title.strategy.ts │ │ │ ├── custom-route-preload.strategy.ts │ │ │ ├── custom-route-reuse.strategy.ts │ │ │ ├── index.ts │ │ │ └── network-aware-route-preload.strategy.ts │ │ ├── tokens │ │ │ └── app-name.ts │ │ └── utils │ │ │ └── index.ts │ ├── features │ │ ├── home │ │ │ ├── components │ │ │ │ └── index.ts │ │ │ ├── home-routing.module.ts │ │ │ ├── home.module.ts │ │ │ ├── models │ │ │ │ └── index.ts │ │ │ ├── pages │ │ │ │ ├── index.ts │ │ │ │ └── welcome │ │ │ │ │ ├── welcome.component.html │ │ │ │ │ ├── welcome.component.scss │ │ │ │ │ └── welcome.component.ts │ │ │ └── services │ │ │ │ └── index.ts │ │ └── jokes │ │ │ ├── components │ │ │ └── index.ts │ │ │ ├── jokes-routing.module.ts │ │ │ ├── jokes.module.ts │ │ │ ├── models │ │ │ ├── chuck-norris-joke.model.ts │ │ │ └── index.ts │ │ │ ├── pages │ │ │ ├── chuck-norris │ │ │ │ ├── chuck-norris.component.html │ │ │ │ ├── chuck-norris.component.scss │ │ │ │ └── chuck-norris.component.ts │ │ │ └── index.ts │ │ │ └── services │ │ │ ├── chuck-norris.service.ts │ │ │ └── index.ts │ └── shared │ │ ├── components │ │ ├── error │ │ │ ├── error.component.html │ │ │ ├── error.component.scss │ │ │ └── error.component.ts │ │ └── index.ts │ │ ├── directives │ │ └── index.ts │ │ ├── modules │ │ └── icons │ │ │ ├── github-icon │ │ │ ├── github-icon.component.html │ │ │ ├── github-icon.component.scss │ │ │ └── github-icon.component.ts │ │ │ ├── happy-icon │ │ │ ├── happy-icon.component.html │ │ │ ├── happy-icon.component.scss │ │ │ └── happy-icon.component.ts │ │ │ ├── heart-icon │ │ │ ├── heart-icon.component.html │ │ │ ├── heart-icon.component.scss │ │ │ └── heart-icon.component.ts │ │ │ └── icons.module.ts │ │ ├── pipes │ │ ├── index.ts │ │ └── localize-route.pipe.ts │ │ ├── services │ │ ├── browser.service.ts │ │ ├── index.ts │ │ ├── seo.service.ts │ │ ├── server.service.ts │ │ └── storage.service.ts │ │ └── shared.module.ts ├── assets │ ├── i18n │ │ ├── en.json │ │ └── es.json │ ├── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ └── icon-96x96.png │ └── images │ │ ├── background.svg │ │ ├── logo.png │ │ └── logo.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── favicon.png ├── index.html ├── jest.mocks.ts ├── jest.setup.ts ├── main.browser.ts ├── main.server.ts ├── manifest.webmanifest ├── polyfills.ts ├── robots.txt ├── styles.scss ├── styles │ ├── abstracts │ │ ├── _functions.scss │ │ ├── _mixins.scss │ │ └── _variables.scss │ ├── base │ │ ├── _base.scss │ │ ├── _fonts.scss │ │ ├── _helpers.scss │ │ └── _typography.scss │ ├── components │ │ └── _button.scss │ ├── layout │ │ ├── _content.scss │ │ ├── _footer.scss │ │ ├── _header.scss │ │ ├── _navbar.scss │ │ └── _root.scss │ ├── main.scss │ └── vendors │ │ ├── _google.scss │ │ └── _tailwind.scss └── types │ ├── figlet │ └── importable-fonts.d.ts │ └── globals.d.ts ├── stylelint.config.js ├── tailwind.config.js ├── transloco.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.server.json └── tsconfig.spec.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | }; 4 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "import": [ 5 | "@cspell/dict-typescript", 6 | "@cspell/dict-software-terms", 7 | "@cspell/dict-node", 8 | "@cspell/dict-en_us", 9 | "@cspell/dict-en-gb", 10 | "@cspell/dict-npm", 11 | "@cspell/dict-html", 12 | "@cspell/dict-companies", 13 | "@cspell/dict-filetypes", 14 | "@cspell/dict-bash", 15 | "@cspell/dict-lorem-ipsum/cspell-ext.json", 16 | "@cspell/dict-es-es/cspell-ext.json" 17 | ], 18 | "allowCompoundWords": true, 19 | "flagWords": [], 20 | "ignorePaths": [ 21 | ".eslintcache", 22 | ".stylelintcache", 23 | ".vscode/**", 24 | "dist/**", 25 | "node_modules/**", 26 | "package-lock.json", 27 | "package.json", 28 | "tsconfig.json", 29 | "tsconfig.build.json", 30 | "yarn.lock", 31 | "**/coverage/**", 32 | "**/node_modules/**", 33 | "**/dist/**", 34 | "**/fixtures/**", 35 | "**/public/**", 36 | "CHANGELOG.md", 37 | "**/changelog.mdx" 38 | ], 39 | "dictionaries": [ 40 | "typescript", 41 | "softwareTerms", 42 | "node", 43 | "en_us", 44 | "en-gb", 45 | "npm", 46 | "html", 47 | "companies", 48 | "misc", 49 | "filetypes", 50 | "bash", 51 | "lorem-ipsum", 52 | "es-es" 53 | ], 54 | "words": [ 55 | "apng", 56 | "avif", 57 | "Bienvenidx", 58 | "borjapazr", 59 | "browserslistrc", 60 | "commitlintrc", 61 | "czrc", 62 | "endent", 63 | "healthz", 64 | "htmlhintrc", 65 | "iisnode", 66 | "Jang", 67 | "joelwmale", 68 | "lcov", 69 | "lintstagedrc", 70 | "ncurc", 71 | "Neue", 72 | "ngfactory", 73 | "ngneat", 74 | "ngstyle", 75 | "ngsw", 76 | "nguniversal", 77 | "opsz", 78 | "OWASP", 79 | "robotstxt", 80 | "skyux", 81 | "sonarjs", 82 | "styl", 83 | "Suvorov", 84 | "Titillium", 85 | "Todoist", 86 | "Transloco", 87 | "versionrc", 88 | "webp", 89 | "wght" 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .commitlintrc* 2 | .dockerignore 3 | .DS_Store 4 | .editorconfig 5 | .env* 6 | .eslintcache 7 | .stylelintcache 8 | .eslintrc* 9 | .git 10 | .prettierrc* 11 | .vscode 12 | *.log 13 | *docker-compose* 14 | *Dockerfile* 15 | .docker 16 | coverage 17 | data 18 | dist 19 | LICENSE 20 | logs 21 | node_modules 22 | npm-debug.log 23 | README.md 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{js,ts}] 12 | quote_type = single 13 | 14 | [*.json] 15 | insert_final_newline = ignore 16 | 17 | [{Makefile,**.mk}] 18 | indent_style = tab 19 | 20 | [*.md] 21 | max_line_length = off 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ## App configuration ## 2 | APP_NAME=angular-skeleton 3 | PORT=5000 4 | ISR_TOKEN=angular-skeleton 5 | 6 | ## Docker configuration ## 7 | # App configuration # 8 | EXTERNAL_PORT=5000 9 | 10 | # Container configuration 11 | TZ=Europe/Madrid 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .yarn/ 3 | .docker/ 4 | **/.coverage_artifacts 5 | **/.coverage_cache 6 | **/.coverage_contracts 7 | **/artifacts 8 | **/build 9 | **/cache 10 | **/coverage 11 | **/dist 12 | **/logs 13 | **/node_modules 14 | **/types 15 | 16 | # Files 17 | .commitlintrc* 18 | .eslintcache 19 | .stylelintcache 20 | .eslintrc* 21 | .pnp.* 22 | .prettierrc* 23 | *.env 24 | *.log 25 | coverage.json 26 | npm-debug.log* 27 | package-lock.json 28 | yarn.lock 29 | yarn-debug.log* 30 | yarn-error.log* 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * simple-import-sort default grouping, but with type imports last as a separate 3 | * group, sorting that group like non-type imports are grouped. 4 | */ 5 | const importGroups = [ 6 | // Side effect imports. 7 | ['^\\u0000'], 8 | // Packages. 9 | // Things that start with a letter (or digit or underscore), or `@` followed by a letter. 10 | ['^@?\\w'], 11 | // Absolute imports and other imports such as Vue-style `@/foo`. 12 | // Anything not matched in another group. 13 | ['^'], 14 | // Relative imports. 15 | // Anything that starts with a dot 16 | ['^\\.'], 17 | // Typings 18 | ['^@?\\w.*\\u0000$', '^[^.].*\\u0000$', '^\\..*\\u0000$'] 19 | ]; 20 | 21 | /* 22 | * Configuration for simple-import-sort plugin to detect 23 | * the different namespaces defined in the application. 24 | * This matches the "paths" property of the tsconfig.json file. 25 | */ 26 | const { compilerOptions } = require('get-tsconfig').getTsconfig('./tsconfig.json')['config']; 27 | if ('paths' in compilerOptions) { 28 | const namespaces = Object.keys(compilerOptions.paths).map(path => path.replace('/*', '')); 29 | if (namespaces && namespaces.length > 0) { 30 | // Anything that is defined in tsconfig.json with a little trick in order to resolve paths 31 | const pathAliasRegex = [`^(${namespaces.join('|')})(/.*|$)`]; 32 | importGroups.splice(2, 0, pathAliasRegex); 33 | } 34 | } 35 | 36 | /* 37 | * Although many of the extended configurations already automatically 38 | * import the plugins, we have chosen to add them explicitly in case 39 | * the recommended configurations are dispensed with in the future. 40 | * In this way the rules could be added directly in the "rules" section. 41 | */ 42 | module.exports = { 43 | root: true, 44 | ignorePatterns: ['projects/**/*'], 45 | env: { 46 | node: true, 47 | jest: true, 48 | 'jest/globals': true, 49 | 'cypress/globals': true 50 | }, 51 | parserOptions: { 52 | ecmaVersion: 12, 53 | sourceType: 'module' 54 | }, 55 | overrides: [ 56 | { 57 | files: '*.js', 58 | extends: ['eslint:recommended'] 59 | }, 60 | { 61 | files: ['*.ts'], 62 | parser: '@typescript-eslint/parser', 63 | parserOptions: { 64 | project: ['./tsconfig.json', './tsconfig.app.json', './tsconfig.server.json', './tsconfig.spec.json'], 65 | createDefaultProgram: true 66 | }, 67 | extends: [ 68 | 'plugin:@angular-eslint/recommended', 69 | 'plugin:@angular-eslint/recommended--extra', 70 | 'plugin:@angular-eslint/template/process-inline-templates', 71 | 'plugin:eslint-comments/recommended', 72 | 'plugin:sonarjs/recommended', 73 | 'plugin:import/recommended', 74 | 'plugin:import/typescript', 75 | 'plugin:prettier/recommended' 76 | ], 77 | plugins: [ 78 | '@typescript-eslint', 79 | 'prefer-arrow', 80 | 'eslint-comments', 81 | 'sonarjs', 82 | 'import', 83 | 'prettier', 84 | 'simple-import-sort', 85 | 'unused-imports', 86 | 'deprecation' 87 | ], 88 | settings: { 89 | // Define import resolver for import plugin 90 | 'import/resolver': { 91 | typescript: { 92 | alwaysTryTypes: true 93 | } 94 | } 95 | }, 96 | rules: { 97 | // For faster development 98 | 'no-process-exit': 'off', 99 | 'no-useless-constructor': 'off', 100 | 'class-methods-use-this': 'off', 101 | '@typescript-eslint/no-explicit-any': 'off', 102 | '@typescript-eslint/explicit-function-return-type': 'off', 103 | '@typescript-eslint/no-non-null-assertion': 'off', 104 | 'sonarjs/cognitive-complexity': ['error', 18], 105 | 106 | // Angular specific 107 | '@angular-eslint/directive-selector': [ 108 | 'error', 109 | { 110 | type: 'attribute', 111 | prefix: 'app', 112 | style: 'camelCase' 113 | } 114 | ], 115 | '@angular-eslint/component-selector': [ 116 | 'error', 117 | { 118 | type: 'element', 119 | prefix: 'app', 120 | style: 'kebab-case' 121 | } 122 | ], 123 | 124 | // Import and order style 125 | 'simple-import-sort/imports': [ 126 | 'error', 127 | { 128 | groups: importGroups 129 | } 130 | ], 131 | 'no-restricted-imports': [ 132 | 'error', 133 | { 134 | patterns: [ 135 | { 136 | group: ['../*', './../*'], 137 | message: 'For imports of parent elements use better path aliases. For example, @domain/shared.' 138 | } 139 | ] 140 | } 141 | ], 142 | 'import/no-named-as-default': 'off', 143 | 'import/no-named-as-default-member': 'off', 144 | 'simple-import-sort/exports': 'error', 145 | 'import/prefer-default-export': 'off', 146 | 'import/no-default-export': 'off', 147 | 'import/first': 'error', 148 | 'import/newline-after-import': 'error', 149 | 'import/no-duplicates': 'error', 150 | 'import/no-deprecated': 'error', 151 | 'import/group-exports': 'error', 152 | 'import/exports-last': 'error', 153 | 'padding-line-between-statements': [ 154 | 'error', 155 | { blankLine: 'always', prev: '*', next: 'export' }, 156 | { blankLine: 'any', prev: 'export', next: 'export' } 157 | ], 158 | quotes: [ 159 | 'error', 160 | 'single', 161 | { 162 | allowTemplateLiterals: true 163 | } 164 | ], 165 | '@typescript-eslint/member-ordering': 'error', 166 | '@typescript-eslint/no-unused-vars': 'off', 167 | 'no-unused-vars': 'off', 168 | 'unused-imports/no-unused-imports': 'error', 169 | 'unused-imports/no-unused-vars': [ 170 | 'error', 171 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' } 172 | ], 173 | 174 | // General rules 175 | 'deprecation/deprecation': 'warn', 176 | 'eslint-comments/disable-enable-pair': ['error', { allowWholeFile: true }], 177 | 'lines-between-class-members': 'off', 178 | '@typescript-eslint/lines-between-class-members': 'error', 179 | 'prefer-arrow/prefer-arrow-functions': [ 180 | 'warn', 181 | { 182 | disallowPrototype: true, 183 | singleReturnOnly: false, 184 | classPropertiesAllowed: false 185 | } 186 | ] 187 | }, 188 | overrides: [ 189 | { 190 | files: ['*.unit.ts', '*.int.ts', '*.spec.ts', '*.test.ts'], 191 | env: { 192 | jest: true, 193 | 'jest/globals': true 194 | }, 195 | extends: ['plugin:jest/recommended', 'plugin:jest/style'], 196 | plugins: ['jest'], 197 | rules: { 198 | 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', 'request.**.expect'] }] 199 | } 200 | }, 201 | { 202 | files: ['*.e2e.ts', '*.cy.ts'], 203 | env: { 204 | 'cypress/globals': true 205 | }, 206 | parserOptions: { 207 | project: './cypress/tsconfig.json' 208 | }, 209 | extends: ['plugin:cypress/recommended'], 210 | plugins: ['cypress'], 211 | rules: {} 212 | } 213 | ] 214 | }, 215 | { 216 | files: ['*.html'], 217 | extends: ['plugin:@angular-eslint/template/recommended'], 218 | rules: {} 219 | }, 220 | { 221 | files: ['*.html'], 222 | excludedFiles: ['*inline-template-*.component.html'], 223 | extends: ['plugin:prettier/recommended'], 224 | rules: { 225 | 'prettier/prettier': ['error', { parser: 'angular' }] 226 | } 227 | } 228 | ] 229 | }; 230 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @borjapazr 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'friday' 8 | commit-message: 9 | prefix: 'npm' 10 | include: 'scope' 11 | labels: 12 | - 'npm' 13 | - 'dependencies' 14 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: 🐙 Generate new release 14 | runs-on: ubuntu-latest 15 | if: startsWith(github.ref, 'refs/tags/') 16 | 17 | steps: 18 | - name: ⬇️ Checkout project 19 | uses: actions/checkout@v3 20 | 21 | - name: 📋 Build Changelog 22 | run: npx extract-changelog-release > RELEASE_BODY.md 23 | 24 | - name: 🍻 Build and generate new release 25 | uses: softprops/action-gh-release@v1 26 | with: 27 | body_path: RELEASE_BODY.md 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | distribute: 31 | name: 🛩️ Deliver project 32 | runs-on: ubuntu-latest 33 | needs: release 34 | 35 | steps: 36 | - name: ⬇️ Checkout project 37 | uses: actions/checkout@v3 38 | 39 | - name: 💻 Log in to Docker Hub 40 | uses: docker/login-action@v2 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | 45 | - name: 🏷️ Extract metadata (tags, labels) for Docker 46 | id: meta 47 | uses: docker/metadata-action@v4 48 | with: 49 | images: borjapazr/angular-skeleton 50 | 51 | - name: 💽 Build and push Docker image 52 | uses: docker/build-push-action@v3 53 | with: 54 | context: . 55 | file: docker/Dockerfile 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | 60 | redeploy: 61 | name: 🔄 Redeploy webhook call 62 | runs-on: ubuntu-latest 63 | needs: distribute 64 | 65 | steps: 66 | - name: 🚀 Deploy Angular Skeleton webhook 67 | uses: joelwmale/webhook-action@master 68 | env: 69 | WEBHOOK_URL: ${{ secrets.REDEPLOY_WEBHOOK_URL }} 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | validate: 11 | name: ✅ Validate project 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | 17 | steps: 18 | - name: ⬇️ Checkout project 19 | uses: actions/checkout@v3 20 | 21 | - name: 🟢 Setup NodeJS ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | 27 | - name: 📥 Install dependencies 28 | run: npm ci 29 | 30 | - name: 🖍️ Check types 31 | run: npm run check:types 32 | 33 | - name: 💅 Check format 34 | run: npm run check:format 35 | 36 | - name: 📑 Check lint 37 | run: npm run check:lint 38 | 39 | - name: 🌐 Check html 40 | run: npm run check:html 41 | 42 | - name: 💄 Check scss 43 | run: npm run check:scss 44 | 45 | - name: 🔡 Check i18n 46 | run: npm run check:i18n 47 | 48 | - name: 🔤 Check spelling 49 | run: npm run check:spelling 50 | 51 | test: 52 | name: 🧑‍🔬 Test project 53 | runs-on: ubuntu-latest 54 | needs: validate 55 | strategy: 56 | matrix: 57 | node-version: [18.x] 58 | 59 | steps: 60 | - name: ⬇️ Checkout project 61 | uses: actions/checkout@v3 62 | 63 | - name: 🟢 Setup NodeJS ${{ matrix.node-version }} 64 | uses: actions/setup-node@v3 65 | with: 66 | node-version: ${{ matrix.node-version }} 67 | cache: npm 68 | 69 | - name: 📥 Install dependencies 70 | run: npm ci 71 | 72 | - name: 🧪 Run the tests 73 | run: npm run test:coverage 74 | 75 | build: 76 | name: 🧰 Build project 77 | runs-on: ubuntu-latest 78 | needs: test 79 | strategy: 80 | matrix: 81 | node-version: [18.x] 82 | 83 | steps: 84 | - name: ⬇️ Checkout project 85 | uses: actions/checkout@v3 86 | 87 | - name: 🟢 Setup NodeJS ${{ matrix.node-version }} 88 | uses: actions/setup-node@v3 89 | with: 90 | node-version: ${{ matrix.node-version }} 91 | cache: npm 92 | 93 | - name: 📥 Install dependencies 94 | run: npm ci 95 | 96 | - name: ⚒️ Build project in SSR mode 97 | run: npm run build:ssr:prod 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled output 2 | /dist 3 | /tmp 4 | /out-tsc 5 | /bazel-out 6 | 7 | # Node 8 | /node_modules 9 | npm-debug.log 10 | yarn-error.log 11 | 12 | # IDEs and editors 13 | .idea/ 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # Visual Studio Code 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | .history/* 28 | 29 | # Miscellaneous 30 | /.angular/cache 31 | .sass-cache/ 32 | /connect.lock 33 | /coverage* 34 | /libpeerconnection.log 35 | testem.log 36 | /typings 37 | 38 | # System files 39 | .DS_Store 40 | Thumbs.db 41 | 42 | # Dependencies 43 | node_modules 44 | .pnp 45 | .pnp.js 46 | .npm 47 | 48 | # Testing 49 | coverage* 50 | cypress/videos 51 | cypress/screenshots 52 | .nyc_output 53 | 54 | # Production 55 | dist 56 | 57 | # Misc 58 | .DS_Store 59 | .AppleDouble 60 | .LSOverride 61 | 62 | # Logging 63 | npm-debug.log* 64 | yarn-debug.log* 65 | yarn-error.log* 66 | *.log 67 | logs 68 | 69 | # IDE 70 | .idea 71 | 72 | # Runtime data 73 | pids 74 | *.pid 75 | *.seed 76 | 77 | # Lint 78 | .eslintcache 79 | .stylelintcache 80 | 81 | # App data 82 | .docker 83 | 84 | -------------------------------------------------------------------------------- /.htmlhintrc: -------------------------------------------------------------------------------- 1 | { 2 | "tagname-lowercase": false, 3 | "attr-lowercase": false, 4 | "attr-value-double-quotes": true, 5 | "tag-pair": true, 6 | "spec-char-escape": true, 7 | "id-unique": false, 8 | "src-not-empty": true, 9 | "attr-no-duplication": true, 10 | "title-require": true, 11 | "tag-self-close": true, 12 | "head-script-disabled": true, 13 | "doctype-html5": true, 14 | "id-class-value": false, 15 | "style-disabled": true, 16 | "inline-style-disabled": false, 17 | "inline-script-disabled": true, 18 | "space-tab-mixed-disabled": true, 19 | "id-class-ad-disabled": true, 20 | "attr-unsafe-chars": true 21 | } 22 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # The reason we're exporting this variable is because of this issue: 5 | # https://github.com/typicode/husky/issues/968 6 | export FORCE_COLOR=1 7 | 8 | npx commitlint --edit $1 || 9 | ( 10 | echo '✍📤 It seems that the format of the commit does not follow the conventional commit convention. You can also try committing with the "npm run commit" command.'; 11 | false; 12 | ) 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # The reason we're exporting this variable is because of this issue: 5 | # https://github.com/typicode/husky/issues/968 6 | export FORCE_COLOR=1 7 | 8 | echo '🔍🎨 Formating and checking staged files before committing!' 9 | 10 | npx lint-staged || 11 | ( 12 | echo '💀❌ Ooops! Formating and checking process has failed!'; 13 | false; 14 | ) 15 | 16 | echo '🥳✅ Formating and checking process has been successfully completed!' 17 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,ts}': [ 3 | 'prettier --check --write --ignore-unknown', 4 | 'eslint --cache --color --fix', 5 | () => 'tsc --pretty --noEmit' 6 | ], 7 | '!*.{js,ts}': ['prettier --check --write --ignore-unknown'], 8 | '*.html': ['htmlhint'], 9 | '*.scss': ['stylelint --cache --color --fix'], 10 | 'src/assets/i18n/**/*.json': ['transloco-validator'], 11 | '{README.md,TODO.md,.github/*.md,src/**/*.ts}': ['cspell'] 12 | }; 13 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | upgrade: true, 3 | reject: ['@types/node'] 4 | }; 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .commitlintrc* 2 | .docker 3 | .DS_Store 4 | .editorconfig 5 | .eslintcache 6 | .stylelintcache 7 | .eslintignore 8 | .eslintrc* 9 | .nycrc 10 | .prettierrc* 11 | .travis.yml 12 | *.log 13 | coverage 14 | logs 15 | node_modules 16 | README.md 17 | test 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .yarn/ 3 | .docker/ 4 | **/.coverage_artifacts 5 | **/.coverage_cache 6 | **/.coverage_contracts 7 | **/artifacts 8 | **/build 9 | **/cache 10 | **/coverage 11 | **/dist 12 | **/logs 13 | **/node_modules 14 | **/types 15 | 16 | # Files 17 | .commitlintrc* 18 | .eslintcache 19 | .stylelintcache 20 | .pnp.* 21 | *.env 22 | *.log 23 | coverage.json 24 | npm-debug.log* 25 | package.json 26 | package-lock.json 27 | yarn.lock 28 | yarn-debug.log* 29 | yarn-error.log* 30 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | endOfLine: 'lf', 3 | arrowParens: 'avoid', 4 | printWidth: 120, 5 | quoteProps: 'as-needed', 6 | semi: true, 7 | singleQuote: true, 8 | tabWidth: 2, 9 | trailingComma: 'none', 10 | overrides: [ 11 | { 12 | files: '*.ts', 13 | options: { parser: 'typescript' } 14 | } 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.* 4 | !*.css 5 | !*.scss 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.13.0 2 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | header: '# Changelog\n\nAll notable changes to this project will be documented in this file.\n', 3 | types: [ 4 | { type: 'chore', section: 'Others', hidden: false }, 5 | { type: 'revert', section: 'Reverts', hidden: false }, 6 | { type: 'feat', section: 'Features', hidden: false }, 7 | { type: 'fix', section: 'Bug Fixes', hidden: false }, 8 | { type: 'improvement', section: 'Feature Improvements', hidden: false }, 9 | { type: 'docs', section: 'Docs', hidden: false }, 10 | { type: 'style', section: 'Styling', hidden: false }, 11 | { type: 'refactor', section: 'Code Refactoring', hidden: false }, 12 | { type: 'perf', section: 'Performance Improvements', hidden: false }, 13 | { type: 'test', section: 'Tests', hidden: false }, 14 | { type: 'build', section: 'Build System', hidden: false }, 15 | { type: 'ci', section: 'CI', hidden: false } 16 | ], 17 | scripts: { 18 | postchangelog: 'prettier -w CHANGELOG.md' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "angular.ng-template", 4 | "dbaeumer.vscode-eslint", 5 | "eamodio.gitlens", 6 | "esbenp.prettier-vscode", 7 | "mkaufman.HTMLHint", 8 | "streetsidesoftware.code-spell-checker", 9 | "stylelint.vscode-stylelint", 10 | "vsls-contrib.codetour", 11 | "bradlc.vscode-tailwindcss", 12 | "firsttris.vscode-jest-runner", 13 | "Orta.vscode-jest" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.enabled": true, 3 | "cSpell.userWords": [], // only use words from .cspell.json 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true, 6 | "source.fixAll.format": true, 7 | "source.fixAll.stylelint": true, 8 | "source.fixAll.htmlhint": true 9 | }, 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | "editor.formatOnPaste": true, 12 | "editor.formatOnSave": true, 13 | "eslint.alwaysShowStatus": true, 14 | "eslint.options": { 15 | "extensions": [".js", ".ts", ".html"] 16 | }, 17 | "eslint.validate": ["javascript", "typescript", "html"], 18 | "htmlhint.enable": true, 19 | "css.validate": false, 20 | "less.validate": false, 21 | "scss.validate": false, 22 | "errorLens.gutterIconsEnabled": true, 23 | "errorLens.gutterIconSet": "borderless", 24 | "errorLens.followCursor": "activeLine", 25 | "stylelint.enable": true, 26 | "stylelint.validate": ["css", "scss"] 27 | } 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.3.0](https://github.com/borjapazr/angular-skeleton/compare/v1.2.6...v1.3.0) (2023-03-07) 6 | 7 | ### Features 8 | 9 | - **deps:** upgrade angular to v15 ([2b354ef](https://github.com/borjapazr/angular-skeleton/commit/2b354ef99e04ff7dd7199040aa06fdcf80702723)), closes [#100](https://github.com/borjapazr/angular-skeleton/issues/100) 10 | 11 | ### Others 12 | 13 | - **deps:** update dependencies ([315c6a1](https://github.com/borjapazr/angular-skeleton/commit/315c6a104419115aa2dd72cc2ec14df07f47b727)) 14 | 15 | ### [1.2.6](https://github.com/borjapazr/angular-skeleton/compare/v1.2.5...v1.2.6) (2023-01-20) 16 | 17 | ### Others 18 | 19 | - **actions:** update setup-node action version to v3 ([c0b3b7e](https://github.com/borjapazr/angular-skeleton/commit/c0b3b7eaedcc61bbda5fec4790edc6b2ad056937)) 20 | 21 | ### [1.2.5](https://github.com/borjapazr/angular-skeleton/compare/v1.2.4...v1.2.5) (2023-01-20) 22 | 23 | ### Others 24 | 25 | - **deps:** update dependencies ([aef02ff](https://github.com/borjapazr/angular-skeleton/commit/aef02ff6dd9b953041acce01a558dfeafbc7b3f1)) 26 | - **git:** update .gitignore ([c056e5c](https://github.com/borjapazr/angular-skeleton/commit/c056e5c0790c7921f4db62845eee6818c8553657)) 27 | 28 | ### [1.2.4](https://github.com/borjapazr/angular-skeleton/compare/v1.2.3...v1.2.4) (2023-01-10) 29 | 30 | ### Bug Fixes 31 | 32 | - **version:** add postchangelog script ([45da200](https://github.com/borjapazr/angular-skeleton/commit/45da2004423cbfe6bec247bee67371f5e427bf68)) 33 | 34 | ### Others 35 | 36 | - **deps:** update and add peer dependencies explicitly ([d7077c9](https://github.com/borjapazr/angular-skeleton/commit/d7077c9b7da007fe7fbaf65c636790a7536ed1c2)) 37 | 38 | ### [1.2.3](https://github.com/borjapazr/angular-skeleton/compare/v1.2.2...v1.2.3) (2022-12-18) 39 | 40 | ### Bug Fixes 41 | 42 | - **badge:** fix shieldsio badge ([709c7b2](https://github.com/borjapazr/angular-skeleton/commit/709c7b23c155e7214820328cf007327879f92f53)) 43 | 44 | ### Others 45 | 46 | - **deps:** update dependencies ([eed89c1](https://github.com/borjapazr/angular-skeleton/commit/eed89c1be4224fdb9ba7be34220149ce8ba40e48)) 47 | - **deps:** update dependencies ([9cb9b31](https://github.com/borjapazr/angular-skeleton/commit/9cb9b3189576a608fbf92322a37df2da97827b46)) 48 | 49 | ### [1.2.2](https://github.com/borjapazr/angular-skeleton/compare/v1.2.1...v1.2.2) (2022-10-26) 50 | 51 | ### Others 52 | 53 | - **deps:** update dependencies ([71b987f](https://github.com/borjapazr/angular-skeleton/commit/71b987f46d21dbeaa0e6ae5c4730e99cc4a78b26)) 54 | - **deps:** update dependencies ([435f0c3](https://github.com/borjapazr/angular-skeleton/commit/435f0c33b7d8852e53475be16452f7e473b037e6)) 55 | - **deps:** update dependencies ([4e965b5](https://github.com/borjapazr/angular-skeleton/commit/4e965b58e1bcdd10c08990ceb94692d757719dd7)) 56 | 57 | ### [1.2.1](https://github.com/borjapazr/angular-skeleton/compare/v1.2.0...v1.2.1) (2022-10-04) 58 | 59 | ### Others 60 | 61 | - **deps:** update dependencies ([3f52014](https://github.com/borjapazr/angular-skeleton/commit/3f52014f449e27a889df852b2d4f6d8b1ac6b367)) 62 | - **deps:** update dependencies ([d53f914](https://github.com/borjapazr/angular-skeleton/commit/d53f9148081f5b5910bea7fa85ce336b05b1fc84)) 63 | - **deps:** update dependencies ([149ea43](https://github.com/borjapazr/angular-skeleton/commit/149ea4338dcd1a6085d902cecd8de10a23301cfb)) 64 | 65 | ## [1.2.0](https://github.com/borjapazr/angular-skeleton/compare/v1.1.5...v1.2.0) (2022-08-10) 66 | 67 | ### Features 68 | 69 | - **version:** show app version in header ([260c500](https://github.com/borjapazr/angular-skeleton/commit/260c500a72192f5fac2d3e192dc127f828bb36e6)) 70 | 71 | ### [1.1.5](https://github.com/borjapazr/angular-skeleton/compare/v1.1.4...v1.1.5) (2022-08-06) 72 | 73 | ### Bug Fixes 74 | 75 | - **transferstate:** fix deprecation warnings about transferstate (browser and server) ([2b3ef0c](https://github.com/borjapazr/angular-skeleton/commit/2b3ef0cc2ef53c446fd3d9a23b4f5d33aa0dba1a)) 76 | 77 | ### [1.1.4](https://github.com/borjapazr/angular-skeleton/compare/v1.1.3...v1.1.4) (2022-08-06) 78 | 79 | ### Others 80 | 81 | - **deps:** update angular dependencies ([26d0dac](https://github.com/borjapazr/angular-skeleton/commit/26d0dac77250d52af109e2756e43237aa1b49535)) 82 | - **deps:** update dependencies ([b221ca8](https://github.com/borjapazr/angular-skeleton/commit/b221ca82b63084940be49e3ee17a23bdbb1d514c)) 83 | - **deps:** update dependencies ([d1d1eaa](https://github.com/borjapazr/angular-skeleton/commit/d1d1eaa327e15ac4ab3a5fd5e18684c730d2ab38)) 84 | - **deps:** update dependencies ([f3198ad](https://github.com/borjapazr/angular-skeleton/commit/f3198ad000cf22d980d2042ef1053ba8166c1339)) 85 | - **eslint:** format tests section ([79b60d0](https://github.com/borjapazr/angular-skeleton/commit/79b60d074355951d1584afc552200eddd6eb771d)) 86 | 87 | ### [1.1.3](https://github.com/borjapazr/angular-skeleton/compare/v1.1.2...v1.1.3) (2022-07-26) 88 | 89 | ### Others 90 | 91 | - **ci:** add node 18.x validations ([5110965](https://github.com/borjapazr/angular-skeleton/commit/5110965f0a26a226794c5ed53a173237769e839a)) 92 | 93 | ### [1.1.2](https://github.com/borjapazr/angular-skeleton/compare/v1.1.1...v1.1.2) (2022-07-26) 94 | 95 | ### Others 96 | 97 | - **angular:** add allowed commonjs libs to angular config ([2be8210](https://github.com/borjapazr/angular-skeleton/commit/2be8210f4ed249a9e03b9a2b439ce7890830e00c)) 98 | - **deps:** update dependencies ([ea65e69](https://github.com/borjapazr/angular-skeleton/commit/ea65e697a86a9b94fdcb7e08bd99b2857353cae5)) 99 | - **deps:** update dependencies ([dc44cc5](https://github.com/borjapazr/angular-skeleton/commit/dc44cc59178b30c6ec50e1ec60c44444852f36bf)) 100 | 101 | ### [1.1.1](https://github.com/borjapazr/angular-skeleton/compare/v1.1.0...v1.1.1) (2022-07-11) 102 | 103 | ### Others 104 | 105 | - **deps:** update dependencies ([546193f](https://github.com/borjapazr/angular-skeleton/commit/546193f3a9afbb6b7e658bc8042dc84816307351)) 106 | 107 | ## [1.1.0](https://github.com/borjapazr/angular-skeleton/compare/v1.0.3...v1.1.0) (2022-06-30) 108 | 109 | ### Features 110 | 111 | - **footer:** add github link ([6fdd968](https://github.com/borjapazr/angular-skeleton/commit/6fdd968d65917628eb40ac870cd0638e34ef9c8c)) 112 | 113 | ### Docs 114 | 115 | - **readme:** add wip message in several sections ([797e8fa](https://github.com/borjapazr/angular-skeleton/commit/797e8faedf9266c7c0dafe00f2f095df738cc45c)) 116 | 117 | ### Others 118 | 119 | - **ci:** migrate to get-tsconfig ([9f903bb](https://github.com/borjapazr/angular-skeleton/commit/9f903bb2978f45a0085e1a9faca5e84849faf2ff)) 120 | 121 | ### [1.0.3](https://github.com/borjapazr/angular-skeleton/compare/v1.0.2...v1.0.3) (2022-06-26) 122 | 123 | ### Others 124 | 125 | - **deps:** update dependencies ([dd18d5b](https://github.com/borjapazr/angular-skeleton/commit/dd18d5b93e9fcc493032221a0756c84b13e76bed)) 126 | 127 | ### Docs 128 | 129 | - **readme:** add demo badge ([8b1b14c](https://github.com/borjapazr/angular-skeleton/commit/8b1b14cd59bfa73a6b6a2828b90677891dc3c457)) 130 | - **readme:** add documentation status badge ([e9b53d1](https://github.com/borjapazr/angular-skeleton/commit/e9b53d1556194e88c565299711545387eb2c5840)) 131 | 132 | ### [1.0.2](https://github.com/borjapazr/angular-skeleton/compare/v1.0.1...v1.0.2) (2022-06-22) 133 | 134 | ### Bug Fixes 135 | 136 | - **i18n:** change return value of localize route guard ([b5e3c31](https://github.com/borjapazr/angular-skeleton/commit/b5e3c310df5d13fd1802400d36501727240aa3fe)) 137 | 138 | ### Docs 139 | 140 | - **readme:** add content to features section ([5f040ce](https://github.com/borjapazr/angular-skeleton/commit/5f040ce0f6ff4e8bf158ffe77b420d21a99a6b14)) 141 | 142 | ### [1.0.1](https://github.com/borjapazr/angular-skeleton/compare/v1.0.0...v1.0.1) (2022-06-22) 143 | 144 | ### Bug Fixes 145 | 146 | - **router:** fix i18n guard in order to replace state ([bf9fe42](https://github.com/borjapazr/angular-skeleton/commit/bf9fe42eb697f7bae237485e4198ba8b9087eba6)) 147 | 148 | ### Docs 149 | 150 | - **readme:** add a README.md template ([7f82c10](https://github.com/borjapazr/angular-skeleton/commit/7f82c10c5c6fe507fc0db2139683c604a5381392)) 151 | - **readme:** add lighthouse report image ([84e2e94](https://github.com/borjapazr/angular-skeleton/commit/84e2e94cfabbcf98980ef6e1f3e34d4dd29dea2b)) 152 | - **readme:** update lighthouse image ([1dd2775](https://github.com/borjapazr/angular-skeleton/commit/1dd2775b81ab8df8108f70e79b67604605ec58b2)) 153 | 154 | ## [1.0.0](https://github.com/borjapazr/angular-skeleton/compare/v0.0.2...v1.0.0) (2022-06-21) 155 | 156 | ### Bug Fixes 157 | 158 | - **footer:** remove author on small devices ([a82f374](https://github.com/borjapazr/angular-skeleton/commit/a82f37450889895abb475160e1017184275d942c)) 159 | 160 | ### [0.0.2](https://github.com/borjapazr/angular-skeleton/compare/v0.0.1...v0.0.2) (2022-06-21) 161 | 162 | ### Features 163 | 164 | - **cd:** add deploy step ([ad44e9d](https://github.com/borjapazr/angular-skeleton/commit/ad44e9df310479521f3576015534c0a81fdfcbad)) 165 | 166 | ### [0.0.1](https://github.com/borjapazr/angular-skeleton/compare/v0.0.0...v0.0.1) (2022-06-21) 167 | 168 | ### Bug Fixes 169 | 170 | - **cd:** add permissions to cd workflow ([77b5376](https://github.com/borjapazr/angular-skeleton/commit/77b5376ab366b27f769296746baea0b9115e7696)) 171 | 172 | ## 0.0.0 (2022-06-21) 173 | 174 | ### Features 175 | 176 | - **cd:** add job to publish docker image ([9e16cff](https://github.com/borjapazr/angular-skeleton/commit/9e16cffb0dbc7cb2d1f2409f0b0ac29fc7b06974)) 177 | 178 | ### Others 179 | 180 | - **init:** let's start this party ([dcf4544](https://github.com/borjapazr/angular-skeleton/commit/dcf4544c39ceb9e9eaa9a08c10336dadc0a83daa)) 181 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Borja Paz Rodríguez borjapazr@gmail.com 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## Include .env file 2 | include .env 3 | 4 | ## Root directory 5 | ROOT_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) 6 | 7 | ## Set 'bash' as default shell 8 | SHELL := $(shell which bash) 9 | 10 | ## Set 'help' target as the default goal 11 | .DEFAULT_GOAL := help 12 | 13 | .PHONY: help 14 | help: ## Show this help 15 | @egrep -h '^[a-zA-Z0-9_\/-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -d | awk 'BEGIN {FS = ":.*?## "; printf "Usage: make \033[0;34mTARGET\033[0m \033[0;35m[ARGUMENTS]\033[0m\n\n"; printf "Targets:\n"}; {printf " \033[33m%-25s\033[0m \033[0;32m%s\033[0m\n", $$1, $$2}' 16 | 17 | ## Target specific variables 18 | %/csr: MODE = csr 19 | %/ssr: MODE = ssr 20 | build/%: TAG ?= $(MODE) 21 | 22 | .PHONY: build/csr build/ssr 23 | build/csr: ## Build csr image 24 | build/ssr: ## Build ssr image 25 | build/csr build/ssr: 26 | @echo "📦 Building project Docker image..." 27 | @docker build --build-arg PORT=$(PORT) --target $(MODE) -t $(APP_NAME):$(TAG) -f ./docker/Dockerfile . 28 | 29 | .PHONY: start/csr start/ssr 30 | start/csr: ## Start application in Client Side Rendering mode 31 | start/ssr: ## Start application in Server Side Rendering mode 32 | start/csr start/ssr: 33 | @echo "▶️ Starting app in $(MODE) mode (Docker)..." 34 | @docker-compose -f ./docker/docker-compose.$(MODE).yml --env-file .env up --build 35 | 36 | .PHONY: stop/csr stop/ssr 37 | stop/csr: ## Stop application in Client Side Rendering mode 38 | stop/ssr: ## Stop application in Server Side Rendering mode 39 | stop/csr stop/ssr: 40 | @echo "🛑 Stopping app..." 41 | @docker-compose -f ./docker/docker-compose.$(MODE).yml --env-file .env down 42 | 43 | .PHONY: clean/csr clean/ssr 44 | clean/csr: ## Clean CSR application 45 | clean/ssr: ## Clean SSR application 46 | clean/csr clean/ssr: 47 | @echo "🧼 Cleaning all resources..." 48 | @docker-compose -f ./docker/docker-compose.$(MODE).yml --env-file .env down --rmi local --volumes --remove-orphans 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Node.js, Typescript and Express template 6 |
7 | Node.js, Typescript and Express template 11 | 12 |
13 | 14 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/borjapazr/angular-skeleton?style=flat-square) 15 | ![GitHub CI Workflow Status](https://img.shields.io/github/actions/workflow/status/borjapazr/angular-skeleton/ci.yml?branch=main&style=flat-square&logo=github&label=CI) 16 | ![GitHub CD Workflow Status](https://img.shields.io/github/actions/workflow/status/borjapazr/angular-skeleton/cd.yml?branch=main&style=flat-square&logo=github&label=CD) 17 | ![GitHub LICENSE](https://img.shields.io/github/license/borjapazr/angular-skeleton?style=flat-square) 18 | [![Demo](https://img.shields.io/badge/demo-🎮-yellow.svg?style=flat-square)](https://angular-skeleton.marsmachine.space/) 19 | [![Documentation](https://img.shields.io/badge/documentation-80%25-orange?style=flat-square)](https://img.shields.io/badge/documentation-80%25-orange?style=flat-square) 20 | 21 |

22 | 🅰️🦸 Production-ready template for Progressive Web Applications implemented with Angular, TailwindCSS, Transloco, ngx-isr, etc. 23 |

24 | 25 | ℹ️ About • 26 | 📋 Features • 27 | 🤝 Contributing • 28 | 🛣️ Roadmap • 29 | 🎯 Credits • 30 | 🚩 License 31 | 32 |
33 | 34 | --- 35 | 36 | ## ℹ️ About 37 | 38 | The main goal of this project is to provide a base template for the generation of a production-ready web application made with `Angular`. The idea is to avoid having to configure all the tools involved in a project every time it is started and thus be able to focus on the definition and implementation of the business logic. 39 | 40 | > 📣 This is an opinionated template. The architecture of the code base and the configuration of the different tools used has been based on best practices and personal preferences. 41 | 42 | ### 🚀 Quick start 43 | 44 | - Start project in development mode: 45 | 46 | ```bash 47 | npm run start:dev 48 | ``` 49 | 50 | - Start project in production mode: 51 | 52 | ```bash 53 | npm run start:prod 54 | ``` 55 | 56 | ## 📋 Features 57 | 58 | - [Angular](https://angular.io/): Angular is a platform for building mobile and desktop web applications. 59 | - [Angular Universal](https://angular.io/guide/universal): Server-side rendering (SSR) with Angular Universal. 60 | - [TailwindCSS](https://tailwindcss.com/): A utility-first CSS framework packed with classes like flex, pt-4, text-center and rotate-90 that can be composed to build any design, directly in your markup. 61 | - [ng-lazyload-image](https://www.npmjs.com/package/ng-lazyload-image): A super small libary for lazy loading images for Angular apps with zero dependencies 62 | - [ngx-isr](https://www.npmjs.com/package/ngx-isr): Incremental Static Regeneration (ISR) enables developers and content editors to use static-generation on a per-page basis, without needing to rebuild the entire site. With ISR, you can retain the benefits of static while scaling to millions of pages. 63 | - i18n using [Transloco](https://ngneat.github.io/transloco/) 64 | - Unit tests using [Jest](https://github.com/facebook/jest) 65 | - e2e tests using [Cypress](https://www.cypress.io/) 66 | - [Spell check](https://github.com/streetsidesoftware/cspell) 67 | - Linting with [ESLint](https://github.com/eslint/eslint) 68 | - Formatting with [Prettier](https://github.com/prettier/prettier) 69 | - [Stylelint](https://stylelint.io/): A mighty, modern linter that helps you avoid errors and enforce conventions in your styles. 70 | - [HTMLHint](https://htmlhint.com/): A linter for HTML that helps you avoid errors and enforce conventions in your HTML. 71 | - Commit messages must meet conventional commits format 72 | - Git hooks with [Husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) 73 | - Containerised using [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) 74 | - GitHub Actions 75 | - Makefile as project entrypoint 76 | - A lot of emojis 🛸 77 | 78 | ### 🗂 Codebase structure 79 | 80 | ```txt 81 | angular-skeleton/ 82 | ├── .github/ 83 | ├── .husky/ 84 | ├── .vscode/ 85 | ├── cypress/ 86 | │ ├── e2e/ 87 | │ ├── fixtures/ 88 | │ ├── support/ 89 | │ ├── coverage.webpack.js 90 | │ └── tsconfig.json 91 | ├── docker/ 92 | ├── src/ 93 | │ ├── app/ 94 | │ │ ├── core/ 95 | │ │ │ ├── components/ 96 | │ │ │ ├── constants/ 97 | │ │ │ ├── enums/ 98 | │ │ │ ├── guards/ 99 | │ │ │ ├── handlers/ 100 | │ │ │ ├── interceptors/ 101 | │ │ │ ├── loaders/ 102 | │ │ │ ├── models/ 103 | │ │ │ ├── resolvers/ 104 | │ │ │ ├── services/ 105 | │ │ │ ├── strategies/ 106 | │ │ │ ├── tokens/ 107 | │ │ │ ├── utils/ 108 | │ │ │ ├── ... 109 | │ │ │ └── core.module.ts 110 | │ │ ├── features 111 | │ │ │ ├── feature-a 112 | │ │ │ │ ├── components/ 113 | │ │ │ │ ├── models/ 114 | │ │ │ │ ├── pages/ 115 | │ │ │ │ ├── services/ 116 | │ │ │ │ ├── ... 117 | │ │ │ │ ├── home-routing.module.ts 118 | │ │ │ │ └── home.module.ts 119 | │ │ │ ├── feature-b 120 | │ │ │ └── ... 121 | │ │ ├── shared/ 122 | │ │ │ ├── components/ 123 | │ │ │ ├── directives/ 124 | │ │ │ ├── modules/ 125 | │ │ │ ├── pipes/ 126 | │ │ │ ├── services/ 127 | │ │ │ └── shared.module.ts 128 | │ │ ├── app-routing.module.ts 129 | │ │ ├── app.browser.module.ts 130 | │ │ ├── app.component.html 131 | │ │ ├── app.component.scss 132 | │ │ ├── app.component.ts 133 | │ │ ├── app.module.ts 134 | │ │ └── app.server.module.ts 135 | │ ├── assets/ 136 | │ │ ├── i18n/ 137 | │ │ ├── icons/ 138 | │ │ └── images/ 139 | │ ├── environments/ 140 | │ ├── styles/ 141 | │ │ ├── abstracts/ 142 | │ │ ├── base/ 143 | │ │ ├── components/ 144 | │ │ ├── layout/ 145 | │ │ ├── vendors/ 146 | │ │ └── main.scss 147 | │ ├── types/ 148 | │ ├── favicon.ico 149 | │ ├── favicon.png 150 | │ ├── index.html 151 | │ ├── jest.mocks.ts 152 | │ ├── jest.setup.ts 153 | │ ├── main.browser.ts 154 | │ ├── main.server.ts 155 | │ ├── manifest.webmanifest 156 | │ ├── polyfills.ts 157 | │ ├── robots.txt 158 | │ └── styles.scss 159 | ├── .browserslistrc 160 | ├── .commitlintrc.js 161 | ├── .cspell.json 162 | ├── .czrc 163 | ├── .dockerignore 164 | ├── .editorconfig 165 | ├── .env 166 | ├── .eslintcache 167 | ├── .eslintignore 168 | ├── .eslintrc.js 169 | ├── .gitignore 170 | ├── .htmlhintrc 171 | ├── .lintstagedrc.js 172 | ├── .ncurc.js 173 | ├── .npmignore 174 | ├── .prettierignore 175 | ├── .prettierrc.js 176 | ├── .stylelintcache 177 | ├── .stylelintignore 178 | ├── .tool-versions 179 | ├── .versionrc.js 180 | ├── CHANGELOG.md 181 | ├── LICENSE 182 | ├── Makefile 183 | ├── README.md 184 | ├── TODO.md 185 | ├── angular.json 186 | ├── cypress.config.ts 187 | ├── jest.config.js 188 | ├── ngsw-config.json 189 | ├── nyc.config.js 190 | ├── package-lock.json 191 | ├── package.json 192 | ├── routes.txt 193 | ├── server.ts 194 | ├── stylelint.config.js 195 | ├── tailwind.config.js 196 | ├── transloco.config.js 197 | ├── tsconfig.app.json 198 | ├── tsconfig.json 199 | ├── tsconfig.server.json 200 | └── tsconfig.spec.json 201 | ``` 202 | 203 | ### 🎛️ Code style and best practices 204 | 205 | > ⚠️ This section has yet to be fully documented. 206 | 207 | - Prettier 208 | - ESLint 209 | - Stylelint 210 | - HTMLHint 211 | - commitlint 212 | 213 | ### 🛢 Barrel files 214 | 215 | Barrel files are used to organize exports. This significantly reduces the size of the import blocks. 216 | 217 | ### 🏞 Application layout 218 | 219 | > ⚠️ This section has yet to be fully documented. 220 | 221 | - Flexbox layout 222 | - TailwindCSS 223 | - Dark theme 224 | - Styles (SCSS) folder structure 225 | 226 | ### 🌐 Internationalization (i18n) 227 | 228 | > ⚠️ This section has yet to be fully documented. 229 | 230 | - Transloco 231 | - Route language prefixing 232 | 233 | ### 🏎 Server Side Rendering (SSR) and Incremental Static Rendering (ISR) 234 | 235 | > ⚠️ This section has yet to be fully documented. 236 | 237 | - Angular Universal 238 | - Domino 239 | - ngx-isr 240 | 241 | ### 📇 Prerendering 242 | 243 | > ⚠️ This section has yet to be fully documented. 244 | 245 | - Angular Universal 246 | 247 | ### 📈 SEO 248 | 249 | > ⚠️ This section has yet to be fully documented. 250 | 251 | - CustomPageTitleStrategy 252 | 253 | ### 🔰 Progressive Web Application (PWA) 254 | 255 | > ⚠️ This section has yet to be fully documented. 256 | 257 | - Service Worker configuration 258 | - Stale while revalidate strategy 259 | - Offline support 260 | 261 | ### 🌠 Image lazy-loading 262 | 263 | > ⚠️ This section has yet to be fully documented. 264 | 265 | - ng-lazyload-image 266 | 267 | ### 💨 Module preloading strategies 268 | 269 | > ⚠️ This section has yet to be fully documented. 270 | 271 | - [NoPreloading](https://angular.io/api/router/NoPreloading) (default) 272 | - [PreloadAllModules](https://angular.io/api/router/PreloadAllModules) 273 | - [CustomRoutePreloadStrategy](src/app/core/strategies/custom-route-preload.strategy.ts) 274 | - [NetworkAwareRoutePreloadingStrategy](src/app/core/strategies/network-aware-route-preload.strategy.ts) 275 | - [HoverPreloadStrategy](https://github.com/mgechev/ngx-hover-preload/blob/master/projects/ngx-hover-preload/src/lib/hover-preload.strategy.ts) 276 | - [QuicklinkStrategy](https://github.com/mgechev/ngx-quicklink/blob/master/src/quicklink-strategy.service.ts) 277 | 278 | ### 🛣 Route reusability 279 | 280 | - RouteReuseStrategy 281 | 282 | ### 🏒 Pipes 283 | 284 | > ⚠️ This section has yet to be fully documented. 285 | 286 | ### 🧪 Testing 287 | 288 | > ⚠️ This section has yet to be fully documented. 289 | 290 | #### Unit and integration tests 291 | 292 | - Jest 293 | - jest-extended 294 | 295 | #### e2e tests 296 | 297 | - Cypress 298 | 299 | ### 🐐 Makefile rules 300 | 301 | The main actions on this project are managed using a [Makefile](Makefile) as an entrypoint. 302 | 303 | ```bash 304 | Usage: make TARGET [ARGUMENTS] 305 | 306 | Targets: 307 | build/csr Build csr image 308 | build/ssr Build ssr image 309 | clean/csr Clean CSR application 310 | clean/ssr Clean SSR application 311 | help Show this help 312 | start/csr Start application in Client Side Rendering mode 313 | start/ssr Start application in Server Side Rendering mode 314 | stop/csr Stop application in Client Side Rendering mode 315 | stop/ssr Stop application in Server Side Rendering mode 316 | ``` 317 | 318 | ### ⚡ Scripts 319 | 320 | [package.json](package.json) scripts: 321 | 322 | ```json 323 | ... 324 | "scripts": { 325 | "start:dev": "ng serve --configuration development --port 4200 --open", 326 | "start:prod": "ng serve --configuration production --port 4300 --open", 327 | "start:ssr:dev": "ng run angular-skeleton:serve-ssr:development --port 4201 --open", 328 | "start:ssr:prod": "ng run angular-skeleton:serve-ssr:production --port 4301 --open", 329 | "build:dev": "rimraf dist && ng build --configuration development", 330 | "build:prod": "rimraf dist && ng build --configuration production && npm run build:optimize", 331 | "build:ssr:dev": "rimraf dist && ng build --configuration development && ng run angular-skeleton:server:development", 332 | "build:ssr:prod": "rimraf dist && ng build --configuration production && ng run angular-skeleton:server:production && npm run build:optimize", 333 | "build:prerender:dev": "rimraf dist && ng run angular-skeleton:prerender:development", 334 | "build:prerender:prod": "rimraf dist && ng run angular-skeleton:prerender:production && npm run build:optimize", 335 | "build:optimize": "run-s optimize:* && ngsw-config dist/browser ./ngsw-config.json", 336 | "optimize:i18n": "transloco-optimize dist/browser/assets/i18n", 337 | "serve:pwa": "http-server -p 4400 -P http://localhost:4400? dist/browser -o", 338 | "serve:ssr": "node dist/server/main.js", 339 | "i18n:extract": "transloco-keys-manager extract", 340 | "i18n:find": "transloco-keys-manager find", 341 | "check:types": "tsc --pretty --noEmit && tsc --project cypress/tsconfig.json --pretty --noEmit", 342 | "check:format": "prettier --check .", 343 | "check:lint": "eslint . --ext .js,.ts --color", 344 | "check:html": "htmlhint .", 345 | "check:scss": "stylelint 'src/**/*.{css,scss}' --color", 346 | "check:spelling": "cspell --config=.cspell.json \"{README.md,TODO.md,.github/*.md,src/**/*.ts,src/**/*.json}\"", 347 | "check:i18n": "transloco-validator src/assets/i18n/*.json src/assets/i18n/**/*.json", 348 | "check:staged": "lint-staged", 349 | "fix:format": "prettier --check --write --ignore-unknown .", 350 | "fix:lint": "npm run check:lint -- --fix", 351 | "fix:scss": "npm run check:scss -- --fix", 352 | "test": "cross-env NODE_ENV=test jest --verbose --colors --runInBand", 353 | "test:spec": "npm run test -- --testPathPattern=spec", 354 | "test:unit": "npm run test -- --testPathPattern=unit", 355 | "test:int": "npm run test -- --testPathPattern=integration", 356 | "e2e": "ng e2e", 357 | "e2e:run": "ng run angular-skeleton:cypress-run", 358 | "e2e:open": "ng run angular-skeleton:cypress-open", 359 | "e2e:coverage:view": "http-server -p 9004 ./coverage-e2e/lcov-report -o", 360 | "test:watch": "npm run test -- --watch", 361 | "test:coverage": "npm run test -- --coverage --silent", 362 | "test:coverage:view": "http-server -p 9003 ./coverage/lcov-report -o", 363 | "reset-hard": "git clean -dfx && git reset --hard && npm install", 364 | "version": "standard-version -t", 365 | "prepare-release": "run-s reset-hard version", 366 | "commit": "cz", 367 | "update-deps": "npm-check-updates -u", 368 | "prepare": "husky install" 369 | }, 370 | ... 371 | ``` 372 | 373 | ## 🤝 Contributing 374 | 375 | Just fork and open a pull request. All contributions are welcome 🤗 376 | 377 | ## 🛣️ Roadmap 378 | 379 | Please, check [TODO](TODO.md) for the current roadmap. 380 | 381 | ## 🎯 Credits 382 | 383 | To implement this project I have based myself on many similar projects. There were countless of them and I gave them all a star. 384 | 385 | 🙏 Thank you very much for these wonderful creations. 386 | 387 | ### ⭐ Stargazers 388 | 389 | [![Stargazers repo roster for @borjapazr/angular-skeleton](https://reporoster.com/stars/borjapazr/angular-skeleton)](https://github.com/borjapazr/angular-skeleton/stargazers) 390 | 391 | ## 🚩 License 392 | 393 | MIT @ [borjapazr](https://bpaz.dev). Please see [License](LICENSE) for more information. 394 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # 🛣️ Roadmap 2 | 3 | ### Todo 4 | 5 | - [ ] 📄 Configure the prerendering properly and fix the problem with Transloco 6 | - [ ] 📜 Document README.md 7 | - [ ] 📝 Document preloaders 8 | - [ ] 📝 Document Transloco find t(one.two.three) 9 | - [ ] 🚨 Remove deprecation warnings from RxJS 10 | 11 | ### In Progress 12 | 13 | - [ ] 🔄 Continuous improvement of the project 14 | 15 | ### Done ✓ 16 | 17 | - [x] 🥇 Create the first release of the project 18 | - [x] 📤 Add Barrel exports to all components 19 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-skeleton": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | }, 12 | "@schematics/angular:application": { 13 | "strict": true 14 | } 15 | }, 16 | "root": "", 17 | "sourceRoot": "src", 18 | "prefix": "app", 19 | "architect": { 20 | "build": { 21 | "builder": "@angular-devkit/build-angular:browser", 22 | "options": { 23 | "allowedCommonJsDependencies": ["flat", "fast-deep-equal", "@messageformat/core"], 24 | "outputPath": "dist/browser", 25 | "index": "src/index.html", 26 | "main": "src/main.browser.ts", 27 | "polyfills": "src/polyfills.ts", 28 | "tsConfig": "tsconfig.app.json", 29 | "inlineStyleLanguage": "scss", 30 | "assets": [ 31 | "src/robots.txt", 32 | "src/favicon.ico", 33 | "src/favicon.png", 34 | "src/assets", 35 | "src/manifest.webmanifest" 36 | ], 37 | "styles": ["src/styles.scss"], 38 | "scripts": [], 39 | "serviceWorker": true, 40 | "ngswConfigPath": "ngsw-config.json" 41 | }, 42 | "configurations": { 43 | "production": { 44 | "budgets": [ 45 | { 46 | "type": "initial", 47 | "maximumWarning": "500kb", 48 | "maximumError": "1mb" 49 | }, 50 | { 51 | "type": "anyComponentStyle", 52 | "maximumWarning": "2kb", 53 | "maximumError": "4kb" 54 | } 55 | ], 56 | "fileReplacements": [ 57 | { 58 | "replace": "src/environments/environment.ts", 59 | "with": "src/environments/environment.prod.ts" 60 | } 61 | ], 62 | "outputHashing": "all" 63 | }, 64 | "development": { 65 | "buildOptimizer": false, 66 | "optimization": false, 67 | "vendorChunk": true, 68 | "extractLicenses": false, 69 | "sourceMap": true, 70 | "namedChunks": true 71 | } 72 | }, 73 | "defaultConfiguration": "production" 74 | }, 75 | "serve": { 76 | "builder": "ngx-build-plus:dev-server", 77 | "configurations": { 78 | "production": { 79 | "browserTarget": "angular-skeleton:build:production" 80 | }, 81 | "development": { 82 | "browserTarget": "angular-skeleton:build:development", 83 | "extraWebpackConfig": "./cypress/coverage.webpack.js" 84 | } 85 | }, 86 | "defaultConfiguration": "development" 87 | }, 88 | "extract-i18n": { 89 | "builder": "@angular-devkit/build-angular:extract-i18n", 90 | "options": { 91 | "browserTarget": "angular-skeleton:build" 92 | } 93 | }, 94 | "lint": { 95 | "builder": "@angular-eslint/builder:lint", 96 | "options": { 97 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 98 | } 99 | }, 100 | "server": { 101 | "builder": "@angular-devkit/build-angular:server", 102 | "options": { 103 | "outputPath": "dist/server", 104 | "main": "server.ts", 105 | "tsConfig": "tsconfig.server.json", 106 | "inlineStyleLanguage": "scss" 107 | }, 108 | "configurations": { 109 | "production": { 110 | "outputHashing": "media", 111 | "fileReplacements": [ 112 | { 113 | "replace": "src/environments/environment.ts", 114 | "with": "src/environments/environment.prod.ts" 115 | } 116 | ] 117 | }, 118 | "development": { 119 | "optimization": false, 120 | "sourceMap": true, 121 | "extractLicenses": false 122 | } 123 | }, 124 | "defaultConfiguration": "production" 125 | }, 126 | "serve-ssr": { 127 | "builder": "@nguniversal/builders:ssr-dev-server", 128 | "configurations": { 129 | "development": { 130 | "browserTarget": "angular-skeleton:build:development", 131 | "serverTarget": "angular-skeleton:server:development" 132 | }, 133 | "production": { 134 | "browserTarget": "angular-skeleton:build:production", 135 | "serverTarget": "angular-skeleton:server:production" 136 | } 137 | }, 138 | "defaultConfiguration": "development" 139 | }, 140 | "prerender": { 141 | "builder": "@nguniversal/builders:prerender", 142 | "options": { 143 | "routesFile": "./routes.txt", 144 | "guessRoutes": false 145 | }, 146 | "configurations": { 147 | "production": { 148 | "browserTarget": "angular-skeleton:build:production", 149 | "serverTarget": "angular-skeleton:server:production" 150 | }, 151 | "development": { 152 | "browserTarget": "angular-skeleton:build:development", 153 | "serverTarget": "angular-skeleton:server:development" 154 | } 155 | }, 156 | "defaultConfiguration": "production" 157 | }, 158 | "test": { 159 | "builder": "@angular-builders/jest:run", 160 | "options": { 161 | "polyfills": ["src/polyfills.ts"], 162 | "tsConfig": "tsconfig.spec.json", 163 | "inlineStyleLanguage": ["scss"], 164 | "assets": [ 165 | "src/robots.txt", 166 | "src/favicon.ico", 167 | "src/favicon.png", 168 | "src/assets", 169 | "src/manifest.webmanifest" 170 | ], 171 | "styles": ["src/styles.css"], 172 | "scripts": [] 173 | } 174 | }, 175 | "cypress-run": { 176 | "builder": "@cypress/schematic:cypress", 177 | "options": { 178 | "devServerTarget": "angular-skeleton:serve" 179 | }, 180 | "configurations": { 181 | "production": { 182 | "devServerTarget": "angular-skeleton:serve:production" 183 | } 184 | } 185 | }, 186 | "cypress-open": { 187 | "builder": "@cypress/schematic:cypress", 188 | "options": { 189 | "watch": true, 190 | "headless": false 191 | } 192 | }, 193 | "e2e": { 194 | "builder": "@cypress/schematic:cypress", 195 | "options": { 196 | "devServerTarget": "angular-skeleton:serve", 197 | "watch": true, 198 | "headless": false 199 | }, 200 | "configurations": { 201 | "production": { 202 | "devServerTarget": "angular-skeleton:serve:production" 203 | } 204 | } 205 | } 206 | } 207 | } 208 | }, 209 | "cli": { 210 | "schematicCollections": ["@angular-eslint/schematics"], 211 | "analytics": false 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | defaultCommandTimeout: 10000, 5 | e2e: { 6 | baseUrl: 'http://localhost:4200', 7 | chromeWebSecurity: false, 8 | specPattern: 'cypress/e2e/**/*.{e2e,cy}.{js,ts}', 9 | setupNodeEvents: (on, config) => { 10 | require('@cypress/code-coverage/task')(on, config); 11 | return config; 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /cypress/coverage.webpack.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.(js|ts)$/, 6 | loader: '@skyux-sdk/istanbul-instrumenter-loader', 7 | options: { esModules: true }, 8 | enforce: 'post', 9 | include: require('path').join(__dirname, '..', 'src'), 10 | exclude: [/\.(e2e|spec|module|mock)\.ts$/, /node_modules/, /(ngfactory|ngstyle)\.js/] 11 | } 12 | ] 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /cypress/e2e/app.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Welcome page', () => { 2 | it(`should render 'Welcome' message`, () => { 3 | cy.visit('/'); 4 | cy.contains('Welcome!'); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /cypress/e2e/app.e2e.ts: -------------------------------------------------------------------------------- 1 | describe('Welcome page', () => { 2 | it(`should render 'Welcome' message`, () => { 3 | cy.visit('/'); 4 | cy.contains('Welcome!'); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "borjapazr@gmail.com", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example namespace declaration will help 3 | // with Intellisense and code completion in your 4 | // IDE or Text Editor. 5 | // *********************************************** 6 | // declare namespace Cypress { 7 | // interface Chainable { 8 | // customCommand(param: any): typeof customCommand; 9 | // } 10 | // } 11 | // 12 | // function customCommand(param: any): void { 13 | // console.warn(param); 14 | // } 15 | // 16 | // NOTE: You can use it like so: 17 | // Cypress.Commands.add('customCommand', customCommand); 18 | // 19 | // *********************************************** 20 | // This example commands.js shows you how to 21 | // create various custom commands and overwrite 22 | // existing commands. 23 | // 24 | // For more comprehensive examples of custom 25 | // commands please read more here: 26 | // https://on.cypress.io/custom-commands 27 | // *********************************************** 28 | // 29 | // 30 | // -- This is a parent command -- 31 | // Cypress.Commands.add("login", (email, password) => { ... }) 32 | // 33 | // 34 | // -- This is a child command -- 35 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 36 | // 37 | // 38 | // -- This is a dual command -- 39 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 40 | // 41 | // 42 | // -- This will overwrite an existing command -- 43 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 44 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // When a command from ./commands is ready to use, import with `import './commands'` syntax 17 | // import './commands'; 18 | 19 | import '@cypress/code-coverage/support'; 20 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": false, 4 | "types": ["cypress", "node"] 5 | }, 6 | "extends": "../tsconfig.json", 7 | "files": [], 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PORT=5000 2 | 3 | # Base image stage 4 | FROM node:18-alpine as node 5 | 6 | RUN apk --no-cache -U upgrade 7 | RUN apk update 8 | RUN apk add --no-cache curl 9 | RUN npm install npm@latest -g 10 | 11 | # Builder stage 12 | FROM node AS builder 13 | ENV PATH /code/node_modules/.bin:$PATH 14 | 15 | WORKDIR /code 16 | COPY package*.json ./ 17 | RUN npm install --omit=optional --ignore-scripts && npm cache clean --force 18 | 19 | WORKDIR /code/app 20 | COPY . ./ 21 | 22 | # csr builder stage 23 | FROM builder as csr-builder 24 | RUN npm run build:prod 25 | 26 | # ssr builder stage 27 | FROM builder as ssr-builder 28 | RUN npm run build:ssr:prod 29 | 30 | # csr stage 31 | FROM nginx:alpine as csr 32 | 33 | LABEL maintainer "Borja Paz Rodríguez (@borjapazr)" 34 | 35 | ARG PORT 36 | ENV PORT $PORT 37 | 38 | EXPOSE $PORT 39 | 40 | RUN apk add --no-cache curl 41 | 42 | COPY --from=csr-builder /code/app/dist/browser /usr/share/nginx/html 43 | COPY ./docker/nginx/nginx.conf /etc/nginx/conf.d/default.template 44 | 45 | HEALTHCHECK --interval=30s --timeout=60s --start-period=10s --retries=3 CMD curl --fail localhost:$PORT || exit 1 46 | 47 | CMD ["/bin/sh" , "-c" , "envsubst \"`env | awk -F = '{printf \" \\\\$%s\", $1}'`\" < /etc/nginx/conf.d/default.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] 48 | 49 | # ssr stage 50 | FROM node as ssr 51 | 52 | LABEL maintainer "Borja Paz Rodríguez (@borjapazr)" 53 | 54 | ARG PORT 55 | ENV PORT $PORT 56 | ENV NODE_ENV=production 57 | ENV PATH /home/node/app/node_modules/.bin:$PATH 58 | 59 | EXPOSE $PORT 60 | 61 | WORKDIR /home/node/app 62 | 63 | RUN mkdir dist logs 64 | RUN chown -R node:node /home/node/app 65 | RUN npm install -g pm2 66 | 67 | USER node 68 | 69 | COPY --chown=node:node package*.json ./docker/pm2/process.json ./ 70 | COPY --chown=node:node --from=ssr-builder /code/app/dist ./dist 71 | 72 | RUN npm ci --production --ignore-scripts --omit=optional 73 | 74 | HEALTHCHECK --interval=30s --timeout=60s --start-period=10s --retries=3 CMD curl --fail localhost:$PORT || exit 1 75 | 76 | CMD pm2-runtime ./process.json 77 | -------------------------------------------------------------------------------- /docker/docker-compose.csr.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | angular-skeleton: 5 | build: 6 | context: .. 7 | args: 8 | - PORT=${PORT} 9 | dockerfile: ./docker/Dockerfile 10 | target: csr 11 | image: ${APP_NAME}:csr 12 | container_name: ${APP_NAME} 13 | restart: always 14 | env_file: 15 | - ../.env 16 | environment: 17 | - TZ=${TZ} 18 | ports: 19 | - ${EXTERNAL_PORT}:${PORT} 20 | 21 | networks: 22 | default: 23 | name: ${APP_NAME}-network 24 | -------------------------------------------------------------------------------- /docker/docker-compose.ssr.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | angular-skeleton: 5 | build: 6 | context: .. 7 | args: 8 | - PORT=${PORT} 9 | dockerfile: ./docker/Dockerfile 10 | target: ssr 11 | image: ${APP_NAME}:ssr 12 | container_name: ${APP_NAME} 13 | restart: always 14 | env_file: 15 | - ../.env 16 | environment: 17 | - TZ=${TZ} 18 | ports: 19 | - ${EXTERNAL_PORT}:${PORT} 20 | 21 | networks: 22 | default: 23 | name: ${APP_NAME}-network 24 | -------------------------------------------------------------------------------- /docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen ${PORT}; 3 | 4 | default_type application/octet-stream; 5 | sendfile on; 6 | 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | 10 | location / { 11 | try_files $uri $uri/ /index.html =404; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docker/pm2/process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "angular-skeleton", 5 | "script": "./dist/server/main.js", 6 | "exec_mode": "cluster", 7 | "instances": "max" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest'); 2 | const { compilerOptions } = require('get-tsconfig').getTsconfig('./tsconfig.json')['config']; 3 | 4 | module.exports = { 5 | /* Basic settings */ 6 | // Enable verbosity 7 | forceExit: false, 8 | verbose: true, 9 | // The root directory that Jest should scan for tests and modules within 10 | rootDir: './', 11 | // A list of paths to directories that Jest should use to search for files in 12 | roots: [''], 13 | testEnvironmentOptions: { 14 | NODE_ENV: 'test' 15 | }, 16 | testMatch: ['/src/**/?(*.)+(unit|int|spec|test).(ts|js)'], 17 | preset: 'jest-preset-angular', 18 | // Resolve 'paths' from tsconfig.json 19 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '' }), 20 | // Ignore paths and modules 21 | modulePathIgnorePatterns: ['/dist'], 22 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 23 | 24 | /* Bootstrap settings */ 25 | // Set initial config and enable jest-extended features 26 | globalSetup: 'jest-preset-angular/global-setup', 27 | setupFilesAfterEnv: ['/src/jest.setup.ts', 'jest-extended/all'], 28 | 29 | /* Global test settings */ 30 | // Automatically clear mock calls and instances between every test 31 | clearMocks: true, 32 | 33 | /* Coverage settings */ 34 | collectCoverage: false, 35 | // The directory where Jest should output its coverage files 36 | coverageDirectory: 'coverage', 37 | // An array of glob patterns indicating a set of files for which coverage information should be collected 38 | collectCoverageFrom: ['/src/**/*.ts'], 39 | coveragePathIgnorePatterns: ['/node_modules', '/src/types'], 40 | // Jest custom reporters 41 | reporters: ['default'] 42 | /* Uncomment if you want to set thresholds for code coverage 43 | coverageThreshold: { 44 | global: { 45 | branches: 100, 46 | functions: 100, 47 | lines: 100, 48 | statements: 100 49 | } 50 | } 51 | */ 52 | }; 53 | -------------------------------------------------------------------------------- /ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "appData": { 4 | "name": "Angular Skeleton" 5 | }, 6 | "index": "/index.html", 7 | "assetGroups": [ 8 | { 9 | "name": "app", 10 | "installMode": "prefetch", 11 | "updateMode": "prefetch", 12 | "resources": { 13 | "files": [ 14 | "/favicon.ico", 15 | "/favicon.png", 16 | "/index.html", 17 | "/manifest.webmanifest", 18 | "/*.css", 19 | "/*.js", 20 | "/assets/i18n/**" 21 | ] 22 | } 23 | }, 24 | { 25 | "name": "assets", 26 | "installMode": "lazy", 27 | "updateMode": "prefetch", 28 | "resources": { 29 | "files": ["/assets/**", "!/assets/i18n/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"] 30 | } 31 | }, 32 | { 33 | "name": "fonts", 34 | "installMode": "prefetch", 35 | "updateMode": "prefetch", 36 | "resources": { 37 | "files": ["/*.eot", "/*.svg", "/*.woff", "/*.woff2", "/*.ttf"], 38 | "urls": ["https://fonts.googleapis.com/**", "https://fonts.gstatic.com/**"] 39 | } 40 | } 41 | ], 42 | "dataGroups": [ 43 | { 44 | "name": "api-performance", 45 | "urls": ["https://ui-avatars.com/api/**"], 46 | "cacheConfig": { 47 | "strategy": "performance", 48 | "maxSize": 100, 49 | "maxAge": "1d", 50 | "cacheOpaqueResponses": true 51 | } 52 | }, 53 | { 54 | "name": "api-freshness", 55 | "urls": ["https://api.chucknorris.io/**"], 56 | "cacheConfig": { 57 | "strategy": "freshness", 58 | "maxSize": 100, 59 | "maxAge": "7h", 60 | "timeout": "0u" 61 | } 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'report-dir': 'coverage-e2e', 3 | extension: ['.ts'], 4 | require: ['ts-node/register'] 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-skeleton", 3 | "version": "1.3.0", 4 | "description": "Angular Skeleton", 5 | "author": "Borja Paz Rodríguez (@borjapazr)", 6 | "license": "MIT", 7 | "keywords": [ 8 | "frontend", 9 | "angular", 10 | "skeleton" 11 | ], 12 | "repository": "https://github.com/borjapazr/angular-skeleton", 13 | "scripts": { 14 | "start:dev": "ng serve --configuration development --port 4200 --open", 15 | "start:prod": "ng serve --configuration production --port 4300 --open", 16 | "start:ssr:dev": "ng run angular-skeleton:serve-ssr:development --port 4201 --open", 17 | "start:ssr:prod": "ng run angular-skeleton:serve-ssr:production --port 4301 --open", 18 | "build:dev": "rimraf dist && ng build --configuration development", 19 | "build:prod": "rimraf dist && ng build --configuration production && npm run build:optimize", 20 | "build:ssr:dev": "rimraf dist && ng build --configuration development && ng run angular-skeleton:server:development", 21 | "build:ssr:prod": "rimraf dist && ng build --configuration production && ng run angular-skeleton:server:production && npm run build:optimize", 22 | "build:prerender:dev": "rimraf dist && ng run angular-skeleton:prerender:development", 23 | "build:prerender:prod": "rimraf dist && ng run angular-skeleton:prerender:production && npm run build:optimize", 24 | "build:optimize": "run-s optimize:* && ngsw-config dist/browser ./ngsw-config.json", 25 | "optimize:i18n": "transloco-optimize dist/browser/assets/i18n", 26 | "serve:pwa": "http-server -p 4400 -P http://localhost:4400? dist/browser -o", 27 | "serve:ssr": "node dist/server/main.js", 28 | "i18n:extract": "transloco-keys-manager extract", 29 | "i18n:find": "transloco-keys-manager find", 30 | "check:types": "tsc --pretty --noEmit && tsc --project cypress/tsconfig.json --pretty --noEmit", 31 | "check:format": "prettier --check .", 32 | "check:lint": "eslint . --ext .js,.ts --color", 33 | "check:html": "htmlhint .", 34 | "check:scss": "stylelint 'src/**/*.{css,scss}' --color", 35 | "check:spelling": "cspell --config=.cspell.json \"{README.md,TODO.md,.github/*.md,src/**/*.ts,src/**/*.json}\"", 36 | "check:i18n": "transloco-validator src/assets/i18n/*.json", 37 | "check:staged": "lint-staged", 38 | "fix:format": "prettier --check --write --ignore-unknown .", 39 | "fix:lint": "npm run check:lint -- --fix", 40 | "fix:scss": "npm run check:scss -- --fix", 41 | "test": "cross-env NODE_ENV=test jest --verbose --colors --runInBand", 42 | "test:spec": "npm run test -- --testPathPattern=spec", 43 | "test:unit": "npm run test -- --testPathPattern=unit", 44 | "test:int": "npm run test -- --testPathPattern=integration", 45 | "e2e": "ng e2e", 46 | "e2e:run": "ng run angular-skeleton:cypress-run", 47 | "e2e:open": "ng run angular-skeleton:cypress-open", 48 | "e2e:coverage:view": "http-server -p 9004 ./coverage-e2e/lcov-report -o", 49 | "test:watch": "npm run test -- --watch", 50 | "test:coverage": "npm run test -- --coverage --silent", 51 | "test:coverage:view": "http-server -p 9003 ./coverage/lcov-report -o", 52 | "reset-hard": "git clean -dfx && git reset --hard && npm install", 53 | "version": "standard-version -t", 54 | "prepare-release": "run-s reset-hard version", 55 | "commit": "cz", 56 | "update-deps": "npm-check-updates -u", 57 | "prepare": "husky install" 58 | }, 59 | "dependencies": { 60 | "@angular/animations": "^15.2.3", 61 | "@angular/common": "^15.2.3", 62 | "@angular/compiler": "^15.2.3", 63 | "@angular/core": "^15.2.3", 64 | "@angular/forms": "^15.2.3", 65 | "@angular/platform-browser": "^15.2.3", 66 | "@angular/platform-browser-dynamic": "^15.2.3", 67 | "@angular/platform-server": "^15.2.3", 68 | "@angular/router": "^15.2.3", 69 | "@angular/service-worker": "^15.2.3", 70 | "@ngneat/transloco": "^4.2.6", 71 | "@ngneat/transloco-locale": "^4.0.0", 72 | "@ngneat/transloco-messageformat": "^4.1.0", 73 | "@ngneat/transloco-preload-langs": "^4.0.1", 74 | "@ngneat/until-destroy": "^9.2.3", 75 | "@nguniversal/express-engine": "^15.2.0", 76 | "compression": "^1.7.4", 77 | "cookie-parser": "^1.4.6", 78 | "domino": "^2.1.6", 79 | "dotenv-defaults": "^5.0.2", 80 | "dotenv-expand": "^10.0.0", 81 | "endent": "^2.1.0", 82 | "express": "^4.18.2", 83 | "fast-deep-equal": "^3.1.3", 84 | "figlet": "^1.5.2", 85 | "http-status-codes": "^2.2.0", 86 | "intersection-observer": "^0.12.2", 87 | "just-is-empty": "^3.3.0", 88 | "messageformat": "^2.3.0", 89 | "ng-lazyload-image": "^9.1.3", 90 | "ngx-hover-preload": "^0.0.3", 91 | "ngx-isr": "^0.5.1", 92 | "ngx-quicklink": "0.4.1", 93 | "node-fetch": "^3.3.1", 94 | "rxjs": "~7.8.0", 95 | "tslib": "^2.5.0", 96 | "zone.js": "~0.13.0" 97 | }, 98 | "devDependencies": { 99 | "@angular-builders/jest": "^15.0.0", 100 | "@angular-devkit/build-angular": "^15.2.3", 101 | "@angular-eslint/builder": "15.2.1", 102 | "@angular-eslint/eslint-plugin": "15.2.1", 103 | "@angular-eslint/eslint-plugin-template": "15.2.1", 104 | "@angular-eslint/schematics": "15.2.1", 105 | "@angular-eslint/template-parser": "15.2.1", 106 | "@angular/cli": "~15.2.3", 107 | "@angular/compiler-cli": "^15.2.3", 108 | "@commitlint/cli": "^17.4.4", 109 | "@commitlint/config-conventional": "^17.4.4", 110 | "@cspell/dict-bash": "^4.1.1", 111 | "@cspell/dict-companies": "^3.0.9", 112 | "@cspell/dict-es-es": "^2.2.4", 113 | "@cspell/dict-filetypes": "^3.0.0", 114 | "@cspell/dict-html": "^4.0.3", 115 | "@cspell/dict-lorem-ipsum": "^3.0.0", 116 | "@cspell/dict-node": "^4.0.2", 117 | "@cspell/dict-npm": "^5.0.5", 118 | "@cspell/dict-software-terms": "^3.1.6", 119 | "@cspell/dict-typescript": "^3.1.1", 120 | "@cypress/code-coverage": "^3.10.0", 121 | "@cypress/schematic": "^2.5.0", 122 | "@ngneat/transloco-keys-manager": "^3.6.2", 123 | "@ngneat/transloco-optimize": "^3.0.2", 124 | "@ngneat/transloco-validator": "^3.0.1", 125 | "@nguniversal/builders": "^15.2.0", 126 | "@skyux-sdk/istanbul-instrumenter-loader": "^4.0.0", 127 | "@tailwindcss/aspect-ratio": "^0.4.2", 128 | "@tailwindcss/forms": "^0.5.3", 129 | "@tailwindcss/typography": "^0.5.9", 130 | "@types/compression": "^1.7.2", 131 | "@types/cookie-parser": "^1.4.3", 132 | "@types/dotenv-defaults": "^2.0.1", 133 | "@types/express": "^4.17.17", 134 | "@types/figlet": "^1.5.5", 135 | "@types/jest": "^28.1.3", 136 | "@types/node": "^14.15.0", 137 | "@typescript-eslint/eslint-plugin": "^5.56.0", 138 | "@typescript-eslint/parser": "^5.56.0", 139 | "autoprefixer": "^10.4.14", 140 | "commitizen": "^4.3.0", 141 | "cross-env": "^7.0.3", 142 | "cspell": "^6.30.2", 143 | "cypress": "^12.8.1", 144 | "cz-conventional-changelog": "^3.3.0", 145 | "eslint": "^8.36.0", 146 | "eslint-config-prettier": "^8.8.0", 147 | "eslint-import-resolver-typescript": "^3.5.3", 148 | "eslint-plugin-cypress": "^2.12.1", 149 | "eslint-plugin-deprecation": "^1.3.3", 150 | "eslint-plugin-eslint-comments": "^3.2.0", 151 | "eslint-plugin-import": "^2.27.5", 152 | "eslint-plugin-jest": "^27.2.1", 153 | "eslint-plugin-prefer-arrow": "^1.2.3", 154 | "eslint-plugin-prettier": "^4.2.1", 155 | "eslint-plugin-simple-import-sort": "^10.0.0", 156 | "eslint-plugin-sonarjs": "^0.19.0", 157 | "eslint-plugin-unused-imports": "^2.0.0", 158 | "get-tsconfig": "^4.4.0", 159 | "htmlhint": "^1.1.4", 160 | "http-server": "^14.1.1", 161 | "husky": "^8.0.3", 162 | "jest": "^28.1.3", 163 | "jest-extended": "^3.2.4", 164 | "jest-preset-angular": "^12.2.5", 165 | "lint-staged": "^13.2.0", 166 | "ngx-build-plus": "^15.0.0", 167 | "npm-check-updates": "^16.7.13", 168 | "npm-run-all": "^4.1.5", 169 | "postcss": "^8.4.21", 170 | "prettier": "^2.8.6", 171 | "prettier-eslint": "^15.0.1", 172 | "prettier-plugin-tailwindcss": "^0.2.5", 173 | "rimraf": "^4.4.0", 174 | "standard-version": "^9.5.0", 175 | "stylelint": "^15.3.0", 176 | "stylelint-config-recommended-scss": "^9.0.1", 177 | "stylelint-config-standard": "^31.0.0", 178 | "stylelint-config-standard-scss": "^7.0.1", 179 | "stylelint-no-unsupported-browser-features": "^6.1.0", 180 | "stylelint-prettier": "^3.0.0", 181 | "stylelint-scss": "^4.5.0", 182 | "tailwindcss": "^3.2.7", 183 | "ts-jest": "^28.0.8", 184 | "tsc-alias": "^1.8.4", 185 | "typescript": "~4.9.5" 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /routes.txt: -------------------------------------------------------------------------------- 1 | / 2 | /es 3 | /es/welcome 4 | /es/jokes 5 | /es/jokes/chuck-norris 6 | /en 7 | /en/welcome 8 | /en/jokes 9 | /en/jokes/chuck-norris 10 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/dist/zone-node'; 2 | 3 | import { APP_BASE_HREF } from '@angular/common'; 4 | import { ngExpressEngine } from '@nguniversal/express-engine'; 5 | import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; 6 | import * as compression from 'compression'; 7 | import * as cookieParser from 'cookie-parser'; 8 | import * as domino from 'domino'; 9 | import * as dotEnvConfig from 'dotenv-defaults/config'; 10 | import * as dotenvExpand from 'dotenv-expand'; 11 | import endent from 'endent'; 12 | import express, { Express } from 'express'; 13 | import figlet from 'figlet'; 14 | import standard from 'figlet/importable-fonts/Standard'; 15 | import { existsSync, readFileSync } from 'fs'; 16 | import { ISRHandler } from 'ngx-isr'; 17 | import fetch from 'node-fetch'; 18 | import { join } from 'path'; 19 | 20 | import { Logger } from '@core/services/logger.service'; 21 | import { environment } from '@env/environment'; 22 | 23 | dotenvExpand.expand(dotEnvConfig); 24 | 25 | const LOGGER = new Logger('server'); 26 | 27 | const PORT = process.env['PORT'] || 4000; 28 | const ISR_TOKEN = process.env['ISR_TOKEN'] || 'angular-skeleton'; 29 | const DIST_FOLDER = join(process.cwd(), 'dist/browser'); 30 | const INDEX_HTML = existsSync(join(DIST_FOLDER, 'index.original.html')) ? 'index.original.html' : 'index'; 31 | const TEMPLATE = readFileSync(join(DIST_FOLDER, 'index.html')).toString(); 32 | 33 | /** 34 | * window and document polyfills 35 | */ 36 | // Server-side DOM implementation 37 | const window: any = domino.createWindow(TEMPLATE); 38 | window.Object = Object; 39 | window.Math = Math; 40 | window.fetch = fetch; 41 | 42 | // Browser objects abstractions 43 | (global as any).window = window; 44 | Object.defineProperty(window.document.body.style, 'transform', { 45 | value: () => { 46 | return { 47 | configurable: true, 48 | enumerable: true 49 | }; 50 | } 51 | }); 52 | (global as any).document = window.document; 53 | (global as any).HTMLElement = window.HTMLElement; 54 | (global as any).HTMLElement.prototype.getBoundingClientRect = () => { 55 | return { 56 | left: '', 57 | right: '', 58 | top: '', 59 | bottom: '' 60 | }; 61 | }; 62 | 63 | // Other optional depending on application configuration 64 | (global as any).object = window.object; 65 | (global as any).navigator = window.navigator; 66 | (global as any).localStorage = window.localStorage; 67 | (global as any).sessionStorage = window.sessionStorage; 68 | (global as any).DOMTokenList = window.DOMTokenList; 69 | 70 | // eslint-disable-next-line import/first 71 | import { AppServerModule } from './src/main.server'; 72 | 73 | const app = (): Express => { 74 | const server = express(); 75 | 76 | server.use(compression()); 77 | server.use(express.json()); 78 | server.use(express.urlencoded({ extended: true })); 79 | server.use(cookieParser()); 80 | 81 | server.engine( 82 | 'html', 83 | ngExpressEngine({ 84 | bootstrap: AppServerModule 85 | }) 86 | ); 87 | server.set('view engine', 'html'); 88 | server.set('views', DIST_FOLDER); 89 | 90 | // Health check 91 | server.get('/healthz', (_req, res) => 92 | res.json({ 93 | status: 'ALIVE', 94 | message: `🚀 To infinity and beyond!` 95 | }) 96 | ); 97 | 98 | // Invalidate cached routes 99 | server.get('/invalidate', async (req, res) => await isr.invalidate(req, res)); 100 | 101 | // Serve static files from /browser 102 | server.get( 103 | '*.*', 104 | express.static(DIST_FOLDER, { 105 | maxAge: '1y' 106 | }) 107 | ); 108 | 109 | // All regular routes use the ISR Handler 110 | const isr = new ISRHandler({ 111 | indexHtml: INDEX_HTML, 112 | invalidateSecretToken: ISR_TOKEN, 113 | enableLogging: !environment.production 114 | }); 115 | 116 | server.get( 117 | '*', 118 | (req, _, next) => { 119 | (global as any).navigator = { userAgent: req['headers']['user-agent'] } as Navigator; 120 | next(); 121 | }, 122 | // Serve page if it exists in cache 123 | async (req, res, next) => await isr.serveFromCache(req, res, next), 124 | // Server side render the page and add to cache if needed 125 | async (req, res, next) => 126 | await isr.render(req, res, next, { 127 | providers: [ 128 | { provide: APP_BASE_HREF, useValue: req.baseUrl }, 129 | { provide: REQUEST, useValue: req }, 130 | { provide: RESPONSE, useValue: res } 131 | ] 132 | }) 133 | ); 134 | 135 | return server; 136 | }; 137 | 138 | const run = (): void => { 139 | app().listen(PORT, () => showBanner()); 140 | }; 141 | 142 | const showBanner = (): void => { 143 | figlet.parseFont('Standard', standard); 144 | 145 | const banner = endent`Application started successfully! 146 | ${figlet.textSync(environment.appName)} 147 | Name: 🧱 ${environment.appName} 148 | Description: 📖 ${environment.appDescription} 149 | Port: ${PORT} 150 | Base Path: / 151 | Environment: ${environment.production ? 'production' : 'development'} 152 | Author: ${environment.author} 153 | Email: ${environment.authorEmail} 154 | Website: ${environment.authorWebsite} 155 | Copyright © ${new Date().getFullYear()} ${environment.author}. All rights reserved. 156 | `; 157 | LOGGER.info(banner); 158 | }; 159 | 160 | // Webpack will replace 'require' with '__webpack_require__' 161 | // '__non_webpack_require__' is a proxy to Node 'require' 162 | // The below code is to ensure that the server is run only when not requiring the bundle. 163 | // eslint-disable-next-line no-undef 164 | declare const __non_webpack_require__: NodeRequire; 165 | const mainModule = __non_webpack_require__.main; 166 | const moduleFilename = (mainModule && mainModule.filename) || ''; 167 | if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { 168 | run(); 169 | } 170 | 171 | export { app }; 172 | export * from './src/main.server'; 173 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes, UrlMatcher, UrlSegment } from '@angular/router'; 3 | 4 | import { LANGUAGES } from './core/constants'; 5 | import { localizeRoute } from './core/guards'; 6 | import { languageResolver } from './core/resolvers'; 7 | import { CustomRoutePreloadingStrategy } from './core/strategies'; 8 | import { ErrorComponent } from './shared/components'; 9 | 10 | const languageMatcher: UrlMatcher = (url: UrlSegment[]) => { 11 | const isAllowedLanguage = 12 | Array.isArray(url) && url.length && LANGUAGES.map(language => language.id).includes(url[0].path); 13 | return isAllowedLanguage ? { consumed: url.slice(0, 1) } : null; 14 | }; 15 | 16 | /** 17 | * t(routes.default.title, routes.default.description) 18 | * t(routes.welcome.title, routes.welcome.description) 19 | * t(routes.jokes.title, routes.jokes.description) 20 | */ 21 | const routes: Routes = [ 22 | { path: '', redirectTo: 'welcome', pathMatch: 'full' }, 23 | { 24 | path: 'welcome', 25 | loadChildren: () => import('./features/home/home.module').then(m => m.HomeModule), 26 | data: { 27 | meta: { 28 | titleKey: 'routes.welcome.title', 29 | descriptionKey: 'routes.welcome.description' 30 | } 31 | } 32 | }, 33 | { 34 | path: 'jokes', 35 | loadChildren: () => import('./features/jokes/jokes.module').then(m => m.JokesModule), 36 | data: { 37 | preload: true, 38 | delayInSeconds: 10, 39 | meta: { 40 | titleKey: 'routes.jokes.title', 41 | descriptionKey: 'routes.jokes.description' 42 | } 43 | } 44 | }, 45 | { 46 | path: '**', 47 | component: ErrorComponent, 48 | data: { 49 | revalidate: 0 50 | } 51 | } 52 | ]; 53 | 54 | @NgModule({ 55 | imports: [ 56 | RouterModule.forRoot( 57 | [ 58 | { 59 | matcher: languageMatcher, 60 | children: routes, 61 | resolve: [languageResolver] 62 | }, 63 | { path: '', canActivate: [localizeRoute], children: routes } 64 | ], 65 | { 66 | initialNavigation: 'enabledBlocking', 67 | scrollPositionRestoration: 'enabled', 68 | preloadingStrategy: CustomRoutePreloadingStrategy 69 | } 70 | ) 71 | ], 72 | exports: [RouterModule] 73 | }) 74 | export class AppRoutingModule {} 75 | -------------------------------------------------------------------------------- /src/app/app.browser.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ServiceWorkerModule } from '@angular/service-worker'; 3 | import { TRANSLOCO_LOADER } from '@ngneat/transloco'; 4 | import { TranslocoPreloadLangsModule } from '@ngneat/transloco-preload-langs'; 5 | import { StateTransferInitializerModule } from '@nguniversal/common'; 6 | 7 | import { LANGUAGES } from '@core/constants'; 8 | import { I18nBrowserLoader } from '@core/loaders/i18n-browser.loader'; 9 | import { environment } from '@env/environment'; 10 | 11 | import { AppComponent } from './app.component'; 12 | import { AppModule } from './app.module'; 13 | 14 | @NgModule({ 15 | imports: [ 16 | // Application NgModule 17 | AppModule, 18 | 19 | // SSR - Angular Universal 20 | StateTransferInitializerModule, 21 | 22 | // i18n 23 | TranslocoPreloadLangsModule.forRoot(LANGUAGES.map(language => language.id)), 24 | 25 | // PWA - Service Worker 26 | ServiceWorkerModule.register('ngsw-worker.js', { 27 | enabled: environment.production, 28 | registrationStrategy: 'registerWhenStable:30000' 29 | }) 30 | ], 31 | providers: [{ provide: TRANSLOCO_LOADER, useClass: I18nBrowserLoader }], 32 | bootstrap: [AppComponent] 33 | }) 34 | export class AppBrowserModule {} 35 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 7 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/app.component.scss -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.scss'] 7 | }) 8 | export class AppComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { TransferHttpCacheModule } from '@nguniversal/common'; 4 | 5 | import { CoreModule } from '@core/core.module'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { AppRoutingModule } from './app-routing.module'; 9 | import { SharedModule } from './shared/shared.module'; 10 | 11 | @NgModule({ 12 | declarations: [AppComponent], 13 | imports: [ 14 | // SSR - Angular Universal 15 | BrowserModule.withServerTransition({ appId: 'angular-skeleton' }), 16 | TransferHttpCacheModule, 17 | 18 | // Application modules 19 | CoreModule.forRoot(), 20 | SharedModule.forRoot(), 21 | 22 | // Application routing 23 | AppRoutingModule 24 | ], 25 | providers: [], 26 | bootstrap: [AppComponent] 27 | }) 28 | export class AppModule {} 29 | -------------------------------------------------------------------------------- /src/app/app.server.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ServerModule } from '@angular/platform-server'; 3 | import { TRANSLOCO_LOADER } from '@ngneat/transloco'; 4 | import { NgxIsrModule } from 'ngx-isr'; 5 | 6 | import { I18nServerLoader } from '@core/loaders/i18n-server.loader'; 7 | 8 | import { AppComponent } from './app.component'; 9 | import { AppModule } from './app.module'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | // Application NgModule 14 | AppModule, 15 | 16 | // SSR - Angular Universal 17 | ServerModule, 18 | NgxIsrModule.forRoot() 19 | ], 20 | providers: [{ provide: TRANSLOCO_LOADER, useClass: I18nServerLoader }], 21 | bootstrap: [AppComponent] 22 | }) 23 | export class AppServerModule {} 24 | -------------------------------------------------------------------------------- /src/app/core/components/content/content.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/core/components/content/content.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/core/components/content/content.component.scss -------------------------------------------------------------------------------- /src/app/core/components/content/content.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | 4 | import { ContentComponent } from './content.component'; 5 | 6 | describe('Core -> ContentComponent', () => { 7 | let component: ContentComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | declarations: [ContentComponent], 13 | imports: [RouterTestingModule] 14 | }).compileComponents(); 15 | }); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(ContentComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/core/components/content/content.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-content', 5 | templateUrl: './content.component.html', 6 | styleUrls: ['./content.component.scss'] 7 | }) 8 | export class ContentComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | Made 5 | with using Angular 6 |
7 |
8 | 9 | {{ author }} 10 | 11 | copyright 12 | {{ year }} 13 |
14 |
15 | language 16 |
17 | {{ t(language['id']) }} 23 | 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | :host footer { 2 | @apply flex; 3 | 4 | .left, 5 | .right { 6 | @apply flex-1; 7 | } 8 | 9 | .left { 10 | @apply hidden align-middle sm:inline-block; 11 | 12 | app-github-icon { 13 | @apply -mt-1 mr-2 hidden align-middle text-stone-900 dark:text-stone-100 sm:inline-block; 14 | } 15 | 16 | app-heart-icon { 17 | @apply -mt-1 hidden align-middle text-red-500 sm:inline-block; 18 | } 19 | } 20 | 21 | .center { 22 | .material-symbols-outlined { 23 | @apply mx-2 -mt-1 align-middle; 24 | } 25 | 26 | .author { 27 | @apply hidden md:inline-block; 28 | } 29 | } 30 | 31 | .right { 32 | @apply text-right; 33 | 34 | .material-symbols-outlined { 35 | @apply -mt-1 hidden align-middle sm:inline-block; 36 | } 37 | 38 | div { 39 | @apply ml-4 inline-block; 40 | } 41 | 42 | span { 43 | @apply cursor-pointer; 44 | 45 | &:last-child { 46 | @apply m-4; 47 | } 48 | 49 | &.active { 50 | @apply border-2 border-solid border-stone-600 px-3 py-1.5 font-bold border-sketch-2 dark:border-stone-100; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { I18nService } from '@app/core/services'; 4 | import { LANGUAGES } from '@core/constants'; 5 | import { environment } from '@env/environment'; 6 | 7 | @Component({ 8 | selector: 'app-footer', 9 | templateUrl: './footer.component.html', 10 | styleUrls: ['./footer.component.scss'] 11 | }) 12 | export class FooterComponent { 13 | readonly author = environment.author; 14 | 15 | readonly year = new Date().getFullYear(); 16 | 17 | readonly website = environment.authorWebsite; 18 | 19 | readonly languages = LANGUAGES; 20 | 21 | readonly activeLanguage$ = this.i18nService.getActiveLanguage$(); 22 | 23 | constructor(private readonly i18nService: I18nService) {} 24 | 25 | public setActiveLanguage(language: string): void { 26 | this.i18nService.setActiveLanguage(language); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Avatar 12 |

{{ title }}

13 | v{{ version }} 14 |
15 |
16 |
17 | 21 | 22 | Avatar 30 |
31 |
32 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.scss: -------------------------------------------------------------------------------- 1 | header { 2 | @apply flex; 3 | 4 | .left, 5 | .right { 6 | @apply flex-1; 7 | 8 | button { 9 | @apply mr-4; 10 | } 11 | 12 | .material-symbols-outlined { 13 | @apply align-middle text-3xl; 14 | } 15 | } 16 | 17 | .left { 18 | @apply text-left; 19 | 20 | img { 21 | @apply -mt-4 mr-3 hidden w-12 cursor-pointer md:inline-block; 22 | } 23 | 24 | h1 { 25 | @apply mr-2 inline-block text-2xl md:text-4xl; 26 | } 27 | } 28 | 29 | .right { 30 | @apply text-right; 31 | 32 | img { 33 | @apply inline-block w-11 rounded-lg; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { Component, Inject } from '@angular/core'; 3 | 4 | import { Cookie } from '@app/core/enums'; 5 | import { StorageService } from '@app/shared/services'; 6 | import { environment } from '@env/environment'; 7 | 8 | @Component({ 9 | selector: 'app-header', 10 | templateUrl: './header.component.html', 11 | styleUrls: ['./header.component.scss'] 12 | }) 13 | export class HeaderComponent { 14 | public readonly logo = '/assets/images/logo.svg'; 15 | 16 | public readonly lightAvatar = 17 | 'https://ui-avatars.com/api/?background=222&color=f5f5f4&size=88&name=Borja+Paz&rounded=false&background=404040'; 18 | 19 | public readonly darkAvatar = 20 | 'https://ui-avatars.com/api/?background=222&color=404040&size=88&name=Borja+Paz&rounded=false&background=f5f5f4'; 21 | 22 | public readonly title = environment.appName; 23 | 24 | public readonly version = environment.appVersion; 25 | 26 | public isDarkMode = this.getIsDarkMode(); 27 | 28 | constructor(@Inject(DOCUMENT) private readonly document: Document, private readonly storageService: StorageService) { 29 | this.setIsDarkMode(this.isDarkMode); 30 | } 31 | 32 | public toggleDarkMode(): void { 33 | this.isDarkMode = !this.isDarkMode; 34 | this.setIsDarkMode(this.isDarkMode); 35 | } 36 | 37 | private getIsDarkMode(): boolean { 38 | return this.storageService.getCookie(Cookie.DARK_MODE) 39 | ? this.storageService.getCookie(Cookie.DARK_MODE) === '1' 40 | : environment.darkModeAsDefault; 41 | } 42 | 43 | private setIsDarkMode(enabled: boolean): void { 44 | this.storageService.saveCookie(Cookie.DARK_MODE, this.isDarkMode ? '1' : '0', 90); 45 | this.document.documentElement.className = enabled ? 'dark' : ''; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/core/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './content/content.component'; 2 | export * from './footer/footer.component'; 3 | export * from './header/header.component'; 4 | export * from './navbar/navbar.component'; 5 | -------------------------------------------------------------------------------- /src/app/core/components/navbar/navbar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 13 | -------------------------------------------------------------------------------- /src/app/core/components/navbar/navbar.component.scss: -------------------------------------------------------------------------------- 1 | :host ul { 2 | @apply m-0 list-none; 3 | 4 | li { 5 | @apply px-3 py-2.5 text-center; 6 | 7 | &.active { 8 | @apply border-2 border-solid border-stone-600 px-3 py-2.5 font-bold border-sketch-2 dark:border-stone-100; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/core/components/navbar/navbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { MENU_ENTRIES } from '@core/constants'; 4 | 5 | @Component({ 6 | selector: 'app-navbar', 7 | templateUrl: './navbar.component.html', 8 | styleUrls: ['./navbar.component.scss'] 9 | }) 10 | export class NavbarComponent { 11 | readonly menuEntries = MENU_ENTRIES; 12 | 13 | constructor() {} 14 | } 15 | -------------------------------------------------------------------------------- /src/app/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './languages'; 2 | export * from './menu-entries'; 3 | -------------------------------------------------------------------------------- /src/app/core/constants/languages.ts: -------------------------------------------------------------------------------- 1 | type Language = { id: string; name: string; locale: string }; 2 | 3 | const LANGUAGES: Language[] = [ 4 | { id: 'en', name: 'English', locale: 'en-US' }, 5 | { id: 'es', name: 'Spanish', locale: 'es-ES' } 6 | ]; 7 | 8 | export { Language, LANGUAGES }; 9 | -------------------------------------------------------------------------------- /src/app/core/constants/menu-entries.ts: -------------------------------------------------------------------------------- 1 | type MenuEntry = { labelId: string; link: string | string[] }; 2 | 3 | const MENU_ENTRIES: MenuEntry[] = [ 4 | { 5 | labelId: 'welcome', 6 | link: '/welcome' 7 | }, 8 | { 9 | labelId: 'jokes', 10 | link: ['/', 'jokes', 'chuck-norris'] 11 | }, 12 | { 13 | labelId: 'notFound', 14 | link: '/non-existent' 15 | } 16 | ]; 17 | 18 | export { MENU_ENTRIES, MenuEntry }; 19 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; 3 | import { APP_INITIALIZER, ErrorHandler, ModuleWithProviders, NgModule, Optional, SkipSelf, Type } from '@angular/core'; 4 | import { RouteReuseStrategy, RouterModule, TitleStrategy } from '@angular/router'; 5 | import { TRANSLOCO_CONFIG, translocoConfig, TranslocoModule } from '@ngneat/transloco'; 6 | import { TranslocoLocaleModule } from '@ngneat/transloco-locale'; 7 | import { TranslocoMessageFormatModule } from '@ngneat/transloco-messageformat'; 8 | import { LAZYLOAD_IMAGE_HOOKS } from 'ng-lazyload-image'; 9 | 10 | import { LANGUAGES } from '@core/constants/languages'; 11 | import { environment } from '@env/environment'; 12 | import { SharedModule } from '@shared/shared.module'; 13 | 14 | import { ContentComponent, FooterComponent, HeaderComponent, NavbarComponent } from './components'; 15 | import { EnsureModuleLoadedOnce } from './guards'; 16 | import { GlobalErrorHandler } from './handlers'; 17 | import { HttpErrorInterceptor } from './interceptors'; 18 | import { AppInitializer } from './services'; 19 | import { AppUpdater } from './services/app-updater.service'; 20 | import { CustomPageTitleStrategy, CustomRouteReuseStrategy } from './strategies'; 21 | import { CustomLazyLoadImageStrategy } from './strategies/custom-lazyload-image.strategy'; 22 | import { APP_NAME } from './tokens/app-name'; 23 | 24 | const SHARED_ITEMS: Type[] = [FooterComponent, HeaderComponent, ContentComponent, NavbarComponent]; 25 | 26 | const initializeApplication = (appInitializer: AppInitializer) => (): Promise => appInitializer.initialize(); 27 | 28 | const availableLanguages = LANGUAGES.map(language => ({ id: language.id, label: language.name })); 29 | const languagesToLocales = Object.assign({}, ...LANGUAGES.map(language => ({ [language.id]: language.locale }))); 30 | 31 | @NgModule({ 32 | declarations: [...SHARED_ITEMS], 33 | imports: [ 34 | // Angular modules 35 | CommonModule, 36 | RouterModule, 37 | HttpClientModule, 38 | 39 | // i18n 40 | TranslocoMessageFormatModule.forRoot({ 41 | enableCache: true 42 | }), 43 | TranslocoLocaleModule.forRoot({ 44 | defaultLocale: languagesToLocales[environment.defaultLanguage], 45 | langToLocaleMapping: languagesToLocales 46 | }), 47 | TranslocoModule, 48 | 49 | // Shared modules 50 | SharedModule 51 | ], 52 | exports: [HttpClientModule, ...SHARED_ITEMS] 53 | }) 54 | export class CoreModule extends EnsureModuleLoadedOnce { 55 | constructor(@Optional() @SkipSelf() parentModule: CoreModule, private appUpdater: AppUpdater) { 56 | super(parentModule as NgModule); 57 | this.appUpdater.handleAppUpdates(); 58 | } 59 | 60 | static forRoot(): ModuleWithProviders { 61 | return { 62 | ngModule: CoreModule, 63 | providers: [ 64 | { 65 | provide: APP_NAME, 66 | useValue: environment.appName 67 | }, 68 | { 69 | provide: APP_INITIALIZER, 70 | useFactory: initializeApplication, 71 | deps: [AppInitializer], 72 | multi: true 73 | }, 74 | { 75 | provide: ErrorHandler, 76 | useClass: GlobalErrorHandler 77 | }, 78 | { 79 | provide: HTTP_INTERCEPTORS, 80 | useClass: HttpErrorInterceptor, 81 | multi: true 82 | }, 83 | { 84 | provide: TRANSLOCO_CONFIG, 85 | useValue: translocoConfig({ 86 | availableLangs: availableLanguages, 87 | defaultLang: environment.defaultLanguage || 'en', 88 | flatten: { 89 | aot: environment.production 90 | }, 91 | prodMode: environment.production, 92 | reRenderOnLangChange: true, 93 | fallbackLang: environment.defaultLanguage || 'en' 94 | }) 95 | }, 96 | { 97 | provide: RouteReuseStrategy, 98 | useClass: CustomRouteReuseStrategy 99 | }, 100 | { 101 | provide: TitleStrategy, 102 | useClass: CustomPageTitleStrategy 103 | }, 104 | { 105 | provide: LAZYLOAD_IMAGE_HOOKS, 106 | useClass: CustomLazyLoadImageStrategy 107 | } 108 | ] 109 | }; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/core/enums/cookie.ts: -------------------------------------------------------------------------------- 1 | export enum Cookie { 2 | LANGUAGE = 'language', 3 | DARK_MODE = 'dark_mode' 4 | } 5 | -------------------------------------------------------------------------------- /src/app/core/enums/header.ts: -------------------------------------------------------------------------------- 1 | export enum Header { 2 | ACCEPT_LANGUAGE = 'accept-language' 3 | } 4 | -------------------------------------------------------------------------------- /src/app/core/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cookie'; 2 | export * from './header'; 3 | export * from './transfer-state-key'; 4 | -------------------------------------------------------------------------------- /src/app/core/enums/transfer-state-key.ts: -------------------------------------------------------------------------------- 1 | export enum TransferStateKey { 2 | TRANSLATIONS = 'transfer-translate' 3 | } 4 | -------------------------------------------------------------------------------- /src/app/core/guards/ensure-module-loaded-once.guard.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | class EnsureModuleLoadedOnce { 4 | constructor(targetModule: NgModule) { 5 | if (targetModule) { 6 | throwIfAlreadyLoaded(targetModule); 7 | } 8 | } 9 | } 10 | 11 | const throwIfAlreadyLoaded = (parentModule: NgModule): void => { 12 | if (parentModule) { 13 | throw new Error( 14 | `${parentModule.constructor.name} has already been loaded. Import this module in the AppModule only.` 15 | ); 16 | } 17 | }; 18 | 19 | export { EnsureModuleLoadedOnce, throwIfAlreadyLoaded }; 20 | -------------------------------------------------------------------------------- /src/app/core/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ensure-module-loaded-once.guard'; 2 | export * from './localize-route.guard'; 3 | -------------------------------------------------------------------------------- /src/app/core/guards/localize-route.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; 3 | import isEmpty from 'just-is-empty'; 4 | import { Observable } from 'rxjs'; 5 | 6 | import { PlatformService } from '@core/services'; 7 | import { I18nService } from '@core/services/i18n.service'; 8 | import { BrowserService, ServerService } from '@shared/services'; 9 | 10 | const localizeRoute = ( 11 | _route: ActivatedRouteSnapshot, 12 | state: RouterStateSnapshot, 13 | router: Router = inject(Router), 14 | i18nService: I18nService = inject(I18nService), 15 | platformService: PlatformService = inject(PlatformService), 16 | browserService: BrowserService = inject(BrowserService), 17 | serverService: ServerService = inject(ServerService) 18 | ): Observable | Promise | boolean | UrlTree => { 19 | const urlLanguage = i18nService.getUrlLanguage(state.url); 20 | 21 | if (isEmpty(urlLanguage)) { 22 | const availableLanguages = i18nService.getAvailableLanguages(); 23 | const cookieLanguage = i18nService.getCookieLanguage(); 24 | const browserLanguage = browserService.getBrowserLanguage() ?? serverService.getLanguageHeader(); 25 | 26 | const targetLanguage = [cookieLanguage, browserLanguage, i18nService.getDefaultLanguage()].find(language => 27 | availableLanguages.some(availableLanguage => availableLanguage.id === language) 28 | ); 29 | 30 | if (platformService.isBrowser) { 31 | /** 32 | * It is implemented in this way until this issue is resolved. 33 | * https://github.com/angular/angular/issues/27148 34 | */ 35 | router.navigateByUrl(`${targetLanguage}${state.url}`, { replaceUrl: true }); 36 | return false; 37 | } 38 | serverService.setRedirectResponse(`/${targetLanguage}${state.url}`, false); 39 | } 40 | 41 | return true; 42 | }; 43 | 44 | export { localizeRoute }; 45 | -------------------------------------------------------------------------------- /src/app/core/handlers/global-error.handler.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { ErrorHandler, Injectable } from '@angular/core'; 3 | 4 | import { Logger } from '@core/services'; 5 | import { environment } from '@env/environment'; 6 | 7 | const LOGGER = new Logger('GlobalErrorHandler'); 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class GlobalErrorHandler implements ErrorHandler { 13 | constructor() {} 14 | 15 | handleError(error: Error | HttpErrorResponse) { 16 | if (error instanceof HttpErrorResponse) { 17 | this.handleServerError(error); 18 | } else { 19 | this.handleClientError(error); 20 | } 21 | } 22 | 23 | private handleServerError(error: HttpErrorResponse) { 24 | if (!navigator.onLine) { 25 | LOGGER.error('No Internet Connection'); 26 | } 27 | 28 | if (!environment.production) { 29 | LOGGER.error('Http Error', error); 30 | } 31 | } 32 | 33 | private handleClientError(error: Error) { 34 | LOGGER.error(error); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './global-error.handler'; 2 | -------------------------------------------------------------------------------- /src/app/core/interceptors/http-error.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { catchError, Observable, retry } from 'rxjs'; 4 | 5 | import { AppBusService } from '@core/services'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class HttpErrorInterceptor implements HttpInterceptor { 11 | constructor(private readonly appBusService: AppBusService) {} 12 | 13 | intercept(request: HttpRequest, next: HttpHandler): Observable> { 14 | return next.handle(request).pipe( 15 | retry(1), 16 | catchError(error => this.handlerError(error)) 17 | ); 18 | } 19 | 20 | private handlerError(error: HttpErrorResponse): Observable> { 21 | this.appBusService.emitHttpError(error); 22 | throw error; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/core/interceptors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-error.interceptor'; 2 | -------------------------------------------------------------------------------- /src/app/core/loaders/i18n-browser.loader.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser'; 4 | import { Translation, TranslocoLoader } from '@ngneat/transloco'; 5 | import { Observable, Observer } from 'rxjs'; 6 | 7 | import { TransferStateKey } from '@core/enums'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class I18nBrowserLoader implements TranslocoLoader { 13 | constructor(private transferState: TransferState, private http: HttpClient) {} 14 | 15 | public getTranslation(language: string): Observable { 16 | const translationsKey: StateKey = makeStateKey(`${TransferStateKey.TRANSLATIONS}-${language}`); 17 | const translationsData: any = this.transferState.get(translationsKey, null); 18 | 19 | if (translationsData) { 20 | return new Observable((observer: Observer) => { 21 | observer.next(translationsData); 22 | observer.complete(); 23 | }); 24 | } 25 | return this.http.get(`./assets/i18n/${language}.json`); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/core/loaders/i18n-server.loader.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser'; 3 | import { Translation, TranslocoLoader } from '@ngneat/transloco'; 4 | import { readFileSync } from 'fs'; 5 | import { Observable, Observer } from 'rxjs'; 6 | 7 | import { TransferStateKey } from '@core/enums'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class I18nServerLoader implements TranslocoLoader { 13 | private readonly translationsFolder = 'dist/browser/assets/i18n'; 14 | 15 | private readonly translationFileExtension = '.json'; 16 | 17 | constructor(private transferState: TransferState) {} 18 | 19 | public getTranslation(language: string): Observable { 20 | return new Observable((observer: Observer) => { 21 | const translationsData: any = JSON.parse( 22 | readFileSync(`${this.translationsFolder}/${language}${this.translationFileExtension}`, 'utf8') 23 | ); 24 | const translationsKey: StateKey = makeStateKey(`${TransferStateKey.TRANSLATIONS}-${language}`); 25 | this.transferState.set(translationsKey, translationsData); 26 | observer.next(translationsData); 27 | observer.complete(); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/core/models/error-event.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatusCode } from '@angular/common/http'; 2 | 3 | export class ErrorEvent { 4 | public statusCode: HttpStatusCode; 5 | 6 | public statusText: string; 7 | 8 | public message: string; 9 | 10 | public url?: string | null; 11 | 12 | public timestamp?: Date | null; 13 | 14 | constructor( 15 | statusCode: HttpStatusCode, 16 | statusText: string, 17 | message: string, 18 | url?: string | null, 19 | timestamp?: Date | null 20 | ) { 21 | this.statusCode = statusCode; 22 | this.statusText = statusText; 23 | this.message = message; 24 | this.url = url; 25 | this.timestamp = timestamp; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/core/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-event'; 2 | -------------------------------------------------------------------------------- /src/app/core/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './language.resolver'; 2 | -------------------------------------------------------------------------------- /src/app/core/resolvers/language.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { I18nService } from '@core/services/i18n.service'; 6 | 7 | const languageResolver = ( 8 | _route: ActivatedRouteSnapshot, 9 | state: RouterStateSnapshot, 10 | i18nService: I18nService = inject(I18nService) 11 | ): string | Observable | Promise => { 12 | const urlLanguage = i18nService.getUrlLanguage(state.url) as string; 13 | const currentLanguage = i18nService.getActiveLanguage(); 14 | 15 | if (currentLanguage !== urlLanguage) { 16 | i18nService.initializeLanguage(urlLanguage); 17 | } 18 | 19 | return urlLanguage; 20 | }; 21 | 22 | export { languageResolver }; 23 | -------------------------------------------------------------------------------- /src/app/core/services/app-bus.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable, Subject } from 'rxjs'; 4 | 5 | import { ErrorEvent } from '@core/models'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class AppBusService { 11 | private errorSource = new Subject(); 12 | 13 | private error$ = this.errorSource.asObservable(); 14 | 15 | constructor() {} 16 | 17 | public handleHttpErrors(): Observable { 18 | return this.error$; 19 | } 20 | 21 | public clearHttpErrorsBus() { 22 | this.errorSource.next(null); 23 | } 24 | 25 | public emitHttpError(error: HttpErrorResponse) { 26 | this.errorSource.next(new ErrorEvent(error.status, error.statusText, error.error, error.url, new Date())); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/core/services/app-initializer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { environment } from '@env/environment'; 4 | 5 | import { Logger } from './logger.service'; 6 | import { PlatformService } from './platform.service'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class AppInitializer { 12 | constructor(private readonly platformService: PlatformService) {} 13 | 14 | initialize(): Promise { 15 | return new Promise(resolve => { 16 | if (environment.production) { 17 | Logger.enableProductionMode(); 18 | } 19 | this.showSelfXssWarningInBrowserConsole(); 20 | resolve('App initialized'); 21 | }); 22 | } 23 | 24 | private showSelfXssWarningInBrowserConsole() { 25 | if (this.platformService.isBrowser) { 26 | console.log('%c🚨 Stop!', 'font-weight:bold; font-size: 4em; color: red; '); 27 | console.log( 28 | `%cThis is a browser feature intended for developers. Using this console may allow attackers to impersonate you and steal your information using an attack called Self-XSS. Do not enter or paste code that you do not understand.`, 29 | 'font-weight:bold; font-size: 1.5em;' 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/core/services/app-updater.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-deprecated */ 2 | import { ApplicationRef, Injectable, Optional } from '@angular/core'; 3 | import { ActivatedRoute, Data, NavigationEnd, Router } from '@angular/router'; 4 | import { 5 | SwUpdate, 6 | VersionDetectedEvent, 7 | VersionEvent, 8 | VersionInstallationFailedEvent, 9 | VersionReadyEvent 10 | } from '@angular/service-worker'; 11 | import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; 12 | import { concat, filter, first, interval, map, mergeMap, switchMap, zip } from 'rxjs'; 13 | 14 | import { SeoService } from '@app/shared/services'; 15 | import { environment } from '@env/environment'; 16 | 17 | import { I18nService } from './i18n.service'; 18 | import { Logger } from './logger.service'; 19 | import { PlatformService } from './platform.service'; 20 | 21 | const LOGGER = new Logger('AppUpdater'); 22 | 23 | @Injectable({ 24 | providedIn: 'root' 25 | }) 26 | @UntilDestroy() 27 | export class AppUpdater { 28 | private readonly isBrowserAndServiceWorkerIsEnabled = () => 29 | this.platformService.isBrowser && this.swUpdate?.isEnabled; 30 | 31 | constructor( 32 | private platformService: PlatformService, 33 | private applicationRef: ApplicationRef, 34 | private readonly router: Router, 35 | private readonly activatedRoute: ActivatedRoute, 36 | private readonly i18nService: I18nService, 37 | private readonly seoService: SeoService, 38 | @Optional() private readonly swUpdate: SwUpdate 39 | ) { 40 | if (this.isBrowserAndServiceWorkerIsEnabled()) { 41 | const appIsStable$ = this.applicationRef.isStable.pipe(first(isStable => isStable === true)); 42 | const checkInterval$ = interval(environment.checkForUpdatesInterval * 1000); 43 | const checkIntervalOnceAppIsStable$ = concat(appIsStable$, checkInterval$); 44 | checkIntervalOnceAppIsStable$.pipe(untilDestroyed(this)).subscribe(() => this.swUpdate.checkForUpdate()); 45 | } 46 | } 47 | 48 | public handleAppUpdates(): void { 49 | this.handleNewVersionAndUpdateApp(); 50 | this.handleRouterChangesAndUpdateMeta(); 51 | } 52 | 53 | public handleNewVersionAndUpdateApp(): void { 54 | const eventHandler: Record void> = { 55 | VERSION_DETECTED: (event: VersionDetectedEvent) => { 56 | LOGGER.debug(`Downloading new app version: ${event.version.hash}`); 57 | }, 58 | VERSION_READY: (event: VersionReadyEvent) => { 59 | LOGGER.debug(`Current app version: ${event.currentVersion.hash}`); 60 | LOGGER.debug(`New app version ready for use: ${event.latestVersion.hash}`); 61 | this.swUpdate.activateUpdate().then(() => document.location.reload()); 62 | }, 63 | VERSION_INSTALLATION_FAILED: (event: VersionInstallationFailedEvent) => { 64 | LOGGER.debug(`Failed to install app version '${event.version.hash}': ${event.error}`); 65 | }, 66 | DEFAULT: (event: VersionEvent) => { 67 | LOGGER.debug(`Unknown event with type '${event.type}' handled`); 68 | } 69 | }; 70 | 71 | if (this.isBrowserAndServiceWorkerIsEnabled()) { 72 | this.swUpdate.versionUpdates.pipe(untilDestroyed(this)).subscribe((event: VersionEvent) => { 73 | (eventHandler[event.type] || eventHandler['DEFAULT'])(event); 74 | }); 75 | } 76 | } 77 | 78 | public handleRouterChangesAndUpdateMeta(): void { 79 | this.router.events 80 | .pipe( 81 | filter(event => event instanceof NavigationEnd), 82 | map(() => this.activatedRoute), 83 | map((route: ActivatedRoute) => { 84 | while (route.firstChild) route = route.firstChild; 85 | return route; 86 | }), 87 | filter((route: ActivatedRoute) => route.outlet === 'primary'), 88 | mergeMap((route: ActivatedRoute) => route.data), 89 | switchMap((data: Data) => { 90 | const { titleKey, descriptionKey } = data[`meta`] || {}; 91 | const title$ = titleKey 92 | ? this.i18nService.translate(titleKey) 93 | : this.i18nService.translate('routes.default.title'); 94 | const description$ = descriptionKey 95 | ? this.i18nService.translate(descriptionKey) 96 | : this.i18nService.translate('routes.default.description'); 97 | return zip(title$, description$); 98 | }), 99 | untilDestroyed(this) 100 | ) 101 | .subscribe(([title, description]): void => { 102 | this.seoService.setTitle(`${environment.appName} - ${title}`); 103 | this.seoService.setDescription(description); 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/app/core/services/cookie.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT, isPlatformBrowser } from '@angular/common'; 2 | import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core'; 3 | import { REQUEST } from '@nguniversal/express-engine/tokens'; 4 | import { Request } from 'express'; 5 | 6 | type SameSite = 'Lax' | 'None' | 'Strict'; 7 | 8 | interface CookieOptions { 9 | expires?: number | Date; 10 | path?: string; 11 | domain?: string; 12 | secure?: boolean; 13 | sameSite?: SameSite; 14 | } 15 | 16 | @Injectable({ 17 | providedIn: 'root' 18 | }) 19 | class CookieService { 20 | private readonly documentIsAccessible: boolean; 21 | 22 | constructor( 23 | @Inject(DOCUMENT) private document: Document, 24 | @Inject(PLATFORM_ID) private platformId: string, 25 | @Optional() @Inject(REQUEST) private request: Request 26 | ) { 27 | this.documentIsAccessible = isPlatformBrowser(this.platformId); 28 | } 29 | 30 | /** 31 | * Get cookie Regular Expression 32 | * 33 | * @param name Cookie name 34 | * @returns property RegExp 35 | * 36 | * @author: Stepan Suvorov 37 | * @since: 1.0.0 38 | */ 39 | private static getCookieRegExp(name: string): RegExp { 40 | const escapedName: string = name.replace(/([\[\]\{\}\(\)\|\=\;\+\?\,\.\*\^\$])/gi, '\\$1'); 41 | 42 | return new RegExp('(?:^' + escapedName + '|;\\s*' + escapedName + ')=(.*?)(?:;|$)', 'g'); 43 | } 44 | 45 | /** 46 | * Gets the unencoded version of an encoded component of a Uniform Resource Identifier (URI). 47 | * 48 | * @param encodedURIComponent A value representing an encoded URI component. 49 | * 50 | * @returns The unencoded version of an encoded component of a Uniform Resource Identifier (URI). 51 | * 52 | * @author: Stepan Suvorov 53 | * @since: 1.0.0 54 | */ 55 | private static safeDecodeURIComponent(encodedURIComponent: string): string { 56 | try { 57 | return decodeURIComponent(encodedURIComponent); 58 | } catch { 59 | // probably it is not uri encoded. return as is 60 | return encodedURIComponent; 61 | } 62 | } 63 | 64 | /** 65 | * Return `true` if {@link Document} is accessible, otherwise return `false` 66 | * 67 | * @param name Cookie name 68 | * @returns boolean - whether cookie with specified name exists 69 | * 70 | * @author: Stepan Suvorov 71 | * @since: 1.0.0 72 | */ 73 | check(name: string): boolean { 74 | name = encodeURIComponent(name); 75 | const regExp: RegExp = CookieService.getCookieRegExp(name); 76 | return regExp.test(this.getPlatformCookies()); 77 | } 78 | 79 | /** 80 | * Get cookies by name 81 | * 82 | * @param name Cookie name 83 | * @returns property value 84 | * 85 | * @author: Stepan Suvorov 86 | * @since: 1.0.0 87 | */ 88 | get(name: string): string { 89 | if (this.check(name)) { 90 | name = encodeURIComponent(name); 91 | 92 | const regExp: RegExp = CookieService.getCookieRegExp(name); 93 | const result: RegExpExecArray | null = regExp.exec(this.getPlatformCookies()); 94 | 95 | return result && result[1] ? CookieService.safeDecodeURIComponent(result[1]) : ''; 96 | } else { 97 | return ''; 98 | } 99 | } 100 | 101 | /** 102 | * Get all cookies in JSON format 103 | * 104 | * @returns all the cookies in json 105 | * 106 | * @author: Stepan Suvorov 107 | * @since: 1.0.0 108 | */ 109 | getAll(): { [key: string]: string } { 110 | const cookies: { [key: string]: string } = {}; 111 | const cookiesString: string = this.getPlatformCookies(); 112 | 113 | if (cookiesString) { 114 | cookiesString.split(';').forEach((currentCookie: string) => { 115 | const [cookieName, cookieValue] = currentCookie.split('='); 116 | cookies[CookieService.safeDecodeURIComponent(cookieName.replace(/^ /, ''))] = 117 | CookieService.safeDecodeURIComponent(cookieValue); 118 | }); 119 | } 120 | 121 | return cookies; 122 | } 123 | 124 | /** 125 | * Set cookie based on provided information 126 | * 127 | * @param name Cookie name 128 | * @param value Cookie value 129 | * @param expires Number of days until the cookies expires or an actual `Date` 130 | * @param path Cookie path 131 | * @param domain Cookie domain 132 | * @param secure Secure flag 133 | * @param sameSite OWASP samesite token `Lax`, `None`, or `Strict`. Defaults to `Lax` 134 | * 135 | * @author: Stepan Suvorov 136 | * @since: 1.0.0 137 | */ 138 | set( 139 | name: string, 140 | value: string, 141 | expires?: CookieOptions['expires'], 142 | path?: CookieOptions['path'], 143 | domain?: CookieOptions['domain'], 144 | secure?: CookieOptions['secure'], 145 | sameSite?: SameSite 146 | ): void; 147 | 148 | /** 149 | * Set cookie based on provided information 150 | * 151 | * Cookie's parameters: 152 | *
153 |    * expires  Number of days until the cookies expires or an actual `Date`
154 |    * path     Cookie path
155 |    * domain   Cookie domain
156 |    * secure   Secure flag
157 |    * sameSite OWASP samesite token `Lax`, `None`, or `Strict`. Defaults to `Lax`
158 |    * 
159 | * 160 | * @param name Cookie name 161 | * @param value Cookie value 162 | * @param options Body with cookie's params 163 | * 164 | * @author: Stepan Suvorov 165 | * @since: 1.0.0 166 | */ 167 | set(name: string, value: string, options?: CookieOptions): void; 168 | 169 | set( 170 | name: string, 171 | value: string, 172 | expiresOrOptions?: CookieOptions['expires'] | CookieOptions, 173 | path?: CookieOptions['path'], 174 | domain?: CookieOptions['domain'], 175 | secure?: CookieOptions['secure'], 176 | sameSite?: SameSite 177 | ): void { 178 | if (!this.documentIsAccessible) { 179 | return; 180 | } 181 | 182 | if ( 183 | typeof expiresOrOptions === 'number' || 184 | expiresOrOptions instanceof Date || 185 | path || 186 | domain || 187 | secure || 188 | sameSite 189 | ) { 190 | const optionsBody = { 191 | expires: expiresOrOptions as CookieOptions['expires'], 192 | path, 193 | domain, 194 | secure, 195 | sameSite: sameSite ? sameSite : 'Lax' 196 | }; 197 | 198 | this.set(name, value, optionsBody); 199 | return; 200 | } 201 | 202 | let cookieString: string = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';'; 203 | 204 | const options = expiresOrOptions ? expiresOrOptions : {}; 205 | 206 | if (options.expires) { 207 | if (typeof options.expires === 'number') { 208 | const dateExpires: Date = new Date(new Date().getTime() + options.expires * 1000 * 60 * 60 * 24); 209 | 210 | cookieString += 'expires=' + dateExpires.toUTCString() + ';'; 211 | } else { 212 | cookieString += 'expires=' + options.expires.toUTCString() + ';'; 213 | } 214 | } 215 | 216 | if (options.path) { 217 | cookieString += 'path=' + options.path + ';'; 218 | } 219 | 220 | if (options.domain) { 221 | cookieString += 'domain=' + options.domain + ';'; 222 | } 223 | 224 | if (options.secure === false && options.sameSite === 'None') { 225 | options.secure = true; 226 | console.warn( 227 | `[ngx-cookie-service] Cookie ${name} was forced with secure flag because sameSite=None.` + 228 | `More details : https://github.com/stevermeister/ngx-cookie-service/issues/86#issuecomment-597720130` 229 | ); 230 | } 231 | if (options.secure) { 232 | cookieString += 'secure;'; 233 | } 234 | 235 | if (!options.sameSite) { 236 | options.sameSite = 'Lax'; 237 | } 238 | 239 | cookieString += 'sameSite=' + options.sameSite + ';'; 240 | 241 | // this.document.cookie = cookieString 242 | this.setPlatformCookies(cookieString); 243 | } 244 | 245 | /** 246 | * Delete cookie by name 247 | * 248 | * @param name Cookie name 249 | * @param path Cookie path 250 | * @param domain Cookie domain 251 | * @param secure Cookie secure flag 252 | * @param sameSite Cookie sameSite flag - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite 253 | * 254 | * @author: Stepan Suvorov 255 | * @since: 1.0.0 256 | */ 257 | delete( 258 | name: string, 259 | path?: CookieOptions['path'], 260 | domain?: CookieOptions['domain'], 261 | secure?: CookieOptions['secure'], 262 | sameSite: SameSite = 'Lax' 263 | ): void { 264 | if (!this.documentIsAccessible) { 265 | return; 266 | } 267 | const expiresDate = new Date('Thu, 01 Jan 1970 00:00:01 GMT'); 268 | this.set(name, '', { expires: expiresDate, path, domain, secure, sameSite }); 269 | } 270 | 271 | /** 272 | * Delete all cookies 273 | * 274 | * @param path Cookie path 275 | * @param domain Cookie domain 276 | * @param secure Is the Cookie secure 277 | * @param sameSite Is the cookie same site 278 | * 279 | * @author: Stepan Suvorov 280 | * @since: 1.0.0 281 | */ 282 | deleteAll( 283 | path?: CookieOptions['path'], 284 | domain?: CookieOptions['domain'], 285 | secure?: CookieOptions['secure'], 286 | sameSite: SameSite = 'Lax' 287 | ): void { 288 | if (!this.documentIsAccessible) { 289 | return; 290 | } 291 | 292 | const cookies: any = this.getAll(); 293 | 294 | for (const cookieName in cookies) { 295 | if (cookies.hasOwnProperty(cookieName)) { 296 | this.delete(cookieName, path, domain, secure, sameSite); 297 | } 298 | } 299 | } 300 | 301 | getPlatformCookies(): string { 302 | let cookies = ''; 303 | if (this.documentIsAccessible) { 304 | cookies = this.document.cookie; 305 | } else { 306 | cookies = this.request?.headers?.cookie != undefined ? this.request.headers.cookie : ''; 307 | } 308 | return cookies; 309 | } 310 | 311 | setPlatformCookies(cookieString: string) { 312 | if (this.documentIsAccessible) { 313 | this.document.cookie = cookieString; 314 | } else { 315 | this.request.headers.cookie = cookieString; 316 | } 317 | } 318 | } 319 | 320 | export { CookieOptions, CookieService, SameSite }; 321 | -------------------------------------------------------------------------------- /src/app/core/services/i18n.service.ts: -------------------------------------------------------------------------------- 1 | import { Location } from '@angular/common'; 2 | import { Injectable } from '@angular/core'; 3 | import { Router } from '@angular/router'; 4 | import { TranslocoService } from '@ngneat/transloco'; 5 | import { Observable } from 'rxjs'; 6 | 7 | import { Language, LANGUAGES } from '@core/constants'; 8 | import { Cookie } from '@core/enums'; 9 | import { SeoService, StorageService } from '@shared/services'; 10 | 11 | @Injectable({ 12 | providedIn: 'root' 13 | }) 14 | export class I18nService { 15 | private readonly availableLanguages = LANGUAGES; 16 | 17 | constructor( 18 | private readonly translocoService: TranslocoService, 19 | private readonly router: Router, 20 | private readonly location: Location, 21 | private readonly storageService: StorageService, 22 | private readonly seoService: SeoService 23 | ) {} 24 | 25 | public getAvailableLanguages(): Language[] { 26 | return this.availableLanguages; 27 | } 28 | 29 | public getDefaultLanguage(): string { 30 | return this.translocoService.getDefaultLang(); 31 | } 32 | 33 | public getActiveLanguage(): string { 34 | return this.translocoService.getActiveLang(); 35 | } 36 | 37 | public getActiveLanguage$(): Observable { 38 | return this.translocoService.langChanges$; 39 | } 40 | 41 | public initializeLanguage(language: string): void { 42 | this.translocoService.setDefaultLang(language); 43 | this.translocoService.setActiveLang(language); 44 | this.seoService.setLanguage(language); 45 | this.saveCookieLanguage(language); 46 | } 47 | 48 | public setActiveLanguage(language: string): void { 49 | this.translocoService.setActiveLang(language); 50 | this.seoService.setLanguage(language); 51 | this.saveCookieLanguage(language); 52 | this.router.navigate([language, ...this.router.url.split('/').slice(2)]); 53 | } 54 | 55 | public translate(key: string): Observable { 56 | return this.translocoService.selectTranslate(key); 57 | } 58 | 59 | public getUrlLanguage(url?: string): string | null { 60 | const pathSlices = (url || this.location.path() || '').split('#')[0].split('?')[0].split('/'); 61 | if (pathSlices.length > 1 && this.availableLanguages.some(language => language.id === pathSlices[1])) { 62 | return pathSlices[1]; 63 | } 64 | if (pathSlices.length && this.availableLanguages.some(language => language.id === pathSlices[0])) { 65 | return pathSlices[0]; 66 | } 67 | return null; 68 | } 69 | 70 | public getCookieLanguage(): string | undefined { 71 | return this.storageService.getCookie(Cookie.LANGUAGE); 72 | } 73 | 74 | public saveCookieLanguage(language: string): void { 75 | this.storageService.saveCookie(Cookie.LANGUAGE, language, 7); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/core/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-bus.service'; 2 | export * from './app-initializer.service'; 3 | export * from './app-updater.service'; 4 | export * from './i18n.service'; 5 | export * from './logger.service'; 6 | export * from './platform.service'; 7 | -------------------------------------------------------------------------------- /src/app/core/services/logger.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger, LogLevel, LogOutput } from './logger.service'; 2 | 3 | const logMethods = ['log', 'info', 'warn', 'error']; 4 | 5 | describe('Core -> Logger', () => { 6 | let savedConsole: any[]; 7 | let savedLevel: LogLevel; 8 | let savedOutputs: LogOutput[]; 9 | 10 | beforeAll(() => { 11 | savedConsole = []; 12 | logMethods.forEach((m: any) => { 13 | savedConsole[m] = console[m as keyof Console]; 14 | console[m as keyof Console] = jest.fn(); 15 | }); 16 | savedLevel = Logger.level; 17 | savedOutputs = Logger.outputs; 18 | }); 19 | 20 | beforeEach(() => { 21 | Logger.level = LogLevel.DEBUG; 22 | }); 23 | 24 | afterAll(() => { 25 | logMethods.forEach((m: any) => { 26 | console[m as keyof Console] = savedConsole[m]; 27 | }); 28 | Logger.level = savedLevel; 29 | Logger.outputs = savedOutputs; 30 | }); 31 | 32 | it('should create an instance', () => { 33 | expect(new Logger()).toBeTruthy(); 34 | }); 35 | 36 | it('should add a new LogOutput and receives log entries', () => { 37 | const outputSpy = jest.fn(); 38 | const log = new Logger('test'); 39 | 40 | Logger.outputs.push(outputSpy); 41 | 42 | log.debug('d'); 43 | log.info('i'); 44 | log.warn('w'); 45 | log.error('e', { error: true }); 46 | 47 | expect(outputSpy).toHaveBeenCalled(); 48 | expect(outputSpy.mock.calls).toHaveLength(4); 49 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.DEBUG, 'd'); 50 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.INFO, 'i'); 51 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.WARNING, 'w'); 52 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.ERROR, 'e', { error: true }); 53 | }); 54 | 55 | it('should add a new LogOutput and receives only production log entries', () => { 56 | // Arrange 57 | const outputSpy = jest.fn(); 58 | const log = new Logger('test'); 59 | 60 | Logger.outputs.push(outputSpy); 61 | Logger.enableProductionMode(); 62 | 63 | log.debug('d'); 64 | log.info('i'); 65 | log.warn('w'); 66 | log.error('e', { error: true }); 67 | 68 | expect(outputSpy).toHaveBeenCalled(); 69 | expect(outputSpy.mock.calls).toHaveLength(2); 70 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.WARNING, 'w'); 71 | expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.ERROR, 'e', { error: true }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/app/core/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple logger system with the possibility of registering custom outputs. 3 | * 4 | * 4 different log levels are provided, with corresponding methods: 5 | * - DEBUG : for debug information 6 | * - INFO : for informative status of the application (success, ...) 7 | * - WARNING : for non-critical errors that do not prevent normal application behavior 8 | * - ERROR : for critical errors that prevent normal application behavior 9 | * 10 | * Example usage: 11 | * ``` 12 | * import { Logger } from '@core/services/logger.service'; 13 | * 14 | * const LOGGER = new Logger('myFile'); 15 | * ... 16 | * logger.debug('something happened'); 17 | * ``` 18 | * 19 | * If you want to process logs through other outputs than console, you can add LogOutput functions to Logger.outputs. 20 | */ 21 | 22 | enum LogLevel { 23 | OFF = 0, 24 | ERROR = 1, 25 | WARNING = 2, 26 | INFO = 3, 27 | DEBUG = 4 28 | } 29 | 30 | type LogOutput = (source: string | undefined, level: LogLevel, ...objects: any[]) => void; 31 | 32 | class Logger { 33 | static level = LogLevel.DEBUG; 34 | 35 | static outputs: LogOutput[] = []; 36 | 37 | constructor(private source?: string) {} 38 | 39 | static enableProductionMode() { 40 | Logger.level = LogLevel.WARNING; 41 | } 42 | 43 | debug(...objects: any[]) { 44 | this.log(console.log, LogLevel.DEBUG, objects); 45 | } 46 | 47 | info(...objects: any[]) { 48 | this.log(console.info, LogLevel.INFO, objects); 49 | } 50 | 51 | warn(...objects: any[]) { 52 | this.log(console.warn, LogLevel.WARNING, objects); 53 | } 54 | 55 | error(...objects: any[]) { 56 | this.log(console.error, LogLevel.ERROR, objects); 57 | } 58 | 59 | private log(func: (...args: any[]) => void, level: LogLevel, objects: any[]) { 60 | if (level <= Logger.level) { 61 | const log = this.source ? ['[' + this.source + ']'].concat(objects) : objects; 62 | func.apply(console, log); 63 | Logger.outputs.forEach(output => output.apply(output, [this.source, level, ...objects])); 64 | } 65 | } 66 | } 67 | 68 | export { Logger, LogLevel, LogOutput }; 69 | -------------------------------------------------------------------------------- /src/app/core/services/platform.service.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser, isPlatformServer } from '@angular/common'; 2 | import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class PlatformService { 8 | public readonly isBrowser = isPlatformBrowser(this.platformId); 9 | 10 | public readonly isServer = isPlatformServer(this.platformId); 11 | 12 | constructor(@Inject(PLATFORM_ID) private platformId: any) {} 13 | } 14 | -------------------------------------------------------------------------------- /src/app/core/strategies/custom-lazyload-image.strategy.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformServer } from '@angular/common'; 2 | import { IntersectionObserverHooks } from 'ng-lazyload-image'; 3 | 4 | export class CustomLazyLoadImageStrategy extends IntersectionObserverHooks { 5 | override isBot() { 6 | return isPlatformServer(this.platformId); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/core/strategies/custom-page-title.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; 4 | 5 | import { environment } from '@env/environment'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class CustomPageTitleStrategy extends TitleStrategy { 11 | constructor(private readonly title: Title) { 12 | super(); 13 | } 14 | 15 | override updateTitle(routerState: RouterStateSnapshot) { 16 | const title = this.buildTitle(routerState); 17 | if (title !== undefined) { 18 | this.title.setTitle(`${environment.appName} - ${title}`); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/core/strategies/custom-route-preload.strategy.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-deprecated */ 2 | import { ApplicationRef, Injectable } from '@angular/core'; 3 | import { PreloadingStrategy, Route } from '@angular/router'; 4 | import { concat, EMPTY, first, Observable, switchMap, timer, toArray } from 'rxjs'; 5 | 6 | import { PlatformService } from '@core/services'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class CustomRoutePreloadingStrategy implements PreloadingStrategy { 12 | private preloadedModules = new Set(); 13 | 14 | constructor(private readonly platformService: PlatformService, private applicationRef: ApplicationRef) {} 15 | 16 | preload(route: Route, load: () => Observable): Observable { 17 | const loadRoute = (delay: number) => { 18 | this.preloadedModules.add(route); 19 | const appIsStable$ = this.applicationRef.isStable.pipe(first(isStable => isStable === true)); 20 | const preloadTimer$ = timer(delay * 1000); 21 | const preloadRouteOnceAppIsStable$ = concat(appIsStable$, preloadTimer$).pipe( 22 | toArray(), 23 | switchMap(() => load()) 24 | ); 25 | return delay > 0 ? preloadRouteOnceAppIsStable$ : load(); 26 | }; 27 | return this.shouldPreloadRoute(route) ? loadRoute(this.getDelay(route)) : EMPTY; 28 | } 29 | 30 | private shouldPreloadRoute(route: Route): boolean { 31 | if (this.platformService.isServer) { 32 | return false; 33 | } 34 | 35 | return !this.preloadedModules.has(route) && route.data && route.data['preload']; 36 | } 37 | 38 | private getDelay(route: Route): number { 39 | return route.data && route.data['delayInSeconds'] ? route.data['delayInSeconds'] : 0; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/core/strategies/custom-route-reuse.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, DetachedRouteHandle, Route, RouteReuseStrategy } from '@angular/router'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class CustomRouteReuseStrategy implements RouteReuseStrategy { 8 | private readonly detachedRouteHandles = new WeakMap(); 9 | 10 | shouldDetach(route: ActivatedRouteSnapshot): boolean { 11 | return this.shouldBeReused(route); 12 | } 13 | 14 | public store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void { 15 | handle 16 | ? this.detachedRouteHandles.set(route.routeConfig!, handle) 17 | : this.detachedRouteHandles.delete(route.routeConfig!); 18 | } 19 | 20 | shouldAttach(route: ActivatedRouteSnapshot): boolean { 21 | return this.shouldBeReused(route) ? this.detachedRouteHandles.has(route.routeConfig!) : false; 22 | } 23 | 24 | retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { 25 | return this.shouldBeReused(route) ? this.detachedRouteHandles.get(route.routeConfig!)! : null; 26 | } 27 | 28 | public shouldReuseRoute(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean { 29 | return future.routeConfig === current.routeConfig || this.shouldBeReused(future); 30 | } 31 | 32 | private shouldBeReused(route: ActivatedRouteSnapshot): boolean { 33 | return !!route.data['shouldReuse']; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/core/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-page-title.strategy'; 2 | export * from './custom-route-preload.strategy'; 3 | export * from './custom-route-reuse.strategy'; 4 | export * from './network-aware-route-preload.strategy'; 5 | -------------------------------------------------------------------------------- /src/app/core/strategies/network-aware-route-preload.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { PreloadingStrategy, Route } from '@angular/router'; 3 | import { EMPTY, Observable } from 'rxjs'; 4 | 5 | import { PlatformService } from '@core/services'; 6 | 7 | const SLOW_CONNECTIONS = ['slow-2g', '2g', '3g']; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class NetworkAwareRoutePreloadingStrategy implements PreloadingStrategy { 13 | private preloadedModules = new Set(); 14 | 15 | constructor(private readonly platformService: PlatformService) {} 16 | 17 | preload(route: Route, load: () => Observable): Observable { 18 | const loadRoute = () => { 19 | this.preloadedModules.add(route); 20 | return load(); 21 | }; 22 | return this.shouldPreloadRoute(route) ? loadRoute() : EMPTY; 23 | } 24 | 25 | private shouldPreloadRoute(route: Route): boolean { 26 | if (this.platformService.isServer) { 27 | return false; 28 | } 29 | 30 | const connection = (navigator as any)?.connection; 31 | const currentSpeed = connection.effectiveType; 32 | 33 | return !this.preloadedModules.has(route) && !connection.saveData && !SLOW_CONNECTIONS.includes(currentSpeed!); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/core/tokens/app-name.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | export const APP_NAME = new InjectionToken('App Name'); 4 | -------------------------------------------------------------------------------- /src/app/core/utils/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/core/utils/index.ts -------------------------------------------------------------------------------- /src/app/features/home/components/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/features/home/components/index.ts -------------------------------------------------------------------------------- /src/app/features/home/home-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { WelcomeComponent } from './pages'; 5 | 6 | const routes: Routes = [{ path: '', component: WelcomeComponent }]; 7 | 8 | @NgModule({ 9 | imports: [RouterModule.forChild(routes)], 10 | exports: [RouterModule] 11 | }) 12 | export class HomeRoutingModule {} 13 | -------------------------------------------------------------------------------- /src/app/features/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { SharedModule } from '@shared/shared.module'; 5 | 6 | import { HomeRoutingModule } from './home-routing.module'; 7 | import { WelcomeComponent } from './pages'; 8 | 9 | @NgModule({ 10 | declarations: [WelcomeComponent], 11 | imports: [CommonModule, HomeRoutingModule, SharedModule] 12 | }) 13 | export class HomeModule {} 14 | -------------------------------------------------------------------------------- /src/app/features/home/models/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/features/home/models/index.ts -------------------------------------------------------------------------------- /src/app/features/home/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './welcome/welcome.component'; 2 | -------------------------------------------------------------------------------- /src/app/features/home/pages/welcome/welcome.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Welcome!

3 |
4 | -------------------------------------------------------------------------------- /src/app/features/home/pages/welcome/welcome.component.scss: -------------------------------------------------------------------------------- 1 | :host div { 2 | @apply flex h-full items-center justify-center; 3 | 4 | p { 5 | @apply mb-7 border-2 border-dashed border-stone-600 px-3 py-2.5 font-bold border-sketch-2 dark:border-stone-100; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/features/home/pages/welcome/welcome.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-welcome', 5 | templateUrl: './welcome.component.html', 6 | styleUrls: ['./welcome.component.scss'] 7 | }) 8 | export class WelcomeComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/features/home/services/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/features/home/services/index.ts -------------------------------------------------------------------------------- /src/app/features/jokes/components/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/features/jokes/components/index.ts -------------------------------------------------------------------------------- /src/app/features/jokes/jokes-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { ChuckNorrisComponent } from './pages'; 5 | 6 | const routes: Routes = [ 7 | { path: '', redirectTo: 'chuck-norris', pathMatch: 'full' }, 8 | { path: 'chuck-norris', component: ChuckNorrisComponent, data: { shouldReuse: true, revalidate: 5 } } 9 | ]; 10 | 11 | @NgModule({ 12 | imports: [RouterModule.forChild(routes)], 13 | exports: [RouterModule] 14 | }) 15 | export class JokesRoutingModule {} 16 | -------------------------------------------------------------------------------- /src/app/features/jokes/jokes.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { SharedModule } from '@app/shared/shared.module'; 5 | 6 | import { JokesRoutingModule } from './jokes-routing.module'; 7 | import { ChuckNorrisComponent } from './pages'; 8 | import { ChuckNorrisService } from './services'; 9 | 10 | @NgModule({ 11 | declarations: [ChuckNorrisComponent], 12 | imports: [CommonModule, JokesRoutingModule, SharedModule], 13 | providers: [ChuckNorrisService] 14 | }) 15 | export class JokesModule {} 16 | -------------------------------------------------------------------------------- /src/app/features/jokes/models/chuck-norris-joke.model.ts: -------------------------------------------------------------------------------- 1 | export interface IChuckNorrisJoke { 2 | category: string; 3 | icon_url: string; 4 | id: string; 5 | url: string; 6 | value: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/features/jokes/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chuck-norris-joke.model'; 2 | -------------------------------------------------------------------------------- /src/app/features/jokes/pages/chuck-norris/chuck-norris.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | {{ randomJoke.value }} 5 |

6 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /src/app/features/jokes/pages/chuck-norris/chuck-norris.component.scss: -------------------------------------------------------------------------------- 1 | :host div { 2 | @apply mx-auto flex h-full max-w-lg flex-wrap content-center items-center justify-center overflow-hidden text-center align-middle; 3 | 4 | p { 5 | @apply mb-7 border-2 border-dashed border-stone-600 px-3 py-2.5 font-bold border-sketch-2 dark:border-stone-100; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/features/jokes/pages/chuck-norris/chuck-norris.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; 3 | import { Observable, ReplaySubject } from 'rxjs'; 4 | 5 | import { IChuckNorrisJoke } from '@features/jokes/models'; 6 | import { ChuckNorrisService } from '@features/jokes/services'; 7 | 8 | @Component({ 9 | selector: 'app-chuck-norris', 10 | templateUrl: './chuck-norris.component.html', 11 | styleUrls: ['./chuck-norris.component.scss'] 12 | }) 13 | @UntilDestroy() 14 | export class ChuckNorrisComponent implements OnInit { 15 | private _randomJoke = new ReplaySubject(1); 16 | 17 | public randomJoke$: Observable = this._randomJoke.asObservable(); 18 | 19 | constructor(private chuckNorrisService: ChuckNorrisService) {} 20 | 21 | ngOnInit() { 22 | this.getRandomJoke(); 23 | } 24 | 25 | public getRandomJoke() { 26 | this.chuckNorrisService 27 | .getRandomJoke() 28 | .pipe(untilDestroyed(this)) 29 | .subscribe(joke => this._randomJoke.next(joke)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/features/jokes/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chuck-norris/chuck-norris.component'; 2 | -------------------------------------------------------------------------------- /src/app/features/jokes/services/chuck-norris.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { IChuckNorrisJoke } from '@features/jokes/models'; 6 | 7 | @Injectable() 8 | export class ChuckNorrisService { 9 | private readonly randomJokeUrl = 'https://api.chucknorris.io/jokes/random'; 10 | 11 | constructor(private http: HttpClient) {} 12 | 13 | public getRandomJoke(): Observable { 14 | return this.http.get(this.randomJokeUrl); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/features/jokes/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chuck-norris.service'; 2 | -------------------------------------------------------------------------------- /src/app/shared/components/error/error.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ errorData$ | async | json }}

3 |
4 | -------------------------------------------------------------------------------- /src/app/shared/components/error/error.component.scss: -------------------------------------------------------------------------------- 1 | :host div { 2 | @apply flex h-full items-center justify-center; 3 | 4 | p { 5 | @apply mb-7 border-2 border-dashed border-stone-600 px-3 py-2.5 font-bold border-sketch-2 dark:border-stone-100; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/shared/components/error/error.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-deprecated */ 2 | import { HttpStatusCode } from '@angular/common/http'; 3 | import { Component, Input, OnInit } from '@angular/core'; 4 | import { combineLatest, map, Observable } from 'rxjs'; 5 | 6 | import { I18nService } from '@core/services'; 7 | import { ServerService } from '@shared/services'; 8 | 9 | const ALLOWED_ERROR_CODES = [HttpStatusCode.NotFound, HttpStatusCode.GatewayTimeout]; 10 | const DEFAULT_ERROR_CODE = HttpStatusCode.NotFound; 11 | 12 | @Component({ 13 | selector: 'app-error', 14 | templateUrl: './error.component.html', 15 | styleUrls: ['./error.component.scss'] 16 | }) 17 | export class ErrorComponent implements OnInit { 18 | @Input() public errorCode = DEFAULT_ERROR_CODE; 19 | 20 | public readonly errorData$: Observable<{ code: number; title: string; description: string }>; 21 | 22 | constructor(private readonly i18nService: I18nService, private readonly serverService: ServerService) { 23 | const isValidErrorCode = ALLOWED_ERROR_CODES.includes(this.errorCode); 24 | const titleKey = isValidErrorCode ? `errors.${this.errorCode}.title` : 'errors.unknown.title'; 25 | const descriptionKey = isValidErrorCode ? `errors.${this.errorCode}.description` : 'errors.unknown.description'; 26 | this.errorData$ = combineLatest([ 27 | this.i18nService.translate(titleKey), 28 | this.i18nService.translate(descriptionKey) 29 | ]).pipe(map(([title, description]) => ({ code: this.errorCode, title, description }))); 30 | } 31 | 32 | ngOnInit(): void { 33 | const errorHandler: Record void> = { 34 | [HttpStatusCode.NotFound]: () => this.serverService.setNotFoundResponse(), 35 | 0: () => this.serverService.setInternalServerErrorResponse() 36 | }; 37 | (errorHandler[this.errorCode] || errorHandler[0])(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/shared/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error/error.component'; 2 | -------------------------------------------------------------------------------- /src/app/shared/directives/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/shared/directives/index.ts -------------------------------------------------------------------------------- /src/app/shared/modules/icons/github-icon/github-icon.component.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/github-icon/github-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/shared/modules/icons/github-icon/github-icon.component.scss -------------------------------------------------------------------------------- /src/app/shared/modules/icons/github-icon/github-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-github-icon', 5 | templateUrl: './github-icon.component.html', 6 | styleUrls: ['./github-icon.component.scss'] 7 | }) 8 | export class GithubIconComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/happy-icon/happy-icon.component.html: -------------------------------------------------------------------------------- 1 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/happy-icon/happy-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/shared/modules/icons/happy-icon/happy-icon.component.scss -------------------------------------------------------------------------------- /src/app/shared/modules/icons/happy-icon/happy-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-happy-icon', 5 | templateUrl: './happy-icon.component.html', 6 | styleUrls: ['./happy-icon.component.scss'] 7 | }) 8 | export class HappyIconComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/heart-icon/heart-icon.component.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/heart-icon/heart-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/app/shared/modules/icons/heart-icon/heart-icon.component.scss -------------------------------------------------------------------------------- /src/app/shared/modules/icons/heart-icon/heart-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-heart-icon', 5 | templateUrl: './heart-icon.component.html', 6 | styleUrls: ['./heart-icon.component.scss'] 7 | }) 8 | export class HeartIconComponent { 9 | constructor() {} 10 | } 11 | -------------------------------------------------------------------------------- /src/app/shared/modules/icons/icons.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, Type } from '@angular/core'; 2 | 3 | import { GithubIconComponent } from './github-icon/github-icon.component'; 4 | import { HappyIconComponent } from './happy-icon/happy-icon.component'; 5 | import { HeartIconComponent } from './heart-icon/heart-icon.component'; 6 | 7 | const ICONS: Type[] = [HappyIconComponent, HeartIconComponent, GithubIconComponent]; 8 | 9 | @NgModule({ 10 | declarations: [...ICONS], 11 | imports: [], 12 | exports: [...ICONS] 13 | }) 14 | export class IconsModule {} 15 | -------------------------------------------------------------------------------- /src/app/shared/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './localize-route.pipe'; 2 | -------------------------------------------------------------------------------- /src/app/shared/pipes/localize-route.pipe.ts: -------------------------------------------------------------------------------- 1 | import { OnDestroy, Pipe, PipeTransform } from '@angular/core'; 2 | import * as equal from 'fast-deep-equal'; 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { I18nService } from '@core/services'; 6 | 7 | @Pipe({ 8 | name: 'localizeRoute', 9 | // Required to update the value when the promise is resolved 10 | pure: false 11 | }) 12 | export class LocalizeRoutePipe implements PipeTransform, OnDestroy { 13 | private value: string | any[] = ''; 14 | 15 | private lastRoute: string | any[] = ''; 16 | 17 | private lastLanguage: string = this.i18nService.getActiveLanguage(); 18 | 19 | private languageChanges: Subscription = this.i18nService.getActiveLanguage$().subscribe(() => { 20 | this.transform(this.lastRoute); 21 | }); 22 | 23 | constructor(private readonly i18nService: I18nService) {} 24 | 25 | ngOnDestroy() { 26 | if (this.languageChanges) { 27 | this.languageChanges.unsubscribe(); 28 | } 29 | } 30 | 31 | transform(route: string | any[]): string | any[] { 32 | if (!route || route.length === 0 || !this.i18nService.getActiveLanguage()) { 33 | return route; 34 | } 35 | 36 | if (equal(route, this.lastRoute) && equal(this.lastLanguage, this.i18nService.getActiveLanguage())) { 37 | return this.value; 38 | } 39 | 40 | const activeLanguage = this.i18nService.getActiveLanguage(); 41 | this.lastLanguage = this.i18nService.getActiveLanguage(); 42 | this.lastRoute = route; 43 | 44 | this.value = this.getLocalizedRoute(route, activeLanguage); 45 | 46 | return this.value; 47 | } 48 | 49 | private getLocalizedRoute(path: string | any[], language: string): string | any[] { 50 | if (typeof path === 'string') { 51 | return path.indexOf('/') == 0 ? `/${language}${path}` : path; 52 | } 53 | 54 | let result: any[] = []; 55 | path.forEach((segment: any, index: number) => { 56 | if (typeof segment === 'string') { 57 | const isRootRoute = index == 0 && segment.indexOf('/') == 0; 58 | result.push(isRootRoute ? `/${language}${segment}` : segment); 59 | } 60 | }); 61 | 62 | return result; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app/shared/services/browser.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PlatformService } from '@core/services'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class BrowserService { 9 | constructor(private readonly platformService: PlatformService) {} 10 | 11 | public getBrowserLanguage(): string | undefined { 12 | return this.platformService.isBrowser ? navigator.language.substring(0, 2) : undefined; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser.service'; 2 | export * from './seo.service'; 3 | export * from './server.service'; 4 | export * from './storage.service'; 5 | -------------------------------------------------------------------------------- /src/app/shared/services/seo.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { Inject, Injectable } from '@angular/core'; 3 | import { Meta, Title } from '@angular/platform-browser'; 4 | 5 | import { environment } from '@env/environment'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class SeoService { 11 | constructor(private title: Title, private meta: Meta, @Inject(DOCUMENT) private readonly document: Document) {} 12 | 13 | public setLanguage(language: string) { 14 | this.document.documentElement.lang = language; 15 | } 16 | 17 | public setTitle(title: string) { 18 | this.title.setTitle(title); 19 | this.meta.updateTag({ itemprop: 'name', content: title }, 'itemprop="name"'); 20 | this.meta.updateTag({ name: 'twitter:title', content: title }); 21 | this.meta.updateTag({ property: 'og:title', content: title }); 22 | } 23 | 24 | public setDescription(description: string) { 25 | this.meta.updateTag({ name: 'description', content: description }); 26 | this.meta.updateTag({ itemprop: 'description', content: description }, 'itemprop="description"'); 27 | this.meta.updateTag({ name: 'twitter:description', content: description }); 28 | this.meta.updateTag({ property: 'og:description', content: description }); 29 | } 30 | 31 | public setUrl(path: string) { 32 | this.meta.updateTag({ property: 'og:url', content: environment.baseUrl + path }); 33 | } 34 | 35 | public setImage(url: string) { 36 | this.meta.updateTag({ itemprop: 'image', content: url }, 'itemprop="image"'); 37 | this.meta.updateTag({ name: 'twitter:image', content: url }); 38 | this.meta.updateTag({ name: 'twitter:image:src', content: url }); 39 | this.meta.updateTag({ property: 'og:image', content: url }); 40 | this.meta.updateTag({ property: 'og:image:secure_url', content: url }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/shared/services/server.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Optional } from '@angular/core'; 2 | import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; 3 | import { Request, Response } from 'express'; 4 | import { getReasonPhrase, StatusCodes } from 'http-status-codes'; 5 | import isEmpty from 'just-is-empty'; 6 | 7 | import { Header } from '@core/enums'; 8 | import { PlatformService } from '@core/services/platform.service'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ServerService { 14 | private readonly isServerAndResponseIsNotEmpty = () => this.platformService.isServer && !isEmpty(this.response); 15 | 16 | private readonly isServerAndRequestIsNotEmpty = () => this.platformService.isServer && !isEmpty(this.request); 17 | 18 | constructor( 19 | private readonly platformService: PlatformService, 20 | @Optional() @Inject(REQUEST) private readonly request: Request, 21 | @Optional() @Inject(RESPONSE) private readonly response: Response 22 | ) {} 23 | 24 | public setResponseStatus(code: StatusCodes, message?: string, headers?: { [header: string]: string | number }) { 25 | if (this.isServerAndResponseIsNotEmpty()) { 26 | this.response.status(code); 27 | 28 | if (!isEmpty(message)) { 29 | (this.response.statusMessage as any) = message; 30 | } 31 | 32 | if (!isEmpty(headers)) { 33 | for (const header in headers) { 34 | this.response.setHeader(header, headers[header]); 35 | } 36 | } 37 | } 38 | } 39 | 40 | public setRedirectResponse(url: string, permanently: boolean = true) { 41 | const redirectStatusCode = permanently ? StatusCodes.MOVED_PERMANENTLY : StatusCodes.MOVED_TEMPORARILY; 42 | this.setResponseStatus(redirectStatusCode, getReasonPhrase(redirectStatusCode)); 43 | this.setHeader('Location', url); 44 | } 45 | 46 | public setNotFoundResponse(message = getReasonPhrase(StatusCodes.NOT_FOUND)) { 47 | this.setResponseStatus(StatusCodes.NOT_FOUND, message); 48 | } 49 | 50 | public setInternalServerErrorResponse(message = getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) { 51 | this.setResponseStatus(StatusCodes.INTERNAL_SERVER_ERROR, message); 52 | } 53 | 54 | public getLanguageHeader(): string | undefined { 55 | return this.getHeader(Header.ACCEPT_LANGUAGE)?.substring(0, 2); 56 | } 57 | 58 | public getHeader(header: string): string | undefined { 59 | if (this.isServerAndRequestIsNotEmpty()) { 60 | return (this.request.headers as any)[header]; 61 | } 62 | return undefined; 63 | } 64 | 65 | public setHeader(header: string, value: string | number) { 66 | if (this.isServerAndResponseIsNotEmpty()) { 67 | this.response.setHeader(header, value); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/shared/services/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { CookieService } from '@app/core/services/cookie.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class StorageService { 9 | constructor(private readonly cookieService: CookieService) {} 10 | 11 | public saveCookie(key: string, value: string, validityTimeInDays: number): void { 12 | const expirestAt = new Date(); 13 | expirestAt.setDate(expirestAt.getDate() + validityTimeInDays); 14 | this.cookieService.set(key, value, { expires: expirestAt, path: '/' }); 15 | } 16 | 17 | public getCookie(key: string): string | undefined { 18 | return this.cookieService.get(key); 19 | } 20 | 21 | public removeCookie(key: string): void { 22 | this.cookieService.delete(key); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { ModuleWithProviders, NgModule, Type } from '@angular/core'; 3 | import { TranslocoModule } from '@ngneat/transloco'; 4 | import { LazyLoadImageModule } from 'ng-lazyload-image'; 5 | 6 | import { ErrorComponent } from './components'; 7 | import { IconsModule } from './modules/icons/icons.module'; 8 | import { LocalizeRoutePipe } from './pipes'; 9 | 10 | const SHARED_ITEMS: Type[] = [LocalizeRoutePipe, ErrorComponent]; 11 | 12 | @NgModule({ 13 | declarations: [...SHARED_ITEMS], 14 | imports: [CommonModule], 15 | exports: [TranslocoModule, IconsModule, LazyLoadImageModule, ...SHARED_ITEMS] 16 | }) 17 | export class SharedModule { 18 | constructor() {} 19 | 20 | static forRoot(): ModuleWithProviders { 21 | return { ngModule: SharedModule, providers: [] }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "es": "Spanish", 4 | "en": "English" 5 | }, 6 | "navbar": { 7 | "welcome": "Welcome", 8 | "jokes": "Chuck Norris Jokes", 9 | "notFound": "Not Found" 10 | }, 11 | "routes": { 12 | "default": { 13 | "title": "Angular Skeleton", 14 | "description": "Angular Skeleton" 15 | }, 16 | "welcome": { 17 | "title": "Welcome", 18 | "description": "Welcome page" 19 | }, 20 | "jokes": { 21 | "title": "Chuck Norris jokes", 22 | "description": "Chuck Norris jokes page" 23 | } 24 | }, 25 | "features": { 26 | "jokes": { 27 | "getJoke": "Get random joke" 28 | } 29 | }, 30 | "errors": { 31 | "404": { 32 | "title": "Not Found", 33 | "description": "The page you are looking for does not exist" 34 | }, 35 | "504": { 36 | "title": "Network Error", 37 | "description": "Please, check your internet connection and try again" 38 | }, 39 | "unknown": { 40 | "title": "Unknown error", 41 | "description": "Unknown error" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/assets/i18n/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "es": "Español", 4 | "en": "Inglés" 5 | }, 6 | "navbar": { 7 | "welcome": "Bienvenidx", 8 | "jokes": "Bromas de Chuck Norris", 9 | "notFound": "Not Found" 10 | }, 11 | "routes": { 12 | "default": { 13 | "title": "Angular Skeleton", 14 | "description": "Angular Skeleton" 15 | }, 16 | "welcome": { 17 | "title": "Bienvenido", 18 | "description": "Página de bienvenida" 19 | }, 20 | "jokes": { 21 | "title": "Bromas de Chuck Norris", 22 | "description": "Bromas de Chuck Norris" 23 | } 24 | }, 25 | "features": { 26 | "jokes": { 27 | "getJoke": "Obtener chiste aleatorio" 28 | } 29 | }, 30 | "errors": { 31 | "404": { 32 | "title": "Not Found", 33 | "description": "La página que buscas no existe" 34 | }, 35 | "504": { 36 | "title": "Network Error", 37 | "description": "Por favor, comprueba tu conexión a internet y vuelve a intentarlo" 38 | }, 39 | "unknown": { 40 | "title": "Unknown Error", 41 | "description": "Error desconocido" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/assets/images/background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-imports 2 | import packageJson from '../../package.json'; 3 | 4 | export const environment = { 5 | production: true, 6 | appName: 'Angular Skeleton', 7 | appVersion: packageJson.version, 8 | appDescription: 'Angular Skeleton', 9 | author: '@borjapazr', 10 | authorEmail: 'borjapazr@gmail.com', 11 | authorWebsite: 'https://bpaz.dev', 12 | defaultLanguage: 'en', 13 | darkModeAsDefault: false, 14 | checkForUpdatesInterval: 60, 15 | baseUrl: 'http://localhost:4200' 16 | }; 17 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | // eslint-disable-next-line no-restricted-imports 6 | import packageJson from '../../package.json'; 7 | 8 | export const environment = { 9 | production: false, 10 | appName: 'Angular Skeleton', 11 | appVersion: packageJson.version, 12 | appDescription: 'Angular Skeleton', 13 | author: '@borjapazr', 14 | authorEmail: 'borjapazr@gmail.com', 15 | authorWebsite: 'https://bpaz.dev', 16 | defaultLanguage: 'en', 17 | darkModeAsDefault: false, 18 | checkForUpdatesInterval: 60, 19 | baseUrl: 'http://localhost:4200' 20 | }; 21 | 22 | /* 23 | * For easier debugging in development mode, you can import the following file 24 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 25 | * 26 | * This import should be commented out in production mode because it will have a negative impact 27 | * on performance if an error is thrown. 28 | */ 29 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 30 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/favicon.ico -------------------------------------------------------------------------------- /src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/angular-skeleton/eb7bd14e9b83440930634b34d53058f8d5e98c3e/src/favicon.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Angular Skeleton 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/jest.mocks.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | Object.defineProperty(window, 'CSS', { value: null }); 4 | 5 | Object.defineProperty(document, 'doctype', { 6 | value: '' 7 | }); 8 | 9 | Object.defineProperty(window, 'getComputedStyle', { 10 | value: () => { 11 | return { 12 | display: 'none', 13 | appearance: ['-webkit-appearance'] 14 | }; 15 | } 16 | }); 17 | 18 | /** 19 | * ISSUE: https://github.com/angular/material2/issues/7101 20 | * Workaround for JSDOM missing transform property 21 | */ 22 | Object.defineProperty(document.body.style, 'transform', { 23 | value: () => { 24 | return { 25 | enumerable: true, 26 | configurable: true 27 | }; 28 | } 29 | }); 30 | 31 | HTMLCanvasElement.prototype.getContext = jest.fn(); 32 | -------------------------------------------------------------------------------- /src/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | import './jest.mocks'; 3 | -------------------------------------------------------------------------------- /src/main.browser.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppBrowserModule } from './app/app.browser.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | const bootstrap = () => { 12 | platformBrowserDynamic() 13 | .bootstrapModule(AppBrowserModule) 14 | .catch(err => console.error(err)); 15 | }; 16 | 17 | if (document.readyState === 'complete') { 18 | bootstrap(); 19 | } else { 20 | document.addEventListener('DOMContentLoaded', bootstrap); 21 | } 22 | -------------------------------------------------------------------------------- /src/main.server.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Initialize the server environment - for example, adding DOM built-in types to the global scope. 3 | * 4 | * NOTE: 5 | * This import must come before any imports (direct or transitive) that rely on DOM built-ins being 6 | * available, such as `@angular/elements`. 7 | */ 8 | import '@angular/platform-server/init'; 9 | 10 | import { enableProdMode } from '@angular/core'; 11 | 12 | import { environment } from './environments/environment'; 13 | 14 | if (environment.production) { 15 | enableProdMode(); 16 | } 17 | 18 | export { AppServerModule } from './app/app.server.module'; 19 | export { renderModule } from '@angular/platform-server'; 20 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-skeleton", 3 | "short_name": "angular-skeleton", 4 | "theme_color": "#1c1918", 5 | "background_color": "#f5f5f4", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes recent versions of Safari, Chrome (including 12 | * Opera), Edge on the desktop, and iOS and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE and Safari requires the following polyfill to support IntersectionObserver */ 22 | import 'intersection-observer'; 23 | /** 24 | * By default, zone.js will patch all possible macroTask and DomEvents 25 | * user can disable parts of macroTask/DomEvents patch by setting following flags 26 | * because those flags need to be set before `zone.js` being loaded, and webpack 27 | * will put import in the top of bundle, so user need to create a separate file 28 | * in this directory (for example: zone-flags.ts), and put the following flags 29 | * into that file, and then add the following code before importing zone.js. 30 | * import './zone-flags'; 31 | * 32 | * The flags allowed in zone-flags.ts are listed here. 33 | * 34 | * The following flags will work for all browsers. 35 | * 36 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 37 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 38 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 39 | * 40 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 41 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 42 | * 43 | * (window as any).__Zone_enable_cross_context_check = true; 44 | * 45 | */ 46 | /*************************************************************************************************** 47 | * Zone JS is required by default for Angular itself. 48 | */ 49 | import 'zone.js'; // Included with Angular CLI. 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | 3 | # Allow crawling of all content 4 | User-agent: * 5 | Disallow: 6 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @import './styles/main'; 2 | -------------------------------------------------------------------------------- /src/styles/abstracts/_functions.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all application-wide Sass functions. 3 | // ----------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------- /src/styles/abstracts/_mixins.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all application-wide Sass mixins. 3 | // ----------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------- /src/styles/abstracts/_variables.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all application-wide Sass variables. 3 | // ----------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------- /src/styles/base/_base.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains very basic styles. 3 | // ----------------------------------------------------------------------------- 4 | 5 | html { 6 | @apply scroll-smooth; 7 | } 8 | 9 | body { 10 | @apply m-0 bg-stone-100 bg-cover bg-center bg-no-repeat text-lg text-stone-900 dark:bg-stone-900 dark:text-stone-100; 11 | } 12 | 13 | app-header, 14 | app-navbar, 15 | app-content, 16 | app-footer { 17 | @apply border-2 border-solid border-stone-600 p-5 border-sketch-1 dark:border-stone-100; 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/base/_fonts.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all @font-face declarations, if any. 3 | // ----------------------------------------------------------------------------- 4 | 5 | @import 'https://fonts.googleapis.com/css2?family=Comic+Neue:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap'; 6 | -------------------------------------------------------------------------------- /src/styles/base/_helpers.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains CSS helper classes. 3 | // ----------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------- /src/styles/base/_typography.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic typography style for copy text 3 | */ 4 | 5 | body { 6 | @apply font-sans text-neutral-700; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/components/_button.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the button component. 3 | // ----------------------------------------------------------------------------- 4 | 5 | .btn { 6 | @apply border-2 border-solid border-stone-600 px-3 py-2.5 font-bold border-sketch-2 dark:border-stone-100; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/layout/_content.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the content of the site/application. 3 | // ----------------------------------------------------------------------------- 4 | 5 | app-content { 6 | @apply order-4 grow px-5 md:w-3/4 lg:w-5/6; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/layout/_footer.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the footer of the site/application. 3 | // ----------------------------------------------------------------------------- 4 | 5 | app-footer { 6 | @apply order-5 mt-auto p-5; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/layout/_header.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the header of the site/application. 3 | // ----------------------------------------------------------------------------- 4 | 5 | app-header { 6 | @apply order-1 text-center; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/layout/_navbar.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the navbar of the site/application. 3 | // ----------------------------------------------------------------------------- 4 | 5 | app-navbar { 6 | @apply order-3 px-5 md:w-1/4 lg:w-1/6; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/layout/_root.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the root component of the site/application. 3 | // ----------------------------------------------------------------------------- 4 | 5 | app-root { 6 | @apply flex h-screen flex-col p-5; 7 | 8 | section { 9 | @apply order-2 my-5 flex w-full flex-1 flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | // 1. Configuration and helpers 4 | @import 'abstracts/variables', 'abstracts/functions', 'abstracts/mixins'; 5 | 6 | // 2. Vendors 7 | @import 'vendors/tailwind', 'vendors/google'; 8 | 9 | // 3. Base stuff 10 | @import 'base/base', 'base/fonts', 'base/typography', 'base/helpers'; 11 | 12 | // 4. Layout-related sections 13 | @import 'layout/root', 'layout/header', 'layout/navbar', 'layout/content', 'layout/footer'; 14 | 15 | // 5. Components 16 | @import 'components/button'; 17 | -------------------------------------------------------------------------------- /src/styles/vendors/_google.scss: -------------------------------------------------------------------------------- 1 | @import 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap'; 2 | 3 | .material-symbols-outlined { 4 | font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/vendors/_tailwind.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to Tailwind CSS. 3 | // ----------------------------------------------------------------------------- 4 | 5 | @import 'tailwindcss/base'; 6 | @import 'tailwindcss/components'; 7 | @import 'tailwindcss/utilities'; 8 | -------------------------------------------------------------------------------- /src/types/figlet/importable-fonts.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for figlet 1.5 2 | // Project: https://github.com/patorjk/figlet.js 3 | // Definitions by: DefinitelyTyped 4 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 5 | 6 | declare module 'figlet/importable-fonts/*' { 7 | const value: string; 8 | export default value; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import 'jest-extended'; 3 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-standard', 'stylelint-config-recommended-scss', 'stylelint-prettier/recommended'], 3 | plugins: ['stylelint-scss', 'stylelint-no-unsupported-browser-features', 'stylelint-prettier'], 4 | rules: { 5 | 'prettier/prettier': true, 6 | 'selector-no-qualifying-type': null, 7 | 'max-nesting-depth': null, 8 | 'at-rule-no-unknown': null, 9 | 'at-rule-empty-line-before': null, 10 | 'no-descending-specificity': null, 11 | 'selector-max-compound-selectors': 5, 12 | 'no-empty-source': null, 13 | 'selector-pseudo-element-no-unknown': [ 14 | true, 15 | { 16 | ignorePseudoElements: ['ng-deep'] 17 | } 18 | ], 19 | 'plugin/no-unsupported-browser-features': [ 20 | true, 21 | { 22 | severity: 'warning' 23 | } 24 | ], 25 | 'scss/at-rule-no-unknown': [ 26 | true, 27 | { 28 | ignoreAtRules: ['tailwind', 'apply', 'variants', 'responsive', 'screen'] 29 | } 30 | ], 31 | 'import-notation': null 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | const plugin = require('tailwindcss/plugin'); 3 | 4 | module.exports = { 5 | prefix: '', 6 | mode: 'jit', 7 | content: ['./src/**/*.{html,ts,css,scss,sass,less,styl}'], 8 | darkMode: 'class', 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | sans: ['Comic Neue', ...defaultTheme.fontFamily.sans] 13 | }, 14 | backgroundImage: { 15 | waves: "url('/assets/images/background.svg')" 16 | }, 17 | colors: { 18 | 'transparent-gray-50': '#cdcdcd52', 19 | 'transparent-gray-100': '#cdcdcd99' 20 | } 21 | } 22 | }, 23 | corePlugins: { 24 | preflight: true 25 | }, 26 | variants: { 27 | extend: {} 28 | }, 29 | plugins: [ 30 | require('@tailwindcss/forms'), 31 | require('@tailwindcss/typography'), 32 | require('@tailwindcss/aspect-ratio'), 33 | plugin(function ({ addUtilities }) { 34 | addUtilities({ 35 | '.border-sketch-1': { 36 | 'border-radius': '50px 15px 225px 15px/15px 225px 15px 20px' 37 | }, 38 | '.border-sketch-2': { 39 | 'border-radius': '255px 15px 225px 15px/15px 225px 15px 255px' 40 | } 41 | }); 42 | }) 43 | ] 44 | }; 45 | -------------------------------------------------------------------------------- /transloco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootTranslationsPath: 'assets/i18n/', 3 | langs: ['en', 'es'], 4 | keysManager: {} 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./out-tsc/app", 4 | "types": [] 5 | }, 6 | "extends": "./tsconfig.json", 7 | "files": ["src/main.browser.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "angularCompilerOptions": { 3 | "enableI18nLegacyMessageIdFormat": false, 4 | "strictInjectionParameters": true, 5 | "strictInputAccessModifiers": true, 6 | "strictTemplates": true 7 | }, 8 | "compileOnSave": false, 9 | "compilerOptions": { 10 | "baseUrl": "./", 11 | "declaration": false, 12 | "downlevelIteration": true, 13 | "experimentalDecorators": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "importHelpers": true, 16 | "lib": ["es2022", "dom"], 17 | "module": "es2022", 18 | "moduleResolution": "node", 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitOverride": true, 21 | "noImplicitReturns": true, 22 | "useDefineForClassFields": false, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "skipLibCheck": true, 26 | "noPropertyAccessFromIndexSignature": true, 27 | "allowSyntheticDefaultImports": true, 28 | "resolveJsonModule": true, 29 | "outDir": "./dist/out-tsc", 30 | "paths": { 31 | "@app/*": ["src/app/*"], 32 | "@core/*": ["src/app/core/*"], 33 | "@env/*": ["src/environments/*"], 34 | "@features/*": ["src/app/features/*"], 35 | "@shared/*": ["src/app/shared/*"] 36 | }, 37 | "sourceMap": true, 38 | "strict": true, 39 | "target": "es2022", 40 | "typeRoots": ["node_modules/@types", "src/types"] 41 | }, 42 | "files": ["server.ts"], 43 | "include": ["src/**/*"] 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "angularCompilerOptions": { 3 | "entryModule": "./src/app/app.server.module#AppServerModule" 4 | }, 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/server", 7 | "types": ["node"] 8 | }, 9 | "extends": "./tsconfig.app.json", 10 | "files": ["src/main.server.ts", "server.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./out-tsc/spec", 4 | "allowJs": true, 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "extends": "./tsconfig.json", 9 | "files": ["src/polyfills.ts"], 10 | "include": ["src/**/*.int.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"] 11 | } 12 | --------------------------------------------------------------------------------