From 42e9f01253020b2345de02d239e0368151344e25 Mon Sep 17 00:00:00 2001 From: Antonio Ledebuhr Date: Tue, 25 Aug 2026 17:06:51 +0200 Subject: [PATCH] foundation: project guide, tooling, design tokens, bilingual shell and routing contracts Co-authored-by: Cursor --- .prettierignore | 10 + AGENTS.md | 148 ++ README.md | 81 +- angular.json | 12 +- eslint.config.js | 43 + package-lock.json | 1535 ++++++++++++++++- package.json | 15 +- src/_breakpoints.scss | 14 + src/app/app.config.server.ts | 4 +- src/app/app.config.ts | 20 +- src/app/app.html | 97 +- src/app/app.routes.server.ts | 11 +- src/app/app.routes.spec.ts | 55 + src/app/app.routes.ts | 69 +- src/app/app.scss | 163 ++ src/app/app.spec.ts | 35 +- src/app/app.ts | 43 +- .../dot-background/dot-background.scss | 2 +- .../dot-background/dot-background.spec.ts | 11 +- .../dot-background/dot-background.ts | 99 +- .../pages/skills/skill-card/skill-card.html | 3 +- .../pages/skills/skill-card/skill-card.scss | 60 +- .../skills/skill-card/skill-card.spec.ts | 5 +- .../pages/skills/skill-card/skill-card.ts | 4 +- .../pages/skills/skills-grid/skills-grid.scss | 2 +- .../skills/skills-grid/skills-grid.spec.ts | 5 +- .../pages/skills/skills-grid/skills-grid.ts | 92 +- src/app/components/pages/skills/skills.html | 6 +- src/app/components/pages/skills/skills.scss | 12 +- .../components/pages/skills/skills.spec.ts | 16 +- src/app/components/pages/skills/skills.ts | 12 +- src/app/core/content/content.contracts.ts | 50 + src/app/core/content/content.service.ts | 15 + src/app/core/content/content.token.ts | 5 + src/app/core/content/placeholder-content.ts | 58 + src/app/core/content/shell-copy.ts | 49 + src/app/core/content/site-config.ts | 7 + src/app/core/i18n/locale.resolver.ts | 11 + src/app/core/i18n/locale.service.ts | 25 + src/app/core/i18n/locale.spec.ts | 44 + src/app/core/i18n/locale.ts | 18 + .../navigation/navigation.service.spec.ts | 43 + src/app/core/navigation/navigation.service.ts | 77 + src/app/core/navigation/navigation.ts | 68 + src/app/core/platform/browser.spec.ts | 23 + src/app/core/platform/browser.ts | 26 + src/app/core/routing/app-route-data.ts | 16 + src/app/core/routing/route-ids.ts | 30 + src/app/core/routing/route-paths.spec.ts | 46 + src/app/core/routing/route-paths.ts | 83 + src/app/features/about/about.html | 3 + src/app/features/about/about.ts | 13 + src/app/features/contact/contact.html | 3 + src/app/features/contact/contact.ts | 13 + src/app/features/home/home.html | 3 + src/app/features/home/home.ts | 13 + src/app/features/imprint/imprint.html | 3 + src/app/features/imprint/imprint.ts | 13 + src/app/features/not-found/not-found.html | 3 + src/app/features/not-found/not-found.ts | 13 + src/app/features/privacy/privacy.html | 3 + src/app/features/privacy/privacy.ts | 13 + src/app/features/projects/projects.html | 3 + src/app/features/projects/projects.ts | 13 + .../ai-integration/ai-integration.html | 3 + .../services/ai-integration/ai-integration.ts | 13 + .../features/services/clusters/clusters.html | 3 + .../features/services/clusters/clusters.ts | 13 + .../hardware-network/hardware-network.html | 3 + .../hardware-network/hardware-network.ts | 13 + src/app/features/services/services.html | 3 + src/app/features/services/services.ts | 13 + .../features/services/software/software.html | 3 + .../features/services/software/software.ts | 13 + src/app/features/stack/stack.html | 1 + src/app/features/stack/stack.ts | 10 + src/app/models/skill-category.ts | 2 +- .../service/device-detection-service.spec.ts | 16 - src/app/service/device-detection-service.ts | 15 - .../page-placeholder/page-placeholder.html | 13 + .../page-placeholder/page-placeholder.scss | 31 + .../page-placeholder/page-placeholder.ts | 28 + src/index.html | 22 +- src/main.server.ts | 3 +- src/main.ts | 3 +- src/server.ts | 4 +- src/styles.scss | 173 +- tsconfig.app.json | 12 +- tsconfig.spec.json | 9 +- 89 files changed, 3615 insertions(+), 297 deletions(-) create mode 100644 .prettierignore create mode 100644 AGENTS.md create mode 100644 eslint.config.js create mode 100644 src/_breakpoints.scss create mode 100644 src/app/app.routes.spec.ts create mode 100644 src/app/core/content/content.contracts.ts create mode 100644 src/app/core/content/content.service.ts create mode 100644 src/app/core/content/content.token.ts create mode 100644 src/app/core/content/placeholder-content.ts create mode 100644 src/app/core/content/shell-copy.ts create mode 100644 src/app/core/content/site-config.ts create mode 100644 src/app/core/i18n/locale.resolver.ts create mode 100644 src/app/core/i18n/locale.service.ts create mode 100644 src/app/core/i18n/locale.spec.ts create mode 100644 src/app/core/i18n/locale.ts create mode 100644 src/app/core/navigation/navigation.service.spec.ts create mode 100644 src/app/core/navigation/navigation.service.ts create mode 100644 src/app/core/navigation/navigation.ts create mode 100644 src/app/core/platform/browser.spec.ts create mode 100644 src/app/core/platform/browser.ts create mode 100644 src/app/core/routing/app-route-data.ts create mode 100644 src/app/core/routing/route-ids.ts create mode 100644 src/app/core/routing/route-paths.spec.ts create mode 100644 src/app/core/routing/route-paths.ts create mode 100644 src/app/features/about/about.html create mode 100644 src/app/features/about/about.ts create mode 100644 src/app/features/contact/contact.html create mode 100644 src/app/features/contact/contact.ts create mode 100644 src/app/features/home/home.html create mode 100644 src/app/features/home/home.ts create mode 100644 src/app/features/imprint/imprint.html create mode 100644 src/app/features/imprint/imprint.ts create mode 100644 src/app/features/not-found/not-found.html create mode 100644 src/app/features/not-found/not-found.ts create mode 100644 src/app/features/privacy/privacy.html create mode 100644 src/app/features/privacy/privacy.ts create mode 100644 src/app/features/projects/projects.html create mode 100644 src/app/features/projects/projects.ts create mode 100644 src/app/features/services/ai-integration/ai-integration.html create mode 100644 src/app/features/services/ai-integration/ai-integration.ts create mode 100644 src/app/features/services/clusters/clusters.html create mode 100644 src/app/features/services/clusters/clusters.ts create mode 100644 src/app/features/services/hardware-network/hardware-network.html create mode 100644 src/app/features/services/hardware-network/hardware-network.ts create mode 100644 src/app/features/services/services.html create mode 100644 src/app/features/services/services.ts create mode 100644 src/app/features/services/software/software.html create mode 100644 src/app/features/services/software/software.ts create mode 100644 src/app/features/stack/stack.html create mode 100644 src/app/features/stack/stack.ts delete mode 100644 src/app/service/device-detection-service.spec.ts delete mode 100644 src/app/service/device-detection-service.ts create mode 100644 src/app/shared/page-placeholder/page-placeholder.html create mode 100644 src/app/shared/page-placeholder/page-placeholder.scss create mode 100644 src/app/shared/page-placeholder/page-placeholder.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..34da932 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +dist +.angular +node_modules +coverage +package-lock.json +public/**/*.svg +*.pdf +Dockerfile +docker-compose.yml +.cursor diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a57dd5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,148 @@ +# AGENTS.md + +Contributor and agent guide for this repository. Read this before changing routing, copy, styles, or SSR behaviour. + +## Purpose + +This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales. + +## Architecture + +- Angular 21 standalone components, no NgModules +- Zoneless change detection +- Signals for local state +- Lazy-loaded feature routes +- SSR with prerendering (`@angular/ssr`, `outputMode: "server"`) +- Express host in `src/server.ts` + +## Directory map + +| Path | Role | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/core/**` | Contracts, services and tokens. No UI. | +| `src/app/features/**` | Routed page components, one folder per route. | +| `src/app/shared/**` | Reusable UI primitives used by multiple features. | +| `src/app/components/**` | Existing decorative/feature widgets (skills grid, dot background). | +| `public/**` | Static SVG and public assets copied as-is. | +| `src/styles.scss` | Global design tokens and primitives. The only place raw brand hex values are defined. | +| `src/_breakpoints.scss` | Breakpoint map (`sm` 40rem, `md` 48rem, `lg` 64rem, `xl` 80rem) and the `respond-to` mixin. Components pull it in with `@use 'breakpoints'`. | + +## Commands + +| Script | Purpose | +| ----------------------------- | --------------------------------------------- | +| `npm start` | Dev server (`ng serve`). | +| `npm run build` | Production build (default configuration). | +| `npm run watch` | Development rebuild on change. | +| `npm test` | Unit tests in watch mode. | +| `npm run test:ci` | Unit tests once (`--watch=false`); must exit. | +| `npm run lint` | ESLint over the workspace. | +| `npm run lint:fix` | ESLint with autofix. | +| `npm run format` | Prettier write. | +| `npm run format:check` | Prettier check (CI). | +| `npm run serve:ssr` | Serve the production SSR bundle. | +| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. | +| `npm run ci` | lint, format:check, test:ci, then build. | + +## Code conventions + +- Standalone components only +- `inject()` over constructor injection for new code +- Signals over RxJS for local state +- `ChangeDetectionStrategy.OnPush` on new components +- kebab-case file names +- No `any` +- No default exports +- Templates in separate files + +## SCSS conventions + +- Global design tokens live only in `src/styles.scss` as CSS custom properties +- Components consume `var(--...)` and never redefine raw hex values +- Use the documented spacing (`--space-1` … `--space-12`) and typography (`--text-xs` … `--text-3xl`) scale +- Hover-only affordances must be wrapped in `@media (hover: hover) and (pointer: fine)` +- All motion must respect `prefers-reduced-motion` +- Breakpoints and the `respond-to` mixin live in `src/_breakpoints.scss`; do not invent extra breakpoint names +- Shared glass, layout and focus treatments belong on the global primitives (`.glass-surface`, `.content-container`, `.stack`, `.cluster`), not copied into component stylesheets +- Keep component styles under the `anyComponentStyle` budget (4 kB warn, 8 kB error) + +## Content rules + +All user-facing copy comes from the typed bilingual content layer in `src/app/core/content`. Never inline marketing or page copy in templates. + +Keep the four copy roles as separate fields: + +- `headline` — the serious heading +- `proof` — only a verifiable statement +- `playfulLine` — optional wordplay; never a capability claim +- `cta` — action label, kept swappable + +German and English are written idiomatically per language, never machine-translated word for word. + +## Claims policy (non-negotiable) + +- No unsupported superlatives +- No invented references, client names, metrics or certifications +- Unproven AI, local-LLM or multi-agent capabilities are phrased as an offering or way of working, never as a delivered reference +- The CV stations HUP, BitWiz and Cybertrading are deliberately excluded from all public pages, cases and timelines +- The legal pages (Impressum, Datenschutz) contain placeholders that MUST be reviewed by the site owner before publication and must visibly say so + +## i18n rules + +- German at `/`, English under `/en` +- Every route id exists in both locales +- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths +- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService` + +## SSR rules + +- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards +- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser) +- No user-agent sniffing; use CSS media queries for device capability +- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server +- Every addressable route must remain prerenderable + +## Accessibility checklist + +- Skip link as the first focusable element, targeting `#main-content` +- Single `h1` per page +- Landmark elements (`header`/`nav`, `main`, `footer`) +- Visible `:focus-visible` ring +- Keyboard reachability for every action +- `aria-current` on the active nav item +- At least 4.5:1 text contrast on the base surface +- Reduced-motion alternatives for animation and smooth scrolling +- Navigation remains usable in its server-rendered default state (no JS-only menus) + +## SEO expectations + +- Semantic headings +- Real locale-prefixed URLs +- Server-rendered core text +- Per-route titles from the content layer + +Canonical, hreflang document tags and JSON-LD arrive in the integration phase. The language switch already exposes `hreflang` on the alternate-locale link. + +## Test checklist + +A change is not done until: + +1. `npm run lint` exits 0 +2. `npm run format:check` exits 0 +3. `npm run test:ci` exits 0 with no watcher left running +4. `npm run build` exits 0 +5. New or changed routes appear in `prerenderablePaths()` and are prerendered +6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links) +7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard +8. Placeholder or legal copy was not replaced with invented professional claims + +## Domain boundaries + +| Branch | Owns | +| ----------- | -------------------------------------------------------------------------------- | +| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline | +| Content | Bilingual copy data and page composition | +| Signature | Systems Map, terminal, dot background and motion | +| Integration | SEO and cross-cutting a11y/performance hardening | + +Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract. diff --git a/README.md b/README.md index cb9eabc..238afde 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,62 @@ # Portfolio -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5. +Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. -## Development server +This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build. -To start a local development server, run: +## Prerequisites + +- Node.js 22 or newer +- npm 11 or newer (npm 12 prints an engine-compatibility warning on some Node 24 builds; that warning is not an error) + +## Install ```bash -ng serve +npm install ``` -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. +## Scripts -## Code scaffolding +| Script | Description | +| ----------------------------- | ------------------------------------------------------------------------- | +| `npm start` | Start the Angular dev server. | +| `npm run build` | Production build (default configuration), including SSR and prerendering. | +| `npm run watch` | Development rebuild on change. | +| `npm test` | Unit tests in watch mode. | +| `npm run test:ci` | Unit tests once; exits when finished. | +| `npm run lint` | Run ESLint. | +| `npm run lint:fix` | Run ESLint with autofix. | +| `npm run format` | Format the workspace with Prettier. | +| `npm run format:check` | Check formatting without writing. | +| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. | +| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. | +| `npm run ci` | lint, format check, tests, then production build. | -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: +## Local SSR build ```bash -ng generate component component-name +npm run build +npm run serve:ssr ``` -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: +The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. -```bash -ng generate --help +## Project structure + +``` +src/app/core/ Typed contracts: locale, routing, navigation, content, platform +src/app/features/ One standalone page per route id +src/app/shared/ Reusable UI primitives +src/app/components/ Existing decorative widgets (skills, dot background) +src/styles.scss Design tokens and global primitives +src/_breakpoints.scss Breakpoint map and respond-to mixin +public/ Static SVG assets ``` -## Building +Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the placeholder provider live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`. -To build the project run: +Contributor and agent conventions are in [AGENTS.md](./AGENTS.md). -```bash -ng build -``` +## Legal pages -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +The Impressum (`/impressum`, `/en/legal-notice`) and Datenschutz (`/datenschutz`, `/en/privacy`) pages contain unreviewed placeholders. They must be checked by the site owner before publication. diff --git a/angular.json b/angular.json index ee6af54..aa57834 100644 --- a/angular.json +++ b/angular.json @@ -23,15 +23,21 @@ "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", + "stylePreprocessorOptions": { + "includePaths": ["src"] + }, "assets": [ { "glob": "**/*", "input": "public" + }, + { + "glob": "CV.pdf", + "input": ".", + "output": "cv" } ], - "styles": [ - "src/styles.scss" - ], + "styles": ["src/styles.scss"], "server": "src/main.server.ts", "outputMode": "server", "ssr": { diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..498ccbd --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,43 @@ +// @ts-check +const eslint = require('@eslint/js'); +const tseslint = require('typescript-eslint'); +const angular = require('angular-eslint'); +const prettier = require('eslint-config-prettier'); + +module.exports = tseslint.config( + { + ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'], + }, + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], + }, + prettier, +); diff --git a/package-lock.json b/package-lock.json index 5432192..bbe9f26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,16 @@ "@angular/build": "^21.0.5", "@angular/cli": "^21.0.5", "@angular/compiler-cli": "^21.0.0", + "@eslint/js": "^9.39.5", "@types/express": "^5.0.1", "@types/node": "^20.17.19", + "angular-eslint": "^21.4.0", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", "jsdom": "^27.1.0", + "prettier": "^3.9.6", "typescript": "~5.9.2", + "typescript-eslint": "^8.68.0", "vitest": "^4.0.8" } }, @@ -327,6 +333,128 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-eslint/builder": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.4.0.tgz", + "integrity": "sha512-3kgGmrVaCYbLtDjC8g4BmMBbdz4thsOB8/NYly8JtXM8EuDZEk5Pz6VTRpJR02ARprwayraTTmhyvq6OGBlQ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": ">= 0.2100.0 < 0.2200.0", + "@angular-devkit/core": ">= 21.0.0 < 22.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.4.0.tgz", + "integrity": "sha512-mow2DMj+xBvGl5t7jzC34R8YfbHbaGNyCNFzpovtl9qc0JbuqLyg6htmt8xb05f8ZjATOr4nz0ESt6HV4c51hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.4.0.tgz", + "integrity": "sha512-sJEHx2WYnvOgPpzP1eHnUdRS06zgKmRxbiIR0JiCcaSen5iv1HlsMieXy//FS9TtNW+abHOy4UtDuGuSPflPFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "21.4.0", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.4.0.tgz", + "integrity": "sha512-crD6Hfxs7x5bN9FCqTZI7uVSiGvprfCS3MCPOpyIQl87bRr/9aNhnicJ3ROUHv+2A713BgPHIgiCII/bxzrfPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "ignore": "7.0.5", + "semver": "7.7.4", + "strip-json-comments": "3.1.1" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.4.0.tgz", + "integrity": "sha512-7pi+Ga7QmdH5Ig/diau6fR5L4yubgKr9TOjdCg7OeuE/zo0O3osTCNT6JOodzS/iQM1kSCJFDoIBKFeUOttiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, "node_modules/@angular/build": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz", @@ -1607,6 +1735,197 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@exodus/bytes": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz", @@ -1638,6 +1957,72 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -3981,6 +4366,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4020,6 +4412,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -4065,6 +4464,278 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.68.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -4219,6 +4890,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4290,6 +4984,30 @@ "node": ">= 14.0.0" } }, + "node_modules/angular-eslint": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-21.4.0.tgz", + "integrity": "sha512-LH7bWmtJvsubzwPoztnl1pWgI5X0VrfGTUITGSYcwn2J+SXuN/avzrKrxJmhUiIrNvLtfV+18GG6xZS1IGZdKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/builder": "21.4.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "@angular-eslint/schematics": "21.4.0", + "@angular-eslint/template-parser": "21.4.0", + "@typescript-eslint/types": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*", + "typescript-eslint": "^8.0.0" + } + }, "node_modules/ansi-escapes": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", @@ -4332,6 +5050,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4342,6 +5077,23 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", @@ -4413,6 +5165,17 @@ "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4525,6 +5288,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001765", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", @@ -4736,6 +5509,13 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -4930,6 +5710,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5215,6 +6002,305 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5225,6 +6311,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5347,6 +6443,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5382,6 +6492,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -5403,6 +6526,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5546,6 +6707,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -5553,6 +6727,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5572,6 +6759,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5747,6 +6944,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-walk": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", @@ -5767,6 +6974,23 @@ "dev": true, "license": "MIT" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5834,7 +7058,6 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -5861,7 +7084,6 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -5959,6 +7181,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", @@ -6012,6 +7257,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", @@ -6036,6 +7288,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6066,6 +7325,30 @@ ], "license": "MIT" }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -6148,6 +7431,29 @@ "@lmdb/lmdb-win32-x64": "3.4.4" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -6587,6 +7893,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6879,6 +8192,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", @@ -6911,6 +8242,38 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -6956,6 +8319,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -7032,6 +8408,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7172,6 +8558,32 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -7310,6 +8722,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -7930,6 +9352,32 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8076,6 +9524,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8097,6 +9558,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -8125,6 +9599,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.0.tgz", @@ -8208,6 +9706,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -8493,6 +10001,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8696,6 +10214,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index e4af0e2..fd492fc 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,14 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", - "serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs" + "test:ci": "ng test --watch=false", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "serve:ssr": "node dist/Portfolio/server/server.mjs", + "serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs", + "ci": "npm run lint && npm run format:check && npm run test:ci && npm run build" }, "prettier": { "printWidth": 100, @@ -40,10 +47,16 @@ "@angular/build": "^21.0.5", "@angular/cli": "^21.0.5", "@angular/compiler-cli": "^21.0.0", + "@eslint/js": "^9.39.5", "@types/express": "^5.0.1", "@types/node": "^20.17.19", + "angular-eslint": "^21.4.0", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", "jsdom": "^27.1.0", + "prettier": "^3.9.6", "typescript": "~5.9.2", + "typescript-eslint": "^8.68.0", "vitest": "^4.0.8" } } diff --git a/src/_breakpoints.scss b/src/_breakpoints.scss new file mode 100644 index 0000000..3a5390b --- /dev/null +++ b/src/_breakpoints.scss @@ -0,0 +1,14 @@ +@use 'sass:map'; + +$breakpoints: ( + sm: 40rem, + md: 48rem, + lg: 64rem, + xl: 80rem, +); + +@mixin respond-to($name) { + @media (min-width: map.get($breakpoints, $name)) { + @content; + } +} diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index 41031f1..608887d 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -4,9 +4,7 @@ import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; const serverConfig: ApplicationConfig = { - providers: [ - provideServerRendering(withRoutes(serverRoutes)) - ] + providers: [provideServerRendering(withRoutes(serverRoutes))], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 797642e..63f0c6e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,12 +1,22 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; -import { provideRouter } from '@angular/router'; - -import { routes } from './app.routes'; +import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; +import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content'; +import { SITE_CONTENT } from './core/content/content.token'; +import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), provideClientHydration(withEventReplay()) - ] + provideRouter( + routes, + withComponentInputBinding(), + withInMemoryScrolling({ + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + }), + ), + provideClientHydration(withEventReplay()), + { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }, + ], }; diff --git a/src/app/app.html b/src/app/app.html index ae2fc30..c2ab832 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,12 +1,93 @@ - -
-
- + + +
+ -
- +
+
-
diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index ffd37b1..59dc9eb 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,8 +1,13 @@ -import { RenderMode, ServerRoute } from '@angular/ssr'; +import { RenderMode, type ServerRoute } from '@angular/ssr'; +import { prerenderableServerPaths } from './core/routing/route-paths'; export const serverRoutes: ServerRoute[] = [ + ...prerenderableServerPaths().map((path): ServerRoute => ({ + path, + renderMode: RenderMode.Prerender, + })), { path: '**', - renderMode: RenderMode.Prerender - } + renderMode: RenderMode.Server, + }, ]; diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts new file mode 100644 index 0000000..b5dff7b --- /dev/null +++ b/src/app/app.routes.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, type Routes } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { routes } from './app.routes'; +import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content'; +import { SITE_CONTENT } from './core/content/content.token'; +import { LocaleService } from './core/i18n/locale.service'; +import { type AppRouteData } from './core/routing/app-route-data'; + +function flattenRoutes(tree: Routes, prefix = ''): Array<{ path: string; data?: AppRouteData }> { + const entries: Array<{ path: string; data?: AppRouteData }> = []; + + for (const route of tree) { + const segment = route.path ?? ''; + const path = [prefix, segment].filter((part) => part.length > 0).join('/'); + entries.push({ path, data: route.data as AppRouteData | undefined }); + + if (route.children) { + entries.push(...flattenRoutes(route.children, path)); + } + } + + return entries; +} + +describe('app routes', () => { + it('places the English subtree first and attaches locale data', () => { + expect(routes[0]?.path).toBe('en'); + + const flattened = flattenRoutes(routes); + const englishProjects = flattened.find((entry) => entry.path === 'en/projects'); + const germanProjects = flattened.find((entry) => entry.path === 'projekte'); + const englishWildcard = flattened.find((entry) => entry.path === 'en/**'); + const germanWildcard = flattened.find((entry) => entry.path === '**'); + + expect(englishProjects?.data).toEqual({ routeId: 'projects', locale: 'en' }); + expect(germanProjects?.data).toEqual({ routeId: 'projects', locale: 'de' }); + expect(englishWildcard?.data).toEqual({ routeId: 'notFound', locale: 'en' }); + expect(germanWildcard?.data).toEqual({ routeId: 'notFound', locale: 'de' }); + }); + + it('navigates to the English projects placeholder', async () => { + TestBed.configureTestingModule({ + providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }], + }); + + const harness = await RouterTestingHarness.create(); + const locale = TestBed.inject(LocaleService); + await harness.navigateByUrl('/en/projects'); + + expect(locale.locale()).toBe('en'); + expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toContain('Projects'); + expect(harness.routeNativeElement?.textContent).toContain('This page is being built.'); + }); +}); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ce27a40..664b3a1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,69 @@ -import { Routes } from '@angular/router'; -import {Skills} from './components/pages/skills/skills'; +import { type Type } from '@angular/core'; +import { type Routes } from '@angular/router'; +import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content'; +import { localeResolver } from './core/i18n/locale.resolver'; +import { type AppLocale } from './core/i18n/locale'; +import { type AppRouteData } from './core/routing/app-route-data'; +import { ROUTE_IDS, type RouteId } from './core/routing/route-ids'; +import { LOCALE_PREFIX, ROUTE_SEGMENTS } from './core/routing/route-paths'; + +type LazyPage = () => Promise>; + +const PAGE_LOADERS: Record = { + home: () => import('./features/home/home').then((module) => module.HomePage), + services: () => import('./features/services/services').then((module) => module.ServicesPage), + servicesSoftware: () => + import('./features/services/software/software').then((module) => module.ServicesSoftwarePage), + servicesHardwareNetwork: () => + import('./features/services/hardware-network/hardware-network').then( + (module) => module.ServicesHardwareNetworkPage, + ), + servicesClusters: () => + import('./features/services/clusters/clusters').then((module) => module.ServicesClustersPage), + servicesAi: () => + import('./features/services/ai-integration/ai-integration').then( + (module) => module.ServicesAiPage, + ), + projects: () => import('./features/projects/projects').then((module) => module.ProjectsPage), + stack: () => import('./features/stack/stack').then((module) => module.StackPage), + about: () => import('./features/about/about').then((module) => module.AboutPage), + contact: () => import('./features/contact/contact').then((module) => module.ContactPage), + imprint: () => import('./features/imprint/imprint').then((module) => module.ImprintPage), + privacy: () => import('./features/privacy/privacy').then((module) => module.PrivacyPage), + notFound: () => import('./features/not-found/not-found').then((module) => module.NotFoundPage), +}; + +function routeData(routeId: RouteId, locale: AppLocale): AppRouteData { + return { routeId, locale }; +} + +function localeRoutes(locale: AppLocale): Routes { + const segments = ROUTE_SEGMENTS[locale]; + const pages = PLACEHOLDER_CONTENT[locale].pages; + + return ROUTE_IDS.filter((routeId) => routeId !== 'notFound') + .map((routeId) => ({ + path: segments[routeId], + loadComponent: PAGE_LOADERS[routeId], + data: routeData(routeId, locale), + title: pages[routeId]?.title, + resolve: { locale: localeResolver }, + })) + .concat([ + { + path: '**', + loadComponent: PAGE_LOADERS.notFound, + data: routeData('notFound', locale), + title: pages.notFound?.title, + resolve: { locale: localeResolver }, + }, + ]); +} export const routes: Routes = [ - {path: '', component: Skills} + { + path: LOCALE_PREFIX.en, + children: localeRoutes('en'), + }, + ...localeRoutes('de'), ]; diff --git a/src/app/app.scss b/src/app/app.scss index e69de29..4c90c5d 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -0,0 +1,163 @@ +@use 'breakpoints' as bp; + +:host { + display: block; + min-height: 100vh; + color: var(--color-text); + font-family: var(--font-sans); +} + +.site { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + padding-block: var(--space-3); +} + +.site-header-inner { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-3); + align-items: start; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); +} + +.site-identity, +.site-nav a, +.site-actions a, +.site-footer a { + color: var(--color-text); + text-decoration: none; +} + +.site-identity { + font-weight: 600; +} + +.nav-toggle { + justify-self: end; + width: 2.5rem; + height: 2.5rem; + border: 1px solid var(--surface-glass-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); +} + +.nav-toggle span, +.nav-toggle span::before, +.nav-toggle span::after { + display: block; + width: 1rem; + height: 2px; + margin-inline: auto; + background: currentColor; +} + +.nav-toggle span::before, +.nav-toggle span::after { + content: ''; + position: relative; +} + +.nav-toggle span::before { + top: -0.35rem; +} + +.nav-toggle span::after { + top: 0.25rem; +} + +.site-nav, +.site-actions { + grid-column: 1 / -1; +} + +.primary-nav, +.primary-nav ul, +.footer-nav { + list-style: none; + margin: 0; + padding: 0; +} + +.primary-nav { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.primary-nav ul { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-inline-start: var(--space-4); +} + +.contact-cta { + color: var(--color-text); +} + +.is-active { + color: var(--color-accent-cool); +} + +.nav-collapsed .site-nav, +.nav-collapsed .site-actions { + display: none; +} + +.site-main { + flex: 1; + outline: none; +} + +.site-footer { + padding-block: var(--space-6); + color: var(--color-text-muted); +} + +.site-footer-inner { + justify-content: space-between; +} + +.site-footer p { + margin: 0; +} + +@include bp.respond-to(md) { + .nav-toggle { + display: none; + } + + .site-header-inner { + grid-template-columns: auto 1fr auto; + align-items: center; + } + + .site-nav, + .site-actions, + .nav-collapsed .site-nav, + .nav-collapsed .site-actions { + display: flex; + grid-column: auto; + } + + .primary-nav { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space-4); + } + + .primary-nav ul { + padding-inline-start: 0; + } +} diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index a0d6a95..dd08abc 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,23 +1,46 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; +import { routes } from './app.routes'; +import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content'; +import { SITE_CONTENT } from './core/content/content.token'; describe('App', () => { beforeEach(async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }], }).compileComponents(); }); - it('should create the app', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + afterEach(() => { + vi.restoreAllMocks(); }); - it('should render title', async () => { + it('should create the shell', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should render an accessible application shell', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Portfolio'); + + const focusable = compiled.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + const firstFocusable = focusable.item(0); + + expect(firstFocusable).toBeTruthy(); + expect(firstFocusable.getAttribute('href')).toBe('#main-content'); + expect(firstFocusable.classList.contains('skip-link')).toBe(true); + expect(compiled.querySelector('main#main-content')).toBeTruthy(); + expect(compiled.querySelector('nav[aria-label]')).toBeTruthy(); + expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy(); + expect(compiled.querySelector('footer')).toBeTruthy(); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 472ee69..c784892 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,28 +1,33 @@ -import {Component, computed, HostListener, OnInit, signal} from '@angular/core'; -import {RouterOutlet} from '@angular/router'; -import {DotBackground} from './components/dot-background/dot-background'; -import {DeviceDetectionService} from './service/device-detection-service'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { DotBackground } from './components/dot-background/dot-background'; +import { SHELL_COPY } from './core/content/shell-copy'; +import { SITE_CONFIG } from './core/content/site-config'; +import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale'; +import { LocaleService } from './core/i18n/locale.service'; +import { NavigationService } from './core/navigation/navigation.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, DotBackground], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground], templateUrl: './app.html', - styleUrl: './app.scss' + styleUrl: './app.scss', }) -export class App implements OnInit { - protected readonly title = signal('Portfolio'); - protected readonly isMobile = signal(false); - protected readonly isDesktop = computed(() => !this.isMobile()); +export class App { + protected readonly navigation = inject(NavigationService); + protected readonly localeService = inject(LocaleService); + protected readonly siteConfig = SITE_CONFIG; + protected readonly navOpen = signal(true); - constructor(private deviceDetectionService: DeviceDetectionService) { - } + protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]); + protected readonly otherLocale = computed(() => otherLocale(this.localeService.locale())); + protected readonly otherHtmlLang = computed(() => LOCALE_HTML_LANG[this.otherLocale()]); + protected readonly menuLabel = computed(() => + this.navOpen() ? this.shell().menuClose : this.shell().menuOpen, + ); - ngOnInit(): void { - this.isMobile.set(this.deviceDetectionService.mobileCheck()); - } - - @HostListener('window:resize') - onResize() { - this.isMobile.set(this.deviceDetectionService.mobileCheck()); + protected toggleNav(): void { + this.navOpen.update((open) => !open); } } diff --git a/src/app/components/dot-background/dot-background.scss b/src/app/components/dot-background/dot-background.scss index 9e79595..bc5573d 100644 --- a/src/app/components/dot-background/dot-background.scss +++ b/src/app/components/dot-background/dot-background.scss @@ -2,7 +2,7 @@ canvas { position: fixed; inset: 0; z-index: -1; - background: #0a0a0f; + background: var(--color-surface); width: 100vw; height: 100vh; diff --git a/src/app/components/dot-background/dot-background.spec.ts b/src/app/components/dot-background/dot-background.spec.ts index 7aa702d..915284d 100644 --- a/src/app/components/dot-background/dot-background.spec.ts +++ b/src/app/components/dot-background/dot-background.spec.ts @@ -7,16 +7,21 @@ describe('DotBackground', () => { let fixture: ComponentFixture; beforeEach(async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + await TestBed.configureTestingModule({ - imports: [DotBackground] - }) - .compileComponents(); + imports: [DotBackground], + }).compileComponents(); fixture = TestBed.createComponent(DotBackground); component = fixture.componentInstance; await fixture.whenStable(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); diff --git a/src/app/components/dot-background/dot-background.ts b/src/app/components/dot-background/dot-background.ts index a798ebb..4030274 100644 --- a/src/app/components/dot-background/dot-background.ts +++ b/src/app/components/dot-background/dot-background.ts @@ -1,6 +1,14 @@ -import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core'; -import {Dot} from '../../models/dot'; -import {DeviceDetectionService} from '../../service/device-detection-service'; +import { + afterNextRender, + Component, + ElementRef, + inject, + NgZone, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser'; +import { Dot } from '../../models/dot'; @Component({ selector: 'app-dot-background', @@ -11,7 +19,10 @@ import {DeviceDetectionService} from '../../service/device-detection-service'; export class DotBackground implements OnDestroy { @ViewChild('canvas') canvasRef!: ElementRef; - private ctx!: CanvasRenderingContext2D; + private readonly ngZone = inject(NgZone); + private readonly coarsePointer = prefersCoarsePointer(); + + private ctx: CanvasRenderingContext2D | undefined; private dots: Dot[] = []; private mouse = { x: -1000, y: -1000 }; private animationId = 0; @@ -26,23 +37,41 @@ export class DotBackground implements OnDestroy { private ballSpawnId = 0; private ballSpawnNextColor = 0; - constructor(private ngZone: NgZone, private mobileService: DeviceDetectionService) { + constructor() { afterNextRender(() => { this.init(); }); } ngOnDestroy() { - if (!this.initialized) return; + if (!this.initialized) { + return; + } cancelAnimationFrame(this.animationId); - window.removeEventListener('resize', this.resize); - window.removeEventListener('mousemove', this.onMouseMove); + + if (isBrowserPlatform()) { + window.removeEventListener('resize', this.resize); + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('click', this.onMouseClick); + } } private init() { const canvas = this.canvasRef.nativeElement; - this.ctx = canvas.getContext('2d')!; + let ctx: CanvasRenderingContext2D | null = null; + + try { + ctx = canvas.getContext('2d'); + } catch { + return; + } + + if (!ctx) { + return; + } + + this.ctx = ctx; this.resize(); this.initDots(); @@ -62,8 +91,7 @@ export class DotBackground implements OnDestroy { const dx = Math.abs(width - canvas.width) / width; const dy = Math.abs(height - canvas.height) / height; - if (!this.mobileService.mobileCheck() || dy > 0.2 || dx > 0.05) { - //sync canvas size to screen size + if (!this.coarsePointer || dy > 0.2 || dx > 0.05) { canvas.width = width; canvas.height = height; @@ -74,13 +102,10 @@ export class DotBackground implements OnDestroy { } }; - - private onMouseMove = (e: MouseEvent) => { const canvas = this.canvasRef.nativeElement; - // map real res to canvas res - this.mouse.x = e.clientX / window.innerWidth * canvas.width; - this.mouse.y = e.clientY / window.innerHeight * canvas.height; + this.mouse.x = (e.clientX / window.innerWidth) * canvas.width; + this.mouse.y = (e.clientY / window.innerHeight) * canvas.height; }; private onMouseClick = () => { @@ -91,16 +116,17 @@ export class DotBackground implements OnDestroy { private spawnDot(): Dot { const dotId = this.ballSpawnId++; - const max_count = this.mobileService.mobileCheck() ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT; - let dot; - if (dotId < max_count) { + const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT; + let dot: Dot; + + if (dotId < maxCount) { dot = { x: 0, y: 0, vx: 0, vy: 0, radius: 1, - color: "#000000", + color: '#000000', }; this.dots.push(dot); @@ -114,7 +140,7 @@ export class DotBackground implements OnDestroy { } private populateDot(dot: Dot) { - const {width, height} = this.canvasRef.nativeElement; + const { width, height } = this.canvasRef.nativeElement; dot.x = Math.random() * width; dot.y = Math.random() * height; @@ -132,8 +158,14 @@ export class DotBackground implements OnDestroy { private animate = () => { const canvas = this.canvasRef.nativeElement; + const ctx = this.ctx; + + if (!ctx) { + return; + } + const { width, height } = canvas; - this.ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height); for (const dot of this.dots) { const dx = dot.x - this.mouse.x; @@ -161,25 +193,24 @@ export class DotBackground implements OnDestroy { dot.vy = Math.random() * 0.2 - 0.1; } - // Bounce off edges (accounting for radius) - if (dot.x < dot.radius || dot.x > width - dot.radius) dot.vx *= -1; - if (dot.y < dot.radius || dot.y > height - dot.radius) dot.vy *= -1; + if (dot.x < dot.radius || dot.x > width - dot.radius) { + dot.vx *= -1; + } + if (dot.y < dot.radius || dot.y > height - dot.radius) { + dot.vy *= -1; + } - // Clamp to bounds dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x)); dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y)); - const gradient = this.ctx.createRadialGradient( - dot.x, dot.y, 0, - dot.x, dot.y, dot.radius - ); + const gradient = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, dot.radius); gradient.addColorStop(0, dot.color + '40'); gradient.addColorStop(1, 'transparent'); - this.ctx.beginPath(); - this.ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); - this.ctx.fillStyle = gradient; - this.ctx.fill(); + ctx.beginPath(); + ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); } this.animationId = requestAnimationFrame(this.animate); diff --git a/src/app/components/pages/skills/skill-card/skill-card.html b/src/app/components/pages/skills/skill-card/skill-card.html index 43ae59a..d68697f 100644 --- a/src/app/components/pages/skills/skill-card/skill-card.html +++ b/src/app/components/pages/skills/skill-card/skill-card.html @@ -1,10 +1,11 @@ -
+

{{ title }}

@for (skill of skills; track skill.icon) { { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SkillCard] - }) - .compileComponents(); + imports: [SkillCard], + }).compileComponents(); fixture = TestBed.createComponent(SkillCard); component = fixture.componentInstance; diff --git a/src/app/components/pages/skills/skill-card/skill-card.ts b/src/app/components/pages/skills/skill-card/skill-card.ts index 1e33b75..613191b 100644 --- a/src/app/components/pages/skills/skill-card/skill-card.ts +++ b/src/app/components/pages/skills/skill-card/skill-card.ts @@ -1,5 +1,5 @@ -import {Component, Input} from '@angular/core'; -import {Skill} from '../../../../models/skill'; +import { Component, Input } from '@angular/core'; +import { Skill } from '../../../../models/skill'; @Component({ selector: 'app-skill-card', diff --git a/src/app/components/pages/skills/skills-grid/skills-grid.scss b/src/app/components/pages/skills/skills-grid/skills-grid.scss index d7ace0f..adc19cf 100644 --- a/src/app/components/pages/skills/skills-grid/skills-grid.scss +++ b/src/app/components/pages/skills/skills-grid/skills-grid.scss @@ -2,5 +2,5 @@ display: flex; flex-wrap: wrap; flex-direction: column; - gap: 10px; + gap: var(--space-3); } diff --git a/src/app/components/pages/skills/skills-grid/skills-grid.spec.ts b/src/app/components/pages/skills/skills-grid/skills-grid.spec.ts index 25bd879..4bf063c 100644 --- a/src/app/components/pages/skills/skills-grid/skills-grid.spec.ts +++ b/src/app/components/pages/skills/skills-grid/skills-grid.spec.ts @@ -8,9 +8,8 @@ describe('SkillsGrid', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SkillsGrid] - }) - .compileComponents(); + imports: [SkillsGrid], + }).compileComponents(); fixture = TestBed.createComponent(SkillsGrid); component = fixture.componentInstance; diff --git a/src/app/components/pages/skills/skills-grid/skills-grid.ts b/src/app/components/pages/skills/skills-grid/skills-grid.ts index 1cc03fa..fd2a950 100644 --- a/src/app/components/pages/skills/skills-grid/skills-grid.ts +++ b/src/app/components/pages/skills/skills-grid/skills-grid.ts @@ -1,12 +1,10 @@ -import {Component} from '@angular/core'; -import {SkillCategory} from '../../../../models/skill-category'; -import {SkillCard} from '../skill-card/skill-card'; +import { Component } from '@angular/core'; +import { SkillCategory } from '../../../../models/skill-category'; +import { SkillCard } from '../skill-card/skill-card'; @Component({ selector: 'app-skills-grid', - imports: [ - SkillCard - ], + imports: [SkillCard], templateUrl: './skills-grid.html', styleUrl: './skills-grid.scss', }) @@ -17,15 +15,19 @@ export class SkillsGrid { category: 'programming', gridArea: 'prog', skills: [ - {name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/'}, - {name: 'JavaScript', icon: 'javascript', url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript'}, - {name: 'Angular', icon: 'angular', url: 'https://angular.dev/'}, - {name: 'Java', icon: 'java', url: 'https://www.java.com/'}, - {name: 'Spring', icon: 'java-spring', url: 'https://spring.io/'}, - {name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/'}, - {name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/'}, - {name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/'}, - {name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/'}, + { name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' }, + { + name: 'JavaScript', + icon: 'javascript', + url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript', + }, + { name: 'Angular', icon: 'angular', url: 'https://angular.dev/' }, + { name: 'Java', icon: 'java', url: 'https://www.java.com/' }, + { name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' }, + { name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' }, + { name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' }, + { name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' }, + { name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' }, ], }, { @@ -33,10 +35,14 @@ export class SkillsGrid { category: 'db', gridArea: 'db', skills: [ - {name: 'SQL Server', icon: 'microsoftsqlserver', url: 'https://www.microsoft.com/en-us/sql-server'}, - {name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/'}, - {name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/'}, - {name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/'}, + { + name: 'SQL Server', + icon: 'microsoftsqlserver', + url: 'https://www.microsoft.com/en-us/sql-server', + }, + { name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' }, + { name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' }, + { name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' }, ], }, { @@ -44,13 +50,17 @@ export class SkillsGrid { category: 'devops', gridArea: 'devops', skills: [ - {name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/'}, - {name: 'Docker', icon: 'docker', url: 'https://www.docker.com/'}, - {name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/'}, - {name: 'Azure DevOps', icon: 'devops', url: 'https://azure.microsoft.com/en-us/products/devops'}, - {name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/'}, - {name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/'}, - {name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/'}, + { name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' }, + { name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' }, + { name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' }, + { + name: 'Azure DevOps', + icon: 'devops', + url: 'https://azure.microsoft.com/en-us/products/devops', + }, + { name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' }, + { name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' }, + { name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' }, ], }, { @@ -58,10 +68,10 @@ export class SkillsGrid { category: 'os', gridArea: 'os', skills: [ - {name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/'}, - {name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/'}, - {name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/'}, - {name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows'}, + { name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' }, + { name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' }, + { name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' }, + { name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' }, ], }, { @@ -69,8 +79,8 @@ export class SkillsGrid { category: 'iac', gridArea: 'iac', skills: [ - {name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/'}, - {name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/'}, + { name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' }, + { name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' }, ], }, { @@ -78,8 +88,12 @@ export class SkillsGrid { category: 'hyperviser', gridArea: 'hyper', skills: [ - {name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/'}, - {name: 'Hyper-V', icon: 'hyperv', url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/'}, + { name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' }, + { + name: 'Hyper-V', + icon: 'hyperv', + url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/', + }, ], }, { @@ -87,9 +101,13 @@ export class SkillsGrid { category: 'tools', gridArea: 'tools', skills: [ - {name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/'}, - {name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/'}, - {name: 'Microsoft Office', icon: 'office', url: 'https://www.microsoft.com/microsoft-365'}, + { name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' }, + { name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' }, + { + name: 'Microsoft Office', + icon: 'office', + url: 'https://www.microsoft.com/microsoft-365', + }, ], }, ]; diff --git a/src/app/components/pages/skills/skills.html b/src/app/components/pages/skills/skills.html index bcdc830..8010b2b 100644 --- a/src/app/components/pages/skills/skills.html +++ b/src/app/components/pages/skills/skills.html @@ -1,5 +1,7 @@
-

Antonio Ledebuhr

-

Software Engineering & DevOps

+ @if (page(); as copy) { +

{{ copy.title }}

+

{{ copy.description }}

+ }
diff --git a/src/app/components/pages/skills/skills.scss b/src/app/components/pages/skills/skills.scss index baf6a79..b755b17 100644 --- a/src/app/components/pages/skills/skills.scss +++ b/src/app/components/pages/skills/skills.scss @@ -4,17 +4,19 @@ flex-direction: column; align-items: center; justify-content: center; - color: #fff; - font-family: system-ui, sans-serif; + color: var(--color-text); + font-family: var(--font-sans); + padding: var(--space-8) var(--content-gutter); } h1 { - font-size: 3rem; + font-size: var(--text-3xl); font-weight: 600; + line-height: var(--leading-tight); margin: 0; } p { - color: rgba(255, 255, 255, 0.5); - margin: 0.5rem 0 3rem; + color: var(--color-text-muted); + margin: var(--space-2) 0 var(--space-9); } diff --git a/src/app/components/pages/skills/skills.spec.ts b/src/app/components/pages/skills/skills.spec.ts index 8e19e0a..510d68c 100644 --- a/src/app/components/pages/skills/skills.spec.ts +++ b/src/app/components/pages/skills/skills.spec.ts @@ -1,5 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { PLACEHOLDER_CONTENT } from '../../../core/content/placeholder-content'; +import { SITE_CONTENT } from '../../../core/content/content.token'; +import { SITE_CONFIG } from '../../../core/content/site-config'; import { Skills } from './skills'; describe('Skills', () => { @@ -8,9 +10,9 @@ describe('Skills', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Skills] - }) - .compileComponents(); + imports: [Skills], + providers: [{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }], + }).compileComponents(); fixture = TestBed.createComponent(Skills); component = fixture.componentInstance; @@ -20,4 +22,10 @@ describe('Skills', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('renders the localized stack title instead of the person name', () => { + const heading = (fixture.nativeElement as HTMLElement).querySelector('h1'); + expect(heading?.textContent?.trim()).toBe('Stack'); + expect(heading?.textContent).not.toContain(SITE_CONFIG.personName); + }); }); diff --git a/src/app/components/pages/skills/skills.ts b/src/app/components/pages/skills/skills.ts index 4dcd0d8..8496eee 100644 --- a/src/app/components/pages/skills/skills.ts +++ b/src/app/components/pages/skills/skills.ts @@ -1,14 +1,14 @@ -import { Component } from '@angular/core'; -import {SkillsGrid} from './skills-grid/skills-grid'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../../core/content/content.service'; +import { SkillsGrid } from './skills-grid/skills-grid'; @Component({ selector: 'app-skills', - imports: [ - SkillsGrid - ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SkillsGrid], templateUrl: './skills.html', styleUrl: './skills.scss', }) export class Skills { - + protected readonly page = inject(ContentService).page('stack'); } diff --git a/src/app/core/content/content.contracts.ts b/src/app/core/content/content.contracts.ts new file mode 100644 index 0000000..42c0137 --- /dev/null +++ b/src/app/core/content/content.contracts.ts @@ -0,0 +1,50 @@ +import { type RouteId } from '../routing/route-ids'; + +/** + * Copy roles stay separate on purpose: + * - headline is the serious heading used for SEO and page structure + * - proof is only ever a verifiable statement + * - playfulLine is an optional wordplay hook that is never a capability claim + * - cta lives on CtaCopy so action labels stay swappable + */ +export interface CopyBlock { + readonly headline: string; + readonly proof?: string; + readonly playfulLine?: string; + readonly body?: readonly string[]; +} + +export interface CtaCopy { + readonly label: string; + readonly routeId?: RouteId; + readonly href?: string; + readonly external?: boolean; +} + +export interface SectionCopy extends CopyBlock { + readonly id: string; +} + +export interface PageCopy { + readonly routeId: RouteId; + readonly title: string; + readonly description: string; + readonly hero: CopyBlock; + readonly sections: readonly SectionCopy[]; + readonly ctas: readonly CtaCopy[]; +} + +export interface CaseStudySummary { + readonly id: string; + readonly client: string; + readonly headline: string; + readonly proof?: string; + readonly tags: readonly string[]; + readonly routeId?: RouteId; +} + +export type LocalizedPages = Partial>; + +export interface SiteContent { + readonly pages: LocalizedPages; +} diff --git a/src/app/core/content/content.service.ts b/src/app/core/content/content.service.ts new file mode 100644 index 0000000..451254a --- /dev/null +++ b/src/app/core/content/content.service.ts @@ -0,0 +1,15 @@ +import { computed, inject, Injectable, type Signal } from '@angular/core'; +import { LocaleService } from '../i18n/locale.service'; +import { type RouteId } from '../routing/route-ids'; +import { type PageCopy } from './content.contracts'; +import { SITE_CONTENT } from './content.token'; + +@Injectable({ providedIn: 'root' }) +export class ContentService { + private readonly localeService = inject(LocaleService); + private readonly content = inject(SITE_CONTENT); + + page(routeId: RouteId): Signal { + return computed(() => this.content[this.localeService.locale()].pages[routeId]); + } +} diff --git a/src/app/core/content/content.token.ts b/src/app/core/content/content.token.ts new file mode 100644 index 0000000..e5df0a9 --- /dev/null +++ b/src/app/core/content/content.token.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; +import { type AppLocale } from '../i18n/locale'; +import { type SiteContent } from './content.contracts'; + +export const SITE_CONTENT = new InjectionToken>('SITE_CONTENT'); diff --git a/src/app/core/content/placeholder-content.ts b/src/app/core/content/placeholder-content.ts new file mode 100644 index 0000000..eeef219 --- /dev/null +++ b/src/app/core/content/placeholder-content.ts @@ -0,0 +1,58 @@ +import { type AppLocale } from '../i18n/locale'; +import { ROUTE_IDS, type RouteId } from '../routing/route-ids'; +import { type PageCopy, type SiteContent } from './content.contracts'; + +const PAGE_TITLES: Record> = { + home: { de: 'Startseite', en: 'Home' }, + services: { de: 'Leistungen', en: 'Services' }, + servicesSoftware: { de: 'Software', en: 'Software' }, + servicesHardwareNetwork: { de: 'Hardware und Netzwerk', en: 'Hardware and network' }, + servicesClusters: { de: 'Cluster', en: 'Clusters' }, + servicesAi: { de: 'KI-Integration', en: 'AI integration' }, + projects: { de: 'Projekte', en: 'Projects' }, + stack: { de: 'Stack', en: 'Stack' }, + about: { de: 'Über mich', en: 'About' }, + contact: { de: 'Kontakt', en: 'Contact' }, + imprint: { de: 'Impressum', en: 'Legal notice' }, + privacy: { de: 'Datenschutz', en: 'Privacy' }, + notFound: { de: 'Seite nicht gefunden', en: 'Page not found' }, +}; + +function scaffoldPage(routeId: RouteId, locale: AppLocale): PageCopy { + const title = PAGE_TITLES[routeId][locale]; + const description = + locale === 'de' ? 'Diese Seite wird derzeit aufgebaut.' : 'This page is being built.'; + + return { + routeId, + title, + description, + hero: { + headline: title, + body: [description], + }, + sections: [], + ctas: + routeId === 'notFound' + ? [ + { + label: locale === 'de' ? 'Zur Startseite' : 'Back to home', + routeId: 'home', + }, + ] + : [], + }; +} + +function pagesFor(locale: AppLocale): SiteContent { + const pages = Object.fromEntries( + ROUTE_IDS.map((routeId) => [routeId, scaffoldPage(routeId, locale)]), + ) as Record; + + return { pages }; +} + +export const PLACEHOLDER_CONTENT: Record = { + de: pagesFor('de'), + en: pagesFor('en'), +}; diff --git a/src/app/core/content/shell-copy.ts b/src/app/core/content/shell-copy.ts new file mode 100644 index 0000000..e5d4520 --- /dev/null +++ b/src/app/core/content/shell-copy.ts @@ -0,0 +1,49 @@ +import { type AppLocale } from '../i18n/locale'; + +export interface ShellCopy { + readonly skipLink: string; + readonly primaryNavLabel: string; + readonly footerNavLabel: string; + readonly menuOpen: string; + readonly menuClose: string; + readonly languageSwitch: string; + readonly otherLocaleName: string; + readonly cvLabel: string; + readonly contactCta: string; + readonly scaffoldingNote: string; + readonly legalReviewNotice: string; + readonly notFoundHomeLabel: string; +} + +export const SHELL_COPY: Record = { + de: { + skipLink: 'Zum Inhalt springen', + primaryNavLabel: 'Hauptnavigation', + footerNavLabel: 'Fußzeilen-Navigation', + menuOpen: 'Menü öffnen', + menuClose: 'Menü schließen', + languageSwitch: 'Zur englischen Version wechseln', + otherLocaleName: 'English', + cvLabel: 'Lebenslauf als PDF', + contactCta: 'Kontakt', + scaffoldingNote: 'Hinweis: Der endgültige Inhalt folgt. Diese Seite ist ein Gerüst.', + legalReviewNotice: + 'Platzhalter: Dieser Rechtstext ist ungeprüft und muss vor der Veröffentlichung vom Seitenbetreiber geprüft werden.', + notFoundHomeLabel: 'Zur Startseite', + }, + en: { + skipLink: 'Skip to content', + primaryNavLabel: 'Primary navigation', + footerNavLabel: 'Footer navigation', + menuOpen: 'Open menu', + menuClose: 'Close menu', + languageSwitch: 'Switch to the German version', + otherLocaleName: 'Deutsch', + cvLabel: 'Curriculum vitae as PDF', + contactCta: 'Contact', + scaffoldingNote: 'Note: Final content follows. This page is scaffolding.', + legalReviewNotice: + 'Placeholder: This legal text is unreviewed and must be checked by the site owner before publication.', + notFoundHomeLabel: 'Back to home', + }, +}; diff --git a/src/app/core/content/site-config.ts b/src/app/core/content/site-config.ts new file mode 100644 index 0000000..324f2d8 --- /dev/null +++ b/src/app/core/content/site-config.ts @@ -0,0 +1,7 @@ +export const SITE_CONFIG = { + personName: 'Antonio Ledebuhr', + contactEmail: 'info@antoniolede.de', + cvAssetPath: '/cv/CV.pdf', + cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf', + calendarUrl: null, +} as const; diff --git a/src/app/core/i18n/locale.resolver.ts b/src/app/core/i18n/locale.resolver.ts new file mode 100644 index 0000000..ac588f3 --- /dev/null +++ b/src/app/core/i18n/locale.resolver.ts @@ -0,0 +1,11 @@ +import { inject } from '@angular/core'; +import { type ResolveFn } from '@angular/router'; +import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from './locale'; +import { LocaleService } from './locale.service'; + +export const localeResolver: ResolveFn = (route) => { + const localeValue: unknown = route.data['locale']; + const locale = isAppLocale(localeValue) ? localeValue : DEFAULT_LOCALE; + inject(LocaleService).setLocale(locale); + return locale; +}; diff --git a/src/app/core/i18n/locale.service.ts b/src/app/core/i18n/locale.service.ts new file mode 100644 index 0000000..b280bc0 --- /dev/null +++ b/src/app/core/i18n/locale.service.ts @@ -0,0 +1,25 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { DEFAULT_LOCALE, LOCALE_HTML_LANG, type AppLocale } from './locale'; + +@Injectable({ providedIn: 'root' }) +export class LocaleService { + private readonly document = inject(DOCUMENT); + private readonly localeSignal = signal(DEFAULT_LOCALE); + + readonly locale = this.localeSignal.asReadonly(); + readonly htmlLang = computed(() => LOCALE_HTML_LANG[this.localeSignal()]); + + constructor() { + this.applyHtmlLang(DEFAULT_LOCALE); + } + + setLocale(locale: AppLocale): void { + this.localeSignal.set(locale); + this.applyHtmlLang(locale); + } + + private applyHtmlLang(locale: AppLocale): void { + this.document.documentElement.lang = LOCALE_HTML_LANG[locale]; + } +} diff --git a/src/app/core/i18n/locale.spec.ts b/src/app/core/i18n/locale.spec.ts new file mode 100644 index 0000000..32f1559 --- /dev/null +++ b/src/app/core/i18n/locale.spec.ts @@ -0,0 +1,44 @@ +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { DEFAULT_LOCALE, isAppLocale, otherLocale } from './locale'; +import { LocaleService } from './locale.service'; + +describe('locale helpers', () => { + it('defaults to German', () => { + expect(DEFAULT_LOCALE).toBe('de'); + }); + + it('accepts only de and en', () => { + expect(isAppLocale('de')).toBe(true); + expect(isAppLocale('en')).toBe(true); + expect(isAppLocale('fr')).toBe(false); + expect(isAppLocale('')).toBe(false); + expect(isAppLocale(null)).toBe(false); + }); + + it('round-trips the other locale', () => { + expect(otherLocale('de')).toBe('en'); + expect(otherLocale('en')).toBe('de'); + expect(otherLocale(otherLocale('de'))).toBe('de'); + }); +}); + +describe('LocaleService', () => { + it('updates the locale signal and the document lang attribute', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(LocaleService); + const document = TestBed.inject(DOCUMENT); + + expect(service.locale()).toBe('de'); + expect(document.documentElement.lang).toBe('de-DE'); + + service.setLocale('en'); + expect(service.locale()).toBe('en'); + expect(service.htmlLang()).toBe('en'); + expect(document.documentElement.lang).toBe('en'); + + service.setLocale('de'); + expect(service.locale()).toBe('de'); + expect(document.documentElement.lang).toBe('de-DE'); + }); +}); diff --git a/src/app/core/i18n/locale.ts b/src/app/core/i18n/locale.ts new file mode 100644 index 0000000..32f0457 --- /dev/null +++ b/src/app/core/i18n/locale.ts @@ -0,0 +1,18 @@ +export type AppLocale = 'de' | 'en'; + +export const APP_LOCALES: readonly AppLocale[] = ['de', 'en']; + +export const DEFAULT_LOCALE: AppLocale = 'de'; + +export const LOCALE_HTML_LANG: Record = { + de: 'de-DE', + en: 'en', +}; + +export function isAppLocale(value: unknown): value is AppLocale { + return value === 'de' || value === 'en'; +} + +export function otherLocale(locale: AppLocale): AppLocale { + return locale === 'de' ? 'en' : 'de'; +} diff --git a/src/app/core/navigation/navigation.service.spec.ts b/src/app/core/navigation/navigation.service.spec.ts new file mode 100644 index 0000000..792cabd --- /dev/null +++ b/src/app/core/navigation/navigation.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { routes } from '../../app.routes'; +import { PLACEHOLDER_CONTENT } from '../content/placeholder-content'; +import { SITE_CONTENT } from '../content/content.token'; +import { LocaleService } from '../i18n/locale.service'; +import { NavigationService } from './navigation.service'; + +describe('NavigationService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }], + }); + }); + + it('maps a German route to its English counterpart and back', async () => { + const harness = await RouterTestingHarness.create(); + const navigation = TestBed.inject(NavigationService); + + await harness.navigateByUrl('/projekte'); + expect(navigation.activeRouteId()).toBe('projects'); + expect(navigation.alternateLocaleLink()).toEqual(['/', 'en', 'projects']); + + await harness.navigateByUrl('/en/projects'); + expect(navigation.activeRouteId()).toBe('projects'); + expect(navigation.alternateLocaleLink()).toEqual(['/', 'projekte']); + }); + + it('builds links for the active locale', () => { + const navigation = TestBed.inject(NavigationService); + const locale = TestBed.inject(LocaleService); + + locale.setLocale('de'); + expect(navigation.link('projects')).toEqual(['/', 'projekte']); + expect(navigation.link('home')).toEqual(['/']); + + locale.setLocale('en'); + expect(navigation.link('projects')).toEqual(['/', 'en', 'projects']); + expect(navigation.link('home')).toEqual(['/', 'en']); + expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']); + }); +}); diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts new file mode 100644 index 0000000..997afbc --- /dev/null +++ b/src/app/core/navigation/navigation.service.ts @@ -0,0 +1,77 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { SITE_CONFIG } from '../content/site-config'; +import { otherLocale, type AppLocale } from '../i18n/locale'; +import { LocaleService } from '../i18n/locale.service'; +import { isAppRouteData } from '../routing/app-route-data'; +import { type RouteId } from '../routing/route-ids'; +import { routeCommands } from '../routing/route-paths'; +import { FOOTER_NAV, PRIMARY_NAV, type NavItem } from './navigation'; + +export interface ResolvedNavItem { + readonly routeId: RouteId; + readonly label: string; + readonly link: unknown[]; + readonly children?: readonly ResolvedNavItem[]; +} + +@Injectable({ providedIn: 'root' }) +export class NavigationService { + private readonly localeService = inject(LocaleService); + private readonly router = inject(Router); + private readonly activeRouteIdSignal = signal('home'); + + readonly activeRouteId = this.activeRouteIdSignal.asReadonly(); + readonly cvHref = SITE_CONFIG.cvAssetPath; + readonly contactLink = computed(() => this.link('contact')); + readonly primaryNav = computed(() => this.resolveItems(PRIMARY_NAV, this.localeService.locale())); + readonly footerNav = computed(() => this.resolveItems(FOOTER_NAV, this.localeService.locale())); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(() => this.syncFromRouter()); + + this.syncFromRouter(); + } + + link(routeId: RouteId, locale?: AppLocale): unknown[] { + return routeCommands(routeId, locale ?? this.localeService.locale()); + } + + alternateLocaleLink(): unknown[] { + const targetLocale = otherLocale(this.localeService.locale()); + const current = this.activeRouteId(); + const routeId = current === 'notFound' ? 'home' : current; + return routeCommands(routeId, targetLocale); + } + + private resolveItems(items: readonly NavItem[], locale: AppLocale): ResolvedNavItem[] { + return items.map((item) => ({ + routeId: item.routeId, + label: item.label[locale], + link: routeCommands(item.routeId, locale), + children: item.children ? this.resolveItems(item.children, locale) : undefined, + })); + } + + private syncFromRouter(): void { + let snapshot = this.router.routerState.snapshot.root; + + while (snapshot.firstChild) { + snapshot = snapshot.firstChild; + } + + if (!isAppRouteData(snapshot.data)) { + return; + } + + this.localeService.setLocale(snapshot.data.locale); + this.activeRouteIdSignal.set(snapshot.data.routeId); + } +} diff --git a/src/app/core/navigation/navigation.ts b/src/app/core/navigation/navigation.ts new file mode 100644 index 0000000..41374ff --- /dev/null +++ b/src/app/core/navigation/navigation.ts @@ -0,0 +1,68 @@ +import { type AppLocale } from '../i18n/locale'; +import { type RouteId } from '../routing/route-ids'; + +export interface NavItem { + readonly routeId: RouteId; + readonly label: Record; + readonly children?: readonly NavItem[]; +} + +export const PRIMARY_NAV: readonly NavItem[] = [ + { + routeId: 'home', + label: { de: 'Start', en: 'Home' }, + }, + { + routeId: 'services', + label: { de: 'Leistungen', en: 'Services' }, + children: [ + { + routeId: 'servicesSoftware', + label: { de: 'Software', en: 'Software' }, + }, + { + routeId: 'servicesHardwareNetwork', + label: { de: 'Hardware und Netzwerk', en: 'Hardware and network' }, + }, + { + routeId: 'servicesClusters', + label: { de: 'Cluster', en: 'Clusters' }, + }, + { + routeId: 'servicesAi', + label: { de: 'KI-Integration', en: 'AI integration' }, + }, + ], + }, + { + routeId: 'projects', + label: { de: 'Projekte', en: 'Projects' }, + }, + { + routeId: 'stack', + label: { de: 'Stack', en: 'Stack' }, + }, + { + routeId: 'about', + label: { de: 'Über mich', en: 'About' }, + }, + { + routeId: 'contact', + label: { de: 'Kontakt', en: 'Contact' }, + }, +]; + +export const FOOTER_NAV: readonly NavItem[] = [ + { + routeId: 'imprint', + label: { de: 'Impressum', en: 'Legal notice' }, + }, + { + routeId: 'privacy', + label: { de: 'Datenschutz', en: 'Privacy' }, + }, + { + routeId: 'contact', + label: { de: 'Kontakt', en: 'Contact' }, + }, +]; diff --git a/src/app/core/platform/browser.spec.ts b/src/app/core/platform/browser.spec.ts new file mode 100644 index 0000000..d9a2a21 --- /dev/null +++ b/src/app/core/platform/browser.spec.ts @@ -0,0 +1,23 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + isBrowserPlatform, + prefersCoarsePointer, + prefersReducedMotion, + viewportMatches, +} from './browser'; + +describe('browser platform helpers', () => { + it('returns conservative defaults on the server without throwing', () => { + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }], + }); + + TestBed.runInInjectionContext(() => { + expect(isBrowserPlatform()).toBe(false); + expect(prefersReducedMotion()).toBe(false); + expect(prefersCoarsePointer()).toBe(false); + expect(viewportMatches('(min-width: 40rem)')).toBe(false); + }); + }); +}); diff --git a/src/app/core/platform/browser.ts b/src/app/core/platform/browser.ts new file mode 100644 index 0000000..bb9fd11 --- /dev/null +++ b/src/app/core/platform/browser.ts @@ -0,0 +1,26 @@ +import { inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +export function isBrowserPlatform(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +export function prefersReducedMotion(): boolean { + return mediaQueryMatches('(prefers-reduced-motion: reduce)'); +} + +export function prefersCoarsePointer(): boolean { + return mediaQueryMatches('(pointer: coarse)'); +} + +export function viewportMatches(query: string): boolean { + return mediaQueryMatches(query); +} + +function mediaQueryMatches(query: string): boolean { + if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') { + return false; + } + + return window.matchMedia(query).matches; +} diff --git a/src/app/core/routing/app-route-data.ts b/src/app/core/routing/app-route-data.ts new file mode 100644 index 0000000..768ce55 --- /dev/null +++ b/src/app/core/routing/app-route-data.ts @@ -0,0 +1,16 @@ +import { isAppLocale, type AppLocale } from '../i18n/locale'; +import { type RouteId } from './route-ids'; + +export interface AppRouteData { + readonly routeId: RouteId; + readonly locale: AppLocale; +} + +export function isAppRouteData(value: unknown): value is AppRouteData { + if (typeof value !== 'object' || value === null) { + return false; + } + + const record = value as Record; + return typeof record['routeId'] === 'string' && isAppLocale(record['locale']); +} diff --git a/src/app/core/routing/route-ids.ts b/src/app/core/routing/route-ids.ts new file mode 100644 index 0000000..c0cbd47 --- /dev/null +++ b/src/app/core/routing/route-ids.ts @@ -0,0 +1,30 @@ +export type RouteId = + | 'home' + | 'services' + | 'servicesSoftware' + | 'servicesHardwareNetwork' + | 'servicesClusters' + | 'servicesAi' + | 'projects' + | 'stack' + | 'about' + | 'contact' + | 'imprint' + | 'privacy' + | 'notFound'; + +export const ROUTE_IDS: readonly RouteId[] = [ + 'home', + 'services', + 'servicesSoftware', + 'servicesHardwareNetwork', + 'servicesClusters', + 'servicesAi', + 'projects', + 'stack', + 'about', + 'contact', + 'imprint', + 'privacy', + 'notFound', +]; diff --git a/src/app/core/routing/route-paths.spec.ts b/src/app/core/routing/route-paths.spec.ts new file mode 100644 index 0000000..ab95166 --- /dev/null +++ b/src/app/core/routing/route-paths.spec.ts @@ -0,0 +1,46 @@ +import { APP_LOCALES } from '../i18n/locale'; +import { ROUTE_IDS, type RouteId } from './route-ids'; +import { prerenderablePaths, routePath, ROUTE_SEGMENTS } from './route-paths'; + +describe('route paths', () => { + const addressableIds = ROUTE_IDS.filter((routeId) => routeId !== 'notFound'); + + it('defines every route id in both locales', () => { + for (const locale of APP_LOCALES) { + for (const routeId of ROUTE_IDS) { + expect(ROUTE_SEGMENTS[locale][routeId]).toBeTypeOf('string'); + } + } + }); + + it('has no duplicate addressable paths within a locale', () => { + for (const locale of APP_LOCALES) { + const paths = addressableIds.map((routeId) => routePath(routeId, locale)); + expect(new Set(paths).size).toBe(paths.length); + } + }); + + it('builds the expected home and projects paths', () => { + expect(routePath('home', 'de')).toBe('/'); + expect(routePath('home', 'en')).toBe('/en'); + expect(routePath('projects', 'de')).toBe('/projekte'); + expect(routePath('projects', 'en')).toBe('/en/projects'); + }); + + it('includes both locales in prerenderable paths and excludes the wildcard', () => { + const paths = prerenderablePaths(); + + expect(paths).toContain('/'); + expect(paths).toContain('/en'); + expect(paths).toContain('/projekte'); + expect(paths).toContain('/en/projects'); + expect(paths.some((path) => path.includes('**'))).toBe(false); + expect(paths).not.toContain(''); + + for (const locale of APP_LOCALES) { + for (const routeId of addressableIds) { + expect(paths).toContain(routePath(routeId as RouteId, locale)); + } + } + }); +}); diff --git a/src/app/core/routing/route-paths.ts b/src/app/core/routing/route-paths.ts new file mode 100644 index 0000000..0c7fc71 --- /dev/null +++ b/src/app/core/routing/route-paths.ts @@ -0,0 +1,83 @@ +import { APP_LOCALES, type AppLocale } from '../i18n/locale'; +import { ROUTE_IDS, type RouteId } from './route-ids'; + +export const LOCALE_PREFIX: Record = { + de: '', + en: 'en', +}; + +export const ROUTE_SEGMENTS: Record> = { + de: { + home: '', + services: 'leistungen', + servicesSoftware: 'leistungen/software', + servicesHardwareNetwork: 'leistungen/hardware-netzwerk', + servicesClusters: 'leistungen/cluster', + servicesAi: 'leistungen/ai-integration', + projects: 'projekte', + stack: 'stack', + about: 'ueber-mich', + contact: 'kontakt', + imprint: 'impressum', + privacy: 'datenschutz', + notFound: '**', + }, + en: { + home: '', + services: 'services', + servicesSoftware: 'services/software', + servicesHardwareNetwork: 'services/hardware-network', + servicesClusters: 'services/clusters', + servicesAi: 'services/ai-integration', + projects: 'projects', + stack: 'stack', + about: 'about', + contact: 'contact', + imprint: 'legal-notice', + privacy: 'privacy', + notFound: '**', + }, +}; + +export function routePath(routeId: RouteId, locale: AppLocale): string { + if (routeId === 'notFound') { + return ''; + } + + const prefix = LOCALE_PREFIX[locale]; + const segment = ROUTE_SEGMENTS[locale][routeId]; + const parts = [prefix, segment].filter((part) => part.length > 0); + return parts.length === 0 ? '/' : `/${parts.join('/')}`; +} + +export function routeCommands(routeId: RouteId, locale: AppLocale): unknown[] { + if (routeId === 'notFound') { + return ['/']; + } + + const commands: string[] = ['/']; + const prefix = LOCALE_PREFIX[locale]; + const segment = ROUTE_SEGMENTS[locale][routeId]; + + if (prefix.length > 0) { + commands.push(prefix); + } + + if (segment.length > 0) { + commands.push(...segment.split('/')); + } + + return commands; +} + +export function prerenderablePaths(): readonly string[] { + return APP_LOCALES.flatMap((locale) => + ROUTE_IDS.filter((routeId) => routeId !== 'notFound').map((routeId) => + routePath(routeId, locale), + ), + ); +} + +export function prerenderableServerPaths(): readonly string[] { + return prerenderablePaths().map((path) => (path === '/' ? '' : path.slice(1))); +} diff --git a/src/app/features/about/about.html b/src/app/features/about/about.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/about/about.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/about/about.ts b/src/app/features/about/about.ts new file mode 100644 index 0000000..06eeb68 --- /dev/null +++ b/src/app/features/about/about.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-about-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './about.html', +}) +export class AboutPage { + protected readonly page = inject(ContentService).page('about'); +} diff --git a/src/app/features/contact/contact.html b/src/app/features/contact/contact.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/contact/contact.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/contact/contact.ts b/src/app/features/contact/contact.ts new file mode 100644 index 0000000..a8e3974 --- /dev/null +++ b/src/app/features/contact/contact.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-contact-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './contact.html', +}) +export class ContactPage { + protected readonly page = inject(ContentService).page('contact'); +} diff --git a/src/app/features/home/home.html b/src/app/features/home/home.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/home/home.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/home/home.ts b/src/app/features/home/home.ts new file mode 100644 index 0000000..fbe9a05 --- /dev/null +++ b/src/app/features/home/home.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-home-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './home.html', +}) +export class HomePage { + protected readonly page = inject(ContentService).page('home'); +} diff --git a/src/app/features/imprint/imprint.html b/src/app/features/imprint/imprint.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/imprint/imprint.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/imprint/imprint.ts b/src/app/features/imprint/imprint.ts new file mode 100644 index 0000000..adf25ae --- /dev/null +++ b/src/app/features/imprint/imprint.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-imprint-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './imprint.html', +}) +export class ImprintPage { + protected readonly page = inject(ContentService).page('imprint'); +} diff --git a/src/app/features/not-found/not-found.html b/src/app/features/not-found/not-found.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/not-found/not-found.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/not-found/not-found.ts b/src/app/features/not-found/not-found.ts new file mode 100644 index 0000000..56a5146 --- /dev/null +++ b/src/app/features/not-found/not-found.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-not-found-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './not-found.html', +}) +export class NotFoundPage { + protected readonly page = inject(ContentService).page('notFound'); +} diff --git a/src/app/features/privacy/privacy.html b/src/app/features/privacy/privacy.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/privacy/privacy.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/privacy/privacy.ts b/src/app/features/privacy/privacy.ts new file mode 100644 index 0000000..fc0ab2b --- /dev/null +++ b/src/app/features/privacy/privacy.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-privacy-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './privacy.html', +}) +export class PrivacyPage { + protected readonly page = inject(ContentService).page('privacy'); +} diff --git a/src/app/features/projects/projects.html b/src/app/features/projects/projects.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/projects/projects.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/projects/projects.ts b/src/app/features/projects/projects.ts new file mode 100644 index 0000000..a0838c5 --- /dev/null +++ b/src/app/features/projects/projects.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-projects-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './projects.html', +}) +export class ProjectsPage { + protected readonly page = inject(ContentService).page('projects'); +} diff --git a/src/app/features/services/ai-integration/ai-integration.html b/src/app/features/services/ai-integration/ai-integration.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/services/ai-integration/ai-integration.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/services/ai-integration/ai-integration.ts b/src/app/features/services/ai-integration/ai-integration.ts new file mode 100644 index 0000000..32fae08 --- /dev/null +++ b/src/app/features/services/ai-integration/ai-integration.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../../core/content/content.service'; +import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-services-ai-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './ai-integration.html', +}) +export class ServicesAiPage { + protected readonly page = inject(ContentService).page('servicesAi'); +} diff --git a/src/app/features/services/clusters/clusters.html b/src/app/features/services/clusters/clusters.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/services/clusters/clusters.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/services/clusters/clusters.ts b/src/app/features/services/clusters/clusters.ts new file mode 100644 index 0000000..95aef21 --- /dev/null +++ b/src/app/features/services/clusters/clusters.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../../core/content/content.service'; +import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-services-clusters-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './clusters.html', +}) +export class ServicesClustersPage { + protected readonly page = inject(ContentService).page('servicesClusters'); +} diff --git a/src/app/features/services/hardware-network/hardware-network.html b/src/app/features/services/hardware-network/hardware-network.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/services/hardware-network/hardware-network.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/services/hardware-network/hardware-network.ts b/src/app/features/services/hardware-network/hardware-network.ts new file mode 100644 index 0000000..192b036 --- /dev/null +++ b/src/app/features/services/hardware-network/hardware-network.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../../core/content/content.service'; +import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-services-hardware-network-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './hardware-network.html', +}) +export class ServicesHardwareNetworkPage { + protected readonly page = inject(ContentService).page('servicesHardwareNetwork'); +} diff --git a/src/app/features/services/services.html b/src/app/features/services/services.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/services/services.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/services/services.ts b/src/app/features/services/services.ts new file mode 100644 index 0000000..fe3cbae --- /dev/null +++ b/src/app/features/services/services.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../core/content/content.service'; +import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-services-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './services.html', +}) +export class ServicesPage { + protected readonly page = inject(ContentService).page('services'); +} diff --git a/src/app/features/services/software/software.html b/src/app/features/services/software/software.html new file mode 100644 index 0000000..292caf0 --- /dev/null +++ b/src/app/features/services/software/software.html @@ -0,0 +1,3 @@ +@if (page(); as copy) { + +} diff --git a/src/app/features/services/software/software.ts b/src/app/features/services/software/software.ts new file mode 100644 index 0000000..ab9f471 --- /dev/null +++ b/src/app/features/services/software/software.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ContentService } from '../../../core/content/content.service'; +import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder'; + +@Component({ + selector: 'app-services-software-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PagePlaceholder], + templateUrl: './software.html', +}) +export class ServicesSoftwarePage { + protected readonly page = inject(ContentService).page('servicesSoftware'); +} diff --git a/src/app/features/stack/stack.html b/src/app/features/stack/stack.html new file mode 100644 index 0000000..0083c50 --- /dev/null +++ b/src/app/features/stack/stack.html @@ -0,0 +1 @@ + diff --git a/src/app/features/stack/stack.ts b/src/app/features/stack/stack.ts new file mode 100644 index 0000000..5200b59 --- /dev/null +++ b/src/app/features/stack/stack.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Skills } from '../../components/pages/skills/skills'; + +@Component({ + selector: 'app-stack-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Skills], + templateUrl: './stack.html', +}) +export class StackPage {} diff --git a/src/app/models/skill-category.ts b/src/app/models/skill-category.ts index edb3257..9eb4ea5 100644 --- a/src/app/models/skill-category.ts +++ b/src/app/models/skill-category.ts @@ -1,4 +1,4 @@ -import {Skill} from './skill'; +import { Skill } from './skill'; export interface SkillCategory { title: string; diff --git a/src/app/service/device-detection-service.spec.ts b/src/app/service/device-detection-service.spec.ts deleted file mode 100644 index d62e504..0000000 --- a/src/app/service/device-detection-service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { DeviceDetectionService } from './device-detection-service'; - -describe('DeviceDetectionService', () => { - let service: DeviceDetectionService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(DeviceDetectionService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/service/device-detection-service.ts b/src/app/service/device-detection-service.ts deleted file mode 100644 index 62c3fe9..0000000 --- a/src/app/service/device-detection-service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root', -}) -export class DeviceDetectionService { - public mobileCheck() { - let check = false; - (function (a) { - if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) - check = true - })(navigator.userAgent || navigator.vendor || (window as any).opera); - return check; - }; -} diff --git a/src/app/shared/page-placeholder/page-placeholder.html b/src/app/shared/page-placeholder/page-placeholder.html new file mode 100644 index 0000000..c17d0c4 --- /dev/null +++ b/src/app/shared/page-placeholder/page-placeholder.html @@ -0,0 +1,13 @@ +
diff --git a/src/app/shared/page-placeholder/page-placeholder.scss b/src/app/shared/page-placeholder/page-placeholder.scss new file mode 100644 index 0000000..7c0b371 --- /dev/null +++ b/src/app/shared/page-placeholder/page-placeholder.scss @@ -0,0 +1,31 @@ +.page { + padding-block: var(--space-10); +} + +.lead { + color: var(--color-text-muted); + font-size: var(--text-lg); + max-width: 40rem; +} + +.scaffolding-note, +.legal-notice { + margin: 0; + padding: var(--space-4); + border-radius: var(--radius-md); + color: var(--color-text); +} + +.scaffolding-note { + border-left: var(--focus-ring-width) solid var(--color-accent); + background: var(--color-surface-raised); +} + +.legal-notice { + border: 1px solid var(--color-accent-strong); + background: var(--color-surface-overlay); +} + +a { + color: var(--color-accent-cool); +} diff --git a/src/app/shared/page-placeholder/page-placeholder.ts b/src/app/shared/page-placeholder/page-placeholder.ts new file mode 100644 index 0000000..245e01f --- /dev/null +++ b/src/app/shared/page-placeholder/page-placeholder.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { type PageCopy } from '../../core/content/content.contracts'; +import { SHELL_COPY } from '../../core/content/shell-copy'; +import { LocaleService } from '../../core/i18n/locale.service'; +import { NavigationService } from '../../core/navigation/navigation.service'; + +@Component({ + selector: 'app-page-placeholder', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + templateUrl: './page-placeholder.html', + styleUrl: './page-placeholder.scss', +}) +export class PagePlaceholder { + readonly page = input.required(); + + private readonly localeService = inject(LocaleService); + private readonly navigation = inject(NavigationService); + + protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]); + protected readonly homeLink = computed(() => this.navigation.link('home')); + protected readonly isNotFound = computed(() => this.page().routeId === 'notFound'); + protected readonly showLegalNotice = computed(() => { + const routeId = this.page().routeId; + return routeId === 'imprint' || routeId === 'privacy'; + }); +} diff --git a/src/index.html b/src/index.html index 68bc202..d312bd8 100644 --- a/src/index.html +++ b/src/index.html @@ -1,13 +1,13 @@ - - - - Portfolio - - - - - - - + + + + Portfolio + + + + + + + diff --git a/src/main.server.ts b/src/main.server.ts index 723e001..9b1aba7 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -2,7 +2,6 @@ import { BootstrapContext, bootstrapApplication } from '@angular/platform-browse import { App } from './app/app'; import { config } from './app/app.config.server'; -const bootstrap = (context: BootstrapContext) => - bootstrapApplication(App, config, context); +const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context); export default bootstrap; diff --git a/src/main.ts b/src/main.ts index 5df75f9..190f341 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,5 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; -bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/src/server.ts b/src/server.ts index 4bf0631..d5c9240 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,9 +41,7 @@ app.use( app.use((req, res, next) => { angularApp .handle(req) - .then((response) => - response ? writeResponseToNodeResponse(response, res) : next(), - ) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) .catch(next); }); diff --git a/src/styles.scss b/src/styles.scss index a729b8b..93f2103 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,15 +1,180 @@ -/* You can add global styles to this file, and also import other style files */ +/* Design tokens — the only place raw brand hex values are defined. */ +:root { + /* Color — surface ramp built on #0a0a0f */ + --color-surface: #0a0a0f; + --color-surface-raised: #12121a; + --color-surface-overlay: #1a1a24; + --color-surface-muted: #22222e; + + /* Color — accent ramp */ + --color-accent: #6366f1; + --color-accent-strong: #8b5cf6; + --color-accent-soft: #a855f7; + --color-accent-cool: #3b82f6; + + /* Color — text ramp. muted and subtle stay at least 4.5:1 on --color-surface. */ + --color-text: #f4f4f8; + --color-text-muted: #d2d2dc; + --color-text-subtle: #b8b8c8; + + /* Color — glass and elevation */ + --surface-glass: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.05) 100% + ); + --surface-glass-border: rgba(255, 255, 255, 0.15); + --surface-glass-border-strong: rgba(255, 255, 255, 0.25); + --shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1); + --shadow-raised: 0 8px 32px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.15); + --blur-glass: 16px; + + /* Typography */ + --font-sans: system-ui, sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + --text-xs: clamp(0.75rem, 0.7rem + 0.2vw, 0.8125rem); + --text-sm: clamp(0.875rem, 0.8rem + 0.25vw, 0.9375rem); + --text-md: clamp(1rem, 0.95rem + 0.3vw, 1.125rem); + --text-lg: clamp(1.125rem, 1rem + 0.5vw, 1.375rem); + --text-xl: clamp(1.375rem, 1.1rem + 0.9vw, 1.75rem); + --text-2xl: clamp(1.75rem, 1.3rem + 1.4vw, 2.25rem); + --text-3xl: clamp(2.25rem, 1.6rem + 2vw, 3rem); + --leading-tight: 1.2; + --leading-normal: 1.5; + --leading-relaxed: 1.7; + --tracking-wide: 0.08em; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-7: 2rem; + --space-8: 2.5rem; + --space-9: 3rem; + --space-10: 4rem; + --space-11: 5rem; + --space-12: 6rem; + + /* Radii and layout */ + --radius-sm: 0.375rem; + --radius-md: 0.75rem; + --radius-lg: 1rem; + --radius-pill: 999px; + --content-max: 72rem; + --content-gutter: clamp(1rem, 4vw, 2rem); + + /* Motion */ + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); + + /* Focus */ + --focus-ring-color: var(--color-accent); + --focus-ring-width: 2px; + --focus-ring-offset: 3px; +} + *, *::before, *::after { box-sizing: border-box; } +html { + color-scheme: dark; + scroll-behavior: smooth; +} + body { margin: 0; - background: #0a0a0f; - color: #fff; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--text-md); + line-height: var(--leading-normal); -webkit-font-smoothing: antialiased; - color-scheme: dark; } + +:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + top: var(--space-2); + left: var(--space-2); + z-index: 1000; + padding: var(--space-2) var(--space-4); + background: var(--color-surface-raised); + color: var(--color-text); + border-radius: var(--radius-sm); + text-decoration: none; + transform: translateY(-200%); + transition: transform var(--duration-fast) var(--ease-standard); +} + +.skip-link:focus { + transform: translateY(0); +} + +.glass-surface { + background: var(--surface-glass); + backdrop-filter: blur(var(--blur-glass)); + -webkit-backdrop-filter: blur(var(--blur-glass)); + border: 1px solid var(--surface-glass-border); + box-shadow: var(--shadow-soft); +} + +.content-container { + width: min(100% - 2 * var(--content-gutter), var(--content-max)); + margin-inline: auto; +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.cluster { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index ef19921..908478b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,14 +4,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [ - "node" - ] + "types": ["node"] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/tsconfig.spec.json b/tsconfig.spec.json index d383706..26230b0 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,12 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "vitest/globals" - ] + "types": ["vitest/globals"] }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] }