refactor: Migrate to monorepo

BREAKING CHANGE: Complete structure overhaul
This commit is contained in:
deytenit committed 2026-01-24 23:28:36 +03:00
1 parent 5300b4bae0
commit 46a2f68342
496 files changed
+45388 -7531

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
# Testing Configuration
The project uses Jest with a clean configuration structure located in `.config/jest/`:
```
.config/jest/
├── setup.ts # Global test environment setup
├── test-utils.tsx # Custom render utilities for React components
└── tsconfig.json # TypeScript configuration for test files
```
## Setup Files
### `setup.ts`
Global test environment configuration including browser API mocks (IntersectionObserver, ResizeObserver, matchMedia) needed for testing React components in jsdom.
### `test-utils.tsx`
Custom render function with project-specific providers and configurations. Import this instead of `@testing-library/react` directly:
```typescript
import { render, screen } from '@test/test-utils';
import { MyComponent } from './MyComponent';
// Use the custom render function
render(<MyComponent />);
expect(screen.getByText('Hello')).toBeInTheDocument();
```
## Path Aliases
The Jest configuration includes a `@test/*` alias that maps to `.config/jest/`:
```typescript
// Import test utilities
import { render, screen } from '@test/test-utils';
```
This keeps test configuration separate from source code while maintaining clean imports.
-39
View File
@@ -1,39 +0,0 @@
import '@testing-library/jest-dom';
/**
* Global test environment setup for Jest.
*
* This file configures browser APIs and global mocks needed for testing
* React components in a jsdom environment.
*/
// Mock matchMedia for components that use CSS media queries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver for components using intersection detection
(global as any).IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock ResizeObserver for components using resize detection
(global as any).ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
-31
View File
@@ -1,31 +0,0 @@
import { render, type RenderOptions } from '@testing-library/react';
import type { ReactElement } from 'react';
/**
* Custom render function with default providers and configurations
* @param ui - React element to render
* @param options - Additional render options
* @returns Render result from testing library
*
* @example
* ```typescript
* import { render, screen } from '.config/jest/test-utils';
* import { MyComponent } from './MyComponent';
*
* render(<MyComponent />);
* expect(screen.getByText('Hello')).toBeInTheDocument();
* ```
*/
const customRender = (ui: ReactElement, options?: RenderOptions) => {
return render(ui, {
// Add any providers here if needed (e.g., ThemeProvider, Router, etc.)
...options,
});
};
// Re-export everything
export * from '@testing-library/react';
// Override render method
export { customRender as render };
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom", "node"]
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
@@ -1,40 +0,0 @@
name: next/checkout-release
on:
workflow_dispatch:
inputs:
release_version:
description: 'Release version (e.g. v1.2.0)'
required: true
default: 'v1.0.0'
base_branch:
description: 'Trunk branch to branch from'
required: true
default: 'next'
permissions:
contents: write
id-token: write
jobs:
create-release-branch:
runs-on: ubuntu-latest
steps:
- name: Checkout selected main branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.base_branch }}
fetch-depth: 0
fetch-tags: true
- name: Create release branch
run: |
RELEASE="release/${{ github.event.inputs.release_version }}"
echo "Creating branch $RELEASE off ${{ github.event.inputs.base_branch }}"
git checkout -b "$RELEASE"
git push -u origin "$RELEASE"
- name: Output branch URL
run: |
echo "Release branch created:"
echo "https://github.com/${{ github.repository }}/tree/release/${{ github.event.inputs.release_version }}"
@@ -1,150 +0,0 @@
name: next/semantic-dispatch
on:
workflow_run:
workflows: ['next/required']
branches: [next]
types:
- completed
concurrency:
group: ci-${{ github.ref_name }}
cancel-in-progress: true
jobs:
setup:
name: Setup stage
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
release:
runs-on: ubuntu-latest
needs: setup
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
packages: write
outputs:
new-release-published: ${{ steps.semantic.outputs.new-release-published }}
new-release-version: ${{ steps.semantic.outputs.new-release-version }}
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Stage
run: pnpm build
- name: Build Storybook
run: pnpm build-storybook
- name: Pre-release
id: semantic
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm exec semantic-release --prerelease
docker:
runs-on: ubuntu-latest
needs: release
if: needs.release.outputs.new-release-published == 'true'
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Astro image
id: meta-astro
uses: docker/metadata-action@v5
with:
images: ghcr.io/deytenit/www.ermnvldmr.com
tags: |
type=raw,value=${{ needs.release.outputs.new-release-version }}
type=semver,pattern={{major}}.{{minor}},suffix=-next,value=${{ needs.release.outputs.new-release-version }},match=(\d+\.\d+\.\d+),enable={{is_default_branch}}
type=semver,pattern={{major}},suffix=-next,value=${{ needs.release.outputs.new-release-version }},match=(\d+\.\d+\.\d+),enable={{is_default_branch}}
type=raw,value=latest-next
type=sha,prefix={{branch}}-
- name: Extract metadata for Storybook image
id: meta-storybook
uses: docker/metadata-action@v5
with:
images: ghcr.io/deytenit/www.ermnvldmr.com/storybook
tags: |
type=raw,value=${{ needs.release.outputs.new-release-version }}
type=semver,pattern={{major}}.{{minor}},suffix=-next,value=${{ needs.release.outputs.new-release-version }},match=(\d+\.\d+\.\d+),enable={{is_default_branch}}
type=semver,pattern={{major}},suffix=-next,value=${{ needs.release.outputs.new-release-version }},match=(\d+\.\d+\.\d+),enable={{is_default_branch}}
type=raw,value=latest-next
type=sha,prefix={{branch}}-
- name: Build and push Astro image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/astro/Dockerfile
push: true
tags: ${{ steps.meta-astro.outputs.tags }}
labels: ${{ steps.meta-astro.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Build and push Storybook image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/storybook/Dockerfile
push: true
tags: ${{ steps.meta-storybook.outputs.tags }}
labels: ${{ steps.meta-storybook.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
@@ -1,11 +1,14 @@
name: next/required
name: packages/stl - next/required
on:
push:
branches: [next]
paths:
- 'packages/stl/**'
- 'pnpm-lock.yaml'
concurrency:
group: ci-${{ github.ref_name }}
group: ci-stl-${{ github.ref_name }}
cancel-in-progress: true
jobs:
@@ -40,7 +43,7 @@ jobs:
run: pnpm install --frozen-lockfile
lint:
name: Lint
name: Lint STL
needs: setup
runs-on: ubuntu-latest
steps:
@@ -55,11 +58,11 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint (CI mode)
run: pnpm run lint --max-warnings=0
- name: Lint STL package
run: pnpm --filter @ermnvldmr/stl lint
type-check:
name: Typecheck
name: Typecheck STL
needs: setup
runs-on: ubuntu-latest
steps:
@@ -74,37 +77,12 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run TypeScript compiler
run: pnpm exec tsc --noEmit
storybook-build:
name: Build Storybook
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm run build-storybook --quiet
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
retention-days: 1
- name: Typecheck STL package
run: pnpm --filter @ermnvldmr/stl typecheck
test:
name: Test
needs: [setup, storybook-build]
name: Test STL
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -118,27 +96,18 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run unit tests with coverage
run: pnpm run test:coverage
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static/
- name: Serve Storybook and run tests with coverage
run: pnpm run test:storybook:ci
- name: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info,./coverage/storybook/coverage-storybook.json
flags: unittests,storybook
files: ./packages/stl/coverage/lcov.info
flags: stl
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
name: Build
name: Build STL Package
needs: setup
runs-on: ubuntu-latest
steps:
@@ -153,5 +122,5 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Astro build
run: pnpm exec astro build
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
@@ -1,14 +1,15 @@
name: pr/required
name: packages/ui - next/required
on:
pull_request:
branches: [main, next]
paths-ignore:
- '**/*.md'
- '.github/ISSUE_TEMPLATE/**'
push:
branches: [next]
paths:
- 'packages/ui/**'
- 'packages/stl/**'
- 'pnpm-lock.yaml'
concurrency:
group: ci-${{ github.ref_name }}
group: ci-ui-${{ github.ref_name }}
cancel-in-progress: true
jobs:
@@ -43,7 +44,7 @@ jobs:
run: pnpm install --frozen-lockfile
lint:
name: Lint
name: Lint UI & Dependencies
needs: setup
runs-on: ubuntu-latest
steps:
@@ -58,11 +59,13 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint (CI mode)
run: pnpm run lint --max-warnings=0
- name: Lint STL package
run: pnpm --filter @ermnvldmr/stl lint
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
type-check:
name: Typecheck
name: Typecheck UI & Dependencies
needs: setup
runs-on: ubuntu-latest
steps:
@@ -77,11 +80,13 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run TypeScript compiler
run: pnpm exec tsc --noEmit
- name: Typecheck STL package
run: pnpm --filter @ermnvldmr/stl typecheck
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
storybook-build:
name: Build Storybook
build-dependencies:
name: Build Dependencies (STL)
needs: setup
runs-on: ubuntu-latest
steps:
@@ -96,18 +101,12 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm run build-storybook --quiet
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
retention-days: 1
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
test:
name: Test
needs: [setup, storybook-build]
name: Test UI & Dependencies
needs: [setup, build-dependencies]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -121,28 +120,23 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run unit tests with coverage
run: pnpm run test:coverage
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static/
- name: Serve Storybook and run tests with coverage
run: pnpm run test:storybook:ci
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info,./coverage/storybook/coverage-storybook.json
flags: unittests,storybook
files: ./packages/stl/coverage/lcov.info,./packages/ui/coverage/lcov.info
flags: stl,ui
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
name: Build
needs: setup
storybook-build:
name: Build UI Storybook
needs: [setup, build-dependencies]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -156,5 +150,30 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Astro build
run: pnpm exec astro build
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
- name: Build UI Storybook
run: pnpm --filter @ermnvldmr/ui build-storybook
build:
name: Build UI Package
needs: [setup, build-dependencies]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
@@ -1,89 +0,0 @@
name: release/deploy-production
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
env:
BUILD_PATH: '.'
jobs:
setup:
name: Setup stage
runs-on: ubuntu-latest
if: ${{ ! github.event.release.prerelease }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build:
name: Setup stage
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Stage
run: pnpm build
- name: Upload artifacts
uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy stage
steps:
- name: Deploy in production
id: deployment
uses: actions/deploy-pages@v4
@@ -1,145 +0,0 @@
name: release/semantic-dispatch
on:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref_name }}
cancel-in-progress: true
jobs:
setup:
name: Setup stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
release:
runs-on: ubuntu-latest
needs: setup
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
packages: write
outputs:
new-release-published: ${{ steps.semantic.outputs.new-release-published }}
new-release-version: ${{ steps.semantic.outputs.new-release-version }}
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Stage
run: pnpm build
- name: Build Storybook
run: pnpm build-storybook
- name: Release
id: semantic
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm exec semantic-release
docker:
runs-on: ubuntu-latest
needs: release
if: needs.release.outputs.new-release-published == 'true'
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Astro image
id: meta-astro
uses: docker/metadata-action@v5
with:
images: ghcr.io/deytenit/www.ermnvldmr.com
tags: |
type=raw,value=${{ needs.release.outputs.new-release-version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.new-release-version }},enable={{is_default_branch}}
type=semver,pattern={{major}},value=${{ needs.release.outputs.new-release-version }},enable={{is_default_branch}}
type=raw,value=latest
type=sha,prefix={{branch}}-
- name: Extract metadata for Storybook image
id: meta-storybook
uses: docker/metadata-action@v5
with:
images: ghcr.io/deytenit/www.ermnvldmr.com/storybook
tags: |
type=raw,value=${{ needs.release.outputs.new-release-version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.new-release-version }},enable={{is_default_branch}}
type=semver,pattern={{major}},value=${{ needs.release.outputs.new-release-version }},enable={{is_default_branch}}
type=raw,value=latest
type=sha,prefix={{branch}}-
- name: Build and push Astro image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/astro/Dockerfile
push: true
tags: ${{ steps.meta-astro.outputs.tags }}
labels: ${{ steps.meta-astro.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Build and push Storybook image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/storybook/Dockerfile
push: true
tags: ${{ steps.meta-storybook.outputs.tags }}
labels: ${{ steps.meta-storybook.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
@@ -0,0 +1,49 @@
name: services/docs - next/required
on:
push:
branches: [next]
paths:
- 'services/docs/**'
concurrency:
group: ci-docs-${{ github.ref_name }}
cancel-in-progress: true
jobs:
setup:
name: Setup Hugo
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
extended: true
build:
name: Build Hugo Site
needs: setup
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
extended: true
- name: Build Hugo site
working-directory: services/docs
run: hugo --gc --minify
@@ -0,0 +1,207 @@
name: services/www - next/required
on:
push:
branches: [next]
paths:
- 'services/www/**'
- 'packages/ui/**'
- 'packages/stl/**'
- 'pnpm-lock.yaml'
concurrency:
group: ci-www-${{ github.ref_name }}
cancel-in-progress: true
jobs:
setup:
name: Setup stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build-dependencies:
name: Build Dependencies (STL → UI)
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
lint:
name: Lint WWW & Dependencies
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint STL package
run: pnpm --filter @ermnvldmr/stl lint
- name: Lint UI package
run: pnpm --filter @ermnvldmr/ui lint
- name: Lint WWW service
run: pnpm --filter @ermnvldmr/www lint
type-check:
name: Typecheck WWW & Dependencies
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck STL package
run: pnpm --filter @ermnvldmr/stl typecheck
- name: Typecheck UI package
run: pnpm --filter @ermnvldmr/ui typecheck
- name: Typecheck WWW service
run: pnpm --filter @ermnvldmr/www typecheck
storybook-build:
name: Build Storybook
needs: [setup, build-dependencies]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
- name: Build WWW Storybook
run: pnpm --filter @ermnvldmr/www build-storybook
- name: Upload Storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: services/www/storybook-static/
retention-days: 1
test:
name: Test
needs: [setup, storybook-build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
- name: Test STL package
run: pnpm --filter @ermnvldmr/stl test:coverage
- name: Test UI package
run: pnpm --filter @ermnvldmr/ui test:coverage
- name: Test WWW service
run: pnpm --filter @ermnvldmr/www test:coverage
- name: Download Storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: services/www/storybook-static/
- name: Serve Storybook and run tests with coverage
run: pnpm --filter @ermnvldmr/www test:storybook:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./services/www/coverage/lcov.info,./services/www/coverage/storybook/coverage-storybook.json
flags: unittests,storybook
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
name: Build WWW Service
needs: [setup, build-dependencies]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build STL package
run: pnpm --filter @ermnvldmr/stl build
- name: Build UI package
run: pnpm --filter @ermnvldmr/ui build
- name: Build WWW service
run: pnpm --filter @ermnvldmr/www build
-46
View File
@@ -1,46 +0,0 @@
import path from 'path';
import { mergeConfig } from 'vite';
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'@storybook/addon-coverage',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
viteFinal: async (config) => {
const tailwindcss = (await import('@tailwindcss/vite')).default;
const istanbul = (await import('vite-plugin-istanbul')).default;
return mergeConfig(config, {
plugins: [
tailwindcss(),
istanbul({
include: 'src/**/*',
exclude: ['node_modules', 'test/', '**/*.stories.tsx', '**/*.test.tsx'],
extension: ['.js', '.jsx', '.ts', '.tsx'],
requireEnv: false,
cypress: false,
forceBuildInstrument: true,
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, '../src'),
'@shadcn': path.resolve(__dirname, '../src/shadcn'),
},
},
});
},
};
export default config;
-66
View File
@@ -1,66 +0,0 @@
# Codecov configuration for www.ermnvldmr.com
# Documentation: https://docs.codecov.com/docs/codecovyml-reference
# Coverage reporting settings
coverage:
# Set precision for coverage percentage (0-5 decimal places)
precision: 2
# Round coverage percentage (up, down, nearest)
round: nearest
# Coverage range (red to green)
range: 70..90
# Status checks configuration
status:
# Overall project coverage
project:
default:
# Require coverage to be within 1% of base branch
threshold: 1%
# Target minimum coverage
target: 80%
# Allow coverage to drop by at most 1%
if_ci_failed: error
# Patch coverage (for new code in PRs)
patch:
default:
# Require 80% coverage on new code
target: 80%
# Only post status if coverage changes
only_pulls: true
# Comment settings for PRs
comment:
# Customize the PR comment layout
layout: "header, diff, flags, files"
# Show coverage for files with changes
behavior: default
# Require changes to post comment
require_changes: true
# Coverage collection
codecov:
# Require CI to pass before processing coverage
require_ci_to_pass: true
# Maximum number of reports to wait for
max_report_age: "24h"
# File path adjustments
fixes:
- "src/::" # Remove src/ prefix from file paths in reports
# Ignore certain files from coverage
ignore:
- "**/*.stories.tsx" # Storybook files
- "**/*.test.tsx" # Test files themselves
- "**/*.test.ts" # Test files themselves
- "**/test-utils.*" # Test utilities
- ".storybook/**" # Storybook config
- ".config/**" # Configuration files
- "dist/**" # Build output
- "coverage/**" # Coverage reports
- "**/*.d.ts" # Type definitions
+26 -13
View File
@@ -1,47 +1,60 @@
# Docker Compose for local development and testing
# Use: docker-compose up to start both services locally
# Use: docker-compose up to start services locally
version: '3.8'
services:
# Astro static site
astro:
# WWW service (Astro static site)
www:
build:
context: .
dockerfile: docker/astro/Dockerfile
target: production
ports:
- "3000:8080"
container_name: www-ermnvldmr-astro
container_name: ermnvldmr-www
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Storybook component library
storybook:
# WWW Storybook
www-storybook:
build:
context: .
dockerfile: docker/storybook/Dockerfile
target: production
ports:
- "6006:8080"
container_name: www-ermnvldmr-storybook
container_name: ermnvldmr-www-storybook
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Docs service (Hugo static site) - Future implementation
# docs:
# build:
# context: .
# dockerfile: docker/hugo/Dockerfile
# target: production
# ports:
# - "3001:8080"
# container_name: ermnvldmr-docs
# environment:
# - NODE_ENV=production
# restart: unless-stopped
# Optional: Add a reverse proxy for unified access
# nginx-proxy:
# image: nginx:alpine
@@ -50,10 +63,10 @@ services:
# volumes:
# - ./docker/nginx/proxy.conf:/etc/nginx/conf.d/default.conf:ro
# depends_on:
# - astro
# - storybook
# container_name: www-ermnvldmr-proxy
# - www
# - www-storybook
# container_name: ermnvldmr-proxy
networks:
default:
name: www-ermnvldmr-network
name: ermnvldmr-infrastructure
-213
View File
@@ -1,213 +0,0 @@
# Docker Configuration
This directory contains Docker configurations for containerizing the www.ermnvldmr.com website and Storybook component library.
## Structure
```
docker/
├── astro/
│ └── Dockerfile # Astro static site container
├── storybook/
│ └── Dockerfile # Storybook component library container
└── nginx/
├── nginx.conf # Main nginx configuration
├── astro.conf # Astro site server configuration
└── storybook.conf # Storybook server configuration
```
## Images
### Main Website
- **Image**: `ghcr.io/deytenit/www.ermnvldmr.com`
- **Source**: `docker/astro/Dockerfile`
- **Purpose**: Serves the static Astro website
### Component Library
- **Image**: `ghcr.io/deytenit/www.ermnvldmr.com/storybook`
- **Source**: `docker/storybook/Dockerfile`
- **Purpose**: Serves the Storybook component documentation
## Quick Start
### Local Development
```bash
# Start both containers
docker-compose up
# Build and start
docker-compose up --build
# Access services
# Main site: http://localhost:3000
# Storybook: http://localhost:6006
```
### Manual Build
```bash
# Build Astro site
docker build -f docker/astro/Dockerfile -t www-astro .
# Build Storybook
docker build -f docker/storybook/Dockerfile -t www-storybook .
# Run containers
docker run -p 3000:8080 www-astro
docker run -p 6006:8080 www-storybook
```
## Key Features
### Security
- **Non-root execution**: Containers run as `nginx-user` (UID 1001)
- **Minimal attack surface**: Alpine Linux base with minimal packages
- **Security headers**: Comprehensive HTTP security headers configured
- **Non-privileged ports**: Expose port 8080 instead of 80
### Performance
- **Multi-stage builds**: Separate build and runtime stages for smaller images
- **Static content optimization**: nginx tuned for static file serving
- **Compression**: Gzip compression for all text-based assets
- **Caching**: Proper cache headers for optimal performance
### Reliability
- **Health checks**: Built-in health monitoring endpoints
- **Signal handling**: Proper signal handling with dumb-init
- **Graceful shutdown**: Clean container shutdown procedures
- **Resource limits**: Configurable resource constraints
## Configuration
### Environment Variables
The containers are designed to be stateless and don't require runtime environment variables. All configuration is baked into the build process.
### nginx Customization
Modify nginx configurations in the `nginx/` directory:
- **nginx.conf**: Global nginx settings (workers, gzip, security)
- **astro.conf**: Astro-specific routing and caching
- **storybook.conf**: Storybook-specific SPA routing and CSP
### Build Arguments
No build arguments are currently required, but can be added for customization:
```dockerfile
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
```
## Deployment
### CI/CD Integration
Images are automatically built and pushed via GitHub Actions when:
- **Pre-release**: Push to `next` branch triggers semantic pre-release
- **Release**: Manual workflow dispatch creates stable release
### Production Usage
```bash
# Pull latest images
docker pull ghcr.io/deytenit/www.ermnvldmr.com:latest
docker pull ghcr.io/deytenit/www.ermnvldmr.com/storybook:latest
# Run with restart policy
docker run -d \
--name www-astro \
-p 3000:8080 \
--restart unless-stopped \
ghcr.io/deytenit/www.ermnvldmr.com:latest
docker run -d \
--name www-storybook \
-p 6006:8080 \
--restart unless-stopped \
ghcr.io/deytenit/www.ermnvldmr.com/storybook:latest
```
### Reverse Proxy Integration
Both containers are designed to work behind a reverse proxy:
- Expose port 8080 (non-privileged)
- Include proper health check endpoints
- Forward client IP and headers correctly
- Support SSL termination at proxy level
## Monitoring
### Health Checks
```bash
# Container health
curl http://localhost:3000/health
curl http://localhost:6006/health
# Docker health status
docker ps # Shows health status
```
### Logs
```bash
# View logs
docker logs container-name
# Follow logs
docker logs -f container-name
# Docker Compose logs
docker-compose logs -f
```
## Troubleshooting
### Common Issues
1. **Build failures**: Check Node.js version and pnpm compatibility
2. **Permission errors**: Verify nginx user configuration
3. **Port conflicts**: Ensure ports 3000 and 6006 are available
4. **Health check failures**: Verify nginx configuration and file permissions
### Debug Access
```bash
# Interactive shell
docker exec -it container-name sh
# Debug container without starting services
docker run -it --entrypoint=/bin/sh image-name
```
## Security
### Vulnerability Scanning
Images are automatically scanned for vulnerabilities in the CI/CD pipeline. For manual scanning:
```bash
# Using Docker Scout
docker scout cves ghcr.io/deytenit/www.ermnvldmr.com:latest
# Using Trivy
trivy image ghcr.io/deytenit/www.ermnvldmr.com:latest
```
### Security Best Practices
- Regular base image updates
- Non-root user execution
- Minimal package installation
- Security header enforcement
- Access logging enabled
---
For detailed deployment and development guides, see:
- [Container Deployment Guide](../docs/container-deployment.md)
- [Container Development Guide](../docs/container-development.md)
-60
View File
@@ -1,60 +0,0 @@
# Multi-stage build for Astro static site
# Build stage - use Node.js with pnpm for building
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate
# Set working directory
WORKDIR /app
# Copy package files for dependency installation
COPY package.json pnpm-lock.yaml ./
# Install dependencies with frozen lockfile for reproducible builds
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the Astro application
RUN pnpm build
# Production stage - minimal nginx:alpine for serving static files
FROM nginx:1.25-alpine AS production
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy custom nginx configuration
COPY docker/nginx/astro.conf /etc/nginx/conf.d/default.conf
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
# Copy built static files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Create nginx user and set proper permissions
RUN addgroup -g 1001 -S nginx-user && \
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-user nginx-user && \
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
chown -R nginx-user:nginx-user /var/cache/nginx && \
chown -R nginx-user:nginx-user /var/log/nginx && \
chown -R nginx-user:nginx-user /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nginx-user:nginx-user /var/run/nginx.pid
# Switch to non-root user
USER nginx-user
# Expose port 8080 (non-privileged port)
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
# Use dumb-init as entrypoint for proper signal handling
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Start nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
-69
View File
@@ -1,69 +0,0 @@
# Nginx server configuration for Astro static site
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers specific to main site
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Caching strategy for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Caching for HTML files (shorter cache time)
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
add_header Vary "Accept-Encoding";
}
# Main location block for Astro routes
location / {
try_files $uri $uri/ $uri.html =404;
# Handle client-side routing if needed
# Uncomment if using client-side routing
# try_files $uri $uri/ /index.html;
}
# Specific handling for Astro's _astro directory
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Custom error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}
-56
View File
@@ -1,56 +0,0 @@
# Main nginx configuration optimized for static content delivery
user nginx-user;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging format
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json
application/xml
image/svg+xml;
# Security headers (basic set)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Include server configurations
include /etc/nginx/conf.d/*.conf;
}
-77
View File
@@ -1,77 +0,0 @@
# Nginx server configuration for Storybook static site
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers for Storybook
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Storybook needs more permissive CSP for iframe and scripts
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self';" always;
# Caching strategy for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Caching for HTML files (shorter cache time)
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
add_header Vary "Accept-Encoding";
}
# Main location block for Storybook
location / {
try_files $uri $uri/ /index.html;
# Handle Storybook's client-side routing
# Always fall back to index.html for SPA behavior
}
# Storybook static assets
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Storybook specific directories
location ~ ^/(sb-|assets/|fonts/) {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Custom error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}
-60
View File
@@ -1,60 +0,0 @@
# Multi-stage build for Storybook static site
# Build stage - use Node.js with pnpm for building
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate
# Set working directory
WORKDIR /app
# Copy package files for dependency installation
COPY package.json pnpm-lock.yaml ./
# Install dependencies with frozen lockfile for reproducible builds
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the Storybook application
RUN pnpm build-storybook
# Production stage - minimal nginx:alpine for serving static files
FROM nginx:1.25-alpine AS production
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy custom nginx configuration
COPY docker/nginx/storybook.conf /etc/nginx/conf.d/default.conf
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
# Copy built static files from builder stage
COPY --from=builder /app/storybook-static /usr/share/nginx/html
# Create nginx user and set proper permissions
RUN addgroup -g 1001 -S nginx-user && \
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-user nginx-user && \
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
chown -R nginx-user:nginx-user /var/cache/nginx && \
chown -R nginx-user:nginx-user /var/log/nginx && \
chown -R nginx-user:nginx-user /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nginx-user:nginx-user /var/run/nginx.pid
# Switch to non-root user
USER nginx-user
# Expose port 8080 (non-privileged port)
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
# Use dumb-init as entrypoint for proper signal handling
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Start nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
-394
View File
@@ -1,394 +0,0 @@
# Container Deployment Guide
This guide covers the containerized deployment setup for the www.ermnvldmr.com website, including both the main Astro site and the Storybook component library.
## Overview
The project uses Docker containers to package and deploy:
- **Main Site**: Astro static website served via nginx
- **Storybook**: Component library served via nginx
- **Registry**: GitHub Container Registry (ghcr.io)
- **CI/CD**: GitHub Actions with semantic versioning
## Container Architecture
### Multi-stage Build Strategy
Both containers follow a multi-stage build pattern:
1. **Builder Stage**: Node.js 20 Alpine with pnpm for building assets
2. **Production Stage**: nginx:1.25-alpine for serving static content
### Security Features
- **Non-root user**: Containers run as `nginx-user` (UID 1001)
- **Non-privileged ports**: Expose port 8080 instead of 80
- **Security headers**: CSP, HSTS, and other security headers configured
- **Signal handling**: dumb-init for proper signal handling
### Images
- **Main Site**: `ghcr.io/deytenit/www.ermnvldmr.com`
- **Storybook**: `ghcr.io/deytenit/www.ermnvldmr.com/storybook`
## Tagging Strategy
### Release Tags (from `release/semantic-dispatch`)
For both images:
- `vX.X.X` - Full version (e.g., `v1.2.3`)
- `vX.X` - Minor version (e.g., `v1.2`)
- `vX` - Major version (e.g., `v1`)
- `latest` - Latest stable release
- `{sha}` - Commit SHA for traceability
### Pre-release Tags (from `next/semantic-dispatch`)
For both images:
- `vX.X.X-next.X` - Full prerelease version (e.g., `v1.2.3-next.1`)
- `vX.X.X-next` - Latest prerelease for that version
- `vX.X-next` - Latest prerelease for minor version
- `vX-next` - Latest prerelease for major version
- `latest-next` - Latest prerelease overall
- `{sha}` - Commit SHA for traceability
## Local Development
### Using Docker Compose
Start both services locally:
```bash
# Start both containers
docker-compose up
# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
Access points:
- **Main Site**: http://localhost:3000
- **Storybook**: http://localhost:6006
### Building Individual Images
```bash
# Build Astro site
docker build -f docker/astro/Dockerfile -t www-astro .
# Build Storybook
docker build -f docker/storybook/Dockerfile -t www-storybook .
# Run containers
docker run -p 3000:8080 www-astro
docker run -p 6006:8080 www-storybook
```
## Production Deployment
### Manual Container Deployment
Pull and run the latest images:
```bash
# Pull latest stable images
docker pull ghcr.io/deytenit/www.ermnvldmr.com:latest
docker pull ghcr.io/deytenit/www.ermnvldmr.com/storybook:latest
# Run main site
docker run -d \
--name www-ermnvldmr-astro \
-p 3000:8080 \
--restart unless-stopped \
ghcr.io/deytenit/www.ermnvldmr.com:latest
# Run storybook
docker run -d \
--name www-ermnvldmr-storybook \
-p 6006:8080 \
--restart unless-stopped \
ghcr.io/deytenit/www.ermnvldmr.com/storybook:latest
```
### Environment Variables
The containers are designed to be stateless and don't require runtime environment variables. Configuration is baked into the build.
### Health Checks
Both containers include health check endpoints:
```bash
# Check container health
curl http://localhost:3000/health # Astro site
curl http://localhost:6006/health # Storybook
```
### Resource Requirements
**Recommended minimums per container:**
- **CPU**: 0.1 cores
- **Memory**: 64MB
- **Storage**: 100MB
**Production recommendations:**
- **CPU**: 0.25 cores
- **Memory**: 128MB
- **Storage**: 500MB (for logs and cache)
## Reverse Proxy Configuration
### nginx Configuration Example
```nginx
server {
listen 80;
server_name www.ermnvldmr.com;
location / {
proxy_pass http://astro-container:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name storybook.ermnvldmr.com;
location / {
proxy_pass http://storybook-container:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Traefik Configuration Example
```yaml
# docker-compose.yml with Traefik
version: '3.8'
services:
astro:
image: ghcr.io/deytenit/www.ermnvldmr.com:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.astro.rule=Host(`www.ermnvldmr.com`)"
- "traefik.http.services.astro.loadbalancer.server.port=8080"
storybook:
image: ghcr.io/deytenit/www.ermnvldmr.com/storybook:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.storybook.rule=Host(`storybook.ermnvldmr.com`)"
- "traefik.http.services.storybook.loadbalancer.server.port=8080"
```
## Troubleshooting
### Common Issues
#### Container Won't Start
```bash
# Check logs
docker logs container-name
# Common causes:
# - Missing dependencies in build
# - Permission issues
# - Port conflicts
```
#### Build Failures
```bash
# Check build logs
docker build -f docker/astro/Dockerfile -t test .
# Common causes:
# - Node version mismatch
# - pnpm cache issues
# - Missing build dependencies
```
#### Permission Denied
```bash
# Check if running as correct user
docker exec container-name whoami
# Should output: nginx-user
```
### Debugging
Enable debug mode in nginx:
```bash
# Create debug container
docker run -it --entrypoint=/bin/sh ghcr.io/deytenit/www.ermnvldmr.com:latest
# Check nginx config
nginx -t
# Check file permissions
ls -la /usr/share/nginx/html/
```
### Performance Tuning
#### nginx Optimization
For high-traffic deployments, consider:
- Adjusting worker processes
- Enabling gzip compression levels
- Tuning keepalive settings
- Adding rate limiting
#### Container Resources
Monitor and adjust:
```bash
# Check resource usage
docker stats
# Update container limits
docker run --memory=256m --cpus=0.5 ...
```
## Security Considerations
### Container Security
- Containers run as non-root user
- Minimal attack surface with Alpine base
- Regular security updates via base image updates
- No sensitive data in containers
### Network Security
- Expose only necessary ports
- Use internal networks for service communication
- Implement proper firewall rules
- Enable HTTPS with reverse proxy
### Registry Security
- Images are signed and scanned
- Access controlled via GitHub permissions
- Automatic vulnerability scanning
- Regular base image updates
## Monitoring
### Health Monitoring
```bash
# Simple health check script
#!/bin/bash
if curl -f http://localhost:3000/health >/dev/null 2>&1; then
echo "Astro site: healthy"
else
echo "Astro site: unhealthy"
exit 1
fi
```
### Log Management
```bash
# View logs
docker-compose logs -f astro
docker-compose logs -f storybook
# Log rotation (configure docker daemon)
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
```
### Metrics Collection
Consider integrating with:
- Prometheus + Grafana
- nginx prometheus exporter
- Container metrics exporters
- Application performance monitoring
## CI/CD Integration
The containers are automatically built and pushed when:
1. **Pre-release**: Merge to `next` branch triggers prerelease
2. **Release**: Manual workflow dispatch creates stable release
### Workflow Outputs
Each workflow provides:
- `new_release_published`: Boolean indicating if release was created
- `new_release_version`: The version number of the release
### Manual Triggers
```bash
# Trigger release workflow via GitHub CLI
gh workflow run "release/semantic-dispatch" --ref main
# Trigger prerelease via merge to next branch
git checkout next
git merge feature-branch
git push origin next
```
## Maintenance
### Regular Updates
1. **Base Image Updates**: Rebuild monthly for security patches
2. **Dependency Updates**: Follow project dependency update schedule
3. **Configuration Review**: Quarterly review of nginx and security settings
### Backup Strategy
Since containers are stateless:
- Source code is backed up in Git repository
- Container images are versioned in registry
- No persistent data to backup
### Rollback Procedure
```bash
# Rollback to previous version
docker pull ghcr.io/deytenit/www.ermnvldmr.com:v1.2.2
docker stop current-container
docker run -d --name new-container ghcr.io/deytenit/www.ermnvldmr.com:v1.2.2
# Or use docker-compose with specific version
docker-compose down
# Edit docker-compose.yml to specify version
docker-compose up -d
```
---
For additional support or questions, refer to the project repository issues or documentation.
-303
View File
@@ -1,303 +0,0 @@
# Container Development Guide
This guide covers development workflow with the containerized setup.
## Quick Start
### Prerequisites
- Docker and Docker Compose installed
- pnpm v9.12.3+
- Node.js v20+
### Local Development
1. **Clone and setup**:
```bash
git clone https://github.com/deytenit/www.ermnvldmr.com.git
cd www.ermnvldmr.com
pnpm install
```
2. **Start development servers** (non-containerized):
```bash
pnpm dev # Astro dev server on :4321
pnpm storybook # Storybook dev server on :6006
```
3. **Test containerized build**:
```bash
docker-compose up --build
```
### Container Development Workflow
#### Building Images Locally
```bash
# Build both images
docker-compose build
# Build specific image
docker-compose build astro
docker-compose build storybook
# Build with no cache
docker-compose build --no-cache
```
#### Testing Container Changes
```bash
# Start containers
docker-compose up -d
# Check logs
docker-compose logs -f astro
docker-compose logs -f storybook
# Test health endpoints
curl http://localhost:3000/health
curl http://localhost:6006/health
# Stop containers
docker-compose down
```
### Debugging Containers
#### Interactive Shell Access
```bash
# Access running container
docker-compose exec astro sh
docker-compose exec storybook sh
# Or start debug container
docker run -it --entrypoint=/bin/sh $(docker-compose config | grep image: | head -1 | cut -d: -f2-)
```
#### Common Debug Commands
```bash
# Inside container:
whoami # Check user
nginx -t # Test nginx config
ls -la /usr/share/nginx/html/ # Check files
ps aux # Check processes
netstat -tlnp # Check ports
```
## Docker Configuration
### File Structure
```
docker/
├── astro/
│ └── Dockerfile # Astro site container
├── storybook/
│ └── Dockerfile # Storybook container
└── nginx/
├── nginx.conf # Main nginx config
├── astro.conf # Astro site config
└── storybook.conf # Storybook config
```
### Build Context
The `.dockerignore` file optimizes build context:
- Excludes `node_modules/`, `dist/`, build artifacts
- Includes source files, package files, configs
- Reduces build time and image size
### Multi-stage Build Benefits
1. **Smaller Images**: Only runtime dependencies in final image
2. **Security**: No build tools in production image
3. **Caching**: Efficient layer caching for faster builds
4. **Consistency**: Same build environment across all environments
## nginx Configuration
### Security Features
- Non-root user execution
- Security headers (HSTS, CSP, etc.)
- Rate limiting capabilities
- Access controls for sensitive paths
### Performance Optimizations
- Gzip compression for assets
- Caching headers for static files
- Connection keep-alive
- Efficient static file serving
### Configuration Customization
Edit files in `docker/nginx/`:
- `nginx.conf`: Global nginx settings
- `astro.conf`: Astro-specific server block
- `storybook.conf`: Storybook-specific server block
## CI/CD Integration
### GitHub Actions Workflow
The workflows are triggered by semantic versioning:
1. **Development**: Push to feature branches
2. **Pre-release**: Merge to `next` branch
3. **Release**: Manual dispatch or release branch
### Build Process
1. Install dependencies with pnpm
2. Build Astro static site
3. Build Storybook static site
4. Create semantic release
5. Build and push Docker images (if release created)
### Image Tagging
Images follow semantic versioning:
- Release: `latest`, `v1.2.3`, `v1.2`, `v1`
- Pre-release: `latest-next`, `v1.2.3-next.1`, etc.
## Testing
### Container Testing
```bash
# Test image builds
docker build -f docker/astro/Dockerfile -t test-astro .
docker build -f docker/storybook/Dockerfile -t test-storybook .
# Test containers run
docker run -p 3000:8080 test-astro &
docker run -p 6006:8080 test-storybook &
# Test endpoints
curl -f http://localhost:3000/
curl -f http://localhost:6006/
curl -f http://localhost:3000/health
curl -f http://localhost:6006/health
# Cleanup
docker stop $(docker ps -q)
```
### Security Testing
```bash
# Scan images for vulnerabilities
docker scout cves test-astro
docker scout cves test-storybook
# Check for non-root execution
docker run --rm test-astro whoami # Should output: nginx-user
# Test file permissions
docker run --rm test-astro ls -la /usr/share/nginx/html/
```
## Performance Optimization
### Image Size Optimization
Current optimizations:
- Multi-stage builds
- Alpine Linux base images
- Minimal runtime dependencies
- Optimized layer ordering
### Build Performance
- Layer caching in GitHub Actions
- Efficient `.dockerignore`
- Parallel builds for multiple platforms
- Registry cache utilization
### Runtime Performance
- nginx optimizations for static content
- Gzip compression
- Proper caching headers
- Health check optimizations
## Troubleshooting
### Common Build Issues
1. **pnpm version mismatch**:
```bash
# Update Dockerfile pnpm version
RUN corepack prepare pnpm@9.12.3 --activate
```
2. **Permission issues**:
```bash
# Check user creation in Dockerfile
RUN adduser -S -D -H -u 1001 nginx-user
```
3. **Missing dependencies**:
```bash
# Ensure all deps in package.json
pnpm install --frozen-lockfile
```
### Common Runtime Issues
1. **Container won't start**:
```bash
docker logs container-name
# Check nginx config: nginx -t
```
2. **404 errors**:
```bash
# Check file permissions and paths
docker exec container ls -la /usr/share/nginx/html/
```
3. **Health check failures**:
```bash
# Test health endpoint manually
curl -v http://localhost:8080/health
```
### Performance Issues
```bash
# Monitor resource usage
docker stats
# Check nginx access logs
docker exec container tail -f /var/log/nginx/access.log
# Profile application
docker exec container top
```
## Contributing
### Making Changes to Containers
1. Modify Docker configurations
2. Test locally with `docker-compose up --build`
3. Validate with security and performance tests
4. Update documentation
5. Create pull request
### Release Process
1. Changes merged to `next` create pre-releases
2. Manual workflow dispatch creates stable releases
3. Both trigger automated container builds
4. Images are automatically tagged and pushed
---
For more detailed deployment information, see [container-deployment.md](./container-deployment.md).
+23 -188
View File
@@ -1,207 +1,36 @@
// @ts-check
import eslint from '@eslint/js';
import prettier from 'eslint-config-prettier/flat';
import { baseConfig } from '@ermnvldmr/eslint-config';
import eslintPluginAstro from 'eslint-plugin-astro';
import importPlugin from 'eslint-plugin-import';
import jsdoc from 'eslint-plugin-jsdoc';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import storybook from 'eslint-plugin-storybook';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
...baseConfig,
// Additional ignores for monorepo
{
ignores: [
'**/dist',
'**/node_modules',
'**/.astro',
'**/.github',
'**/.changeset',
'**/coverage',
'**/storybook-static',
'.config/**/*',
'*.config.{js,ts,mjs,cjs}',
'astro.config.mjs',
'eslint.config.mjs',
'jest.config.cjs',
'release.config.mjs',
'!.storybook', // Allow linting of .storybook directory
'services/docs/**/*', // Hugo content, no linting needed
],
},
// JavaScript
eslint.configs.recommended,
// TypeScript - Strict Configuration (only for .ts/.tsx files)
{
files: ['**/*.{ts,tsx,mts,cts}'],
extends: [
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// TypeScript strict rules
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', disallowTypeAnnotations: false },
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{ allowExpressions: true, allowTypedFunctionExpressions: true },
],
},
},
// Allow triple-slash references in `*.d.ts` files.
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
},
},
// Import/Export Rules
{
plugins: { import: importPlugin },
rules: {
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
'type',
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'import/no-duplicates': 'error',
'import/no-unused-modules': 'error',
'import/no-cycle': 'error',
'import/no-self-import': 'error',
},
},
// JSDoc Requirements - Only for main source code
{
files: ['src/components/**/*.{ts,tsx}', 'src/shared/**/*.{ts,tsx}', 'src/www/**/*.{ts,tsx}'],
plugins: { jsdoc },
rules: {
'jsdoc/require-jsdoc': [
'error',
{
contexts: [
'ExportNamedDeclaration > FunctionDeclaration',
'ExportDefaultDeclaration > FunctionDeclaration',
// Exclude VariableDeclaration for React components (memo wrapped)
// React components should document via Props interface instead
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
],
},
],
'jsdoc/require-description': 'error',
'jsdoc/require-param': 'error',
'jsdoc/require-returns': 'error',
'jsdoc/require-example': 'warn',
'jsdoc/check-alignment': 'error',
'jsdoc/check-indentation': 'error',
'jsdoc/check-syntax': 'error',
'jsdoc/check-tag-names': 'error',
'jsdoc/check-types': 'error',
'jsdoc/valid-types': 'error',
},
},
// Astro
...eslintPluginAstro.configs.recommended,
{
files: ['services/www/**/*.astro'],
rules: {
// Astro specific rules can go here
},
},
// Storybook - recommended configuration for flat config
...storybook.configs['flat/recommended'],
// Set globals for Node scripts.
{
files: ['scripts/**'],
languageOptions: {
globals: globals.node,
},
},
// Test files
{
files: ['**/*.{test,spec}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
},
},
rules: {
// Relax rules for test files and jest configuration
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsdoc/require-jsdoc': 'off',
'react-refresh/only-export-components': 'off', // Allow re-exports in test utils
},
},
// CommonJS files - disable all TypeScript rules
{
files: ['**/*.cjs'],
...tseslint.configs.disableTypeChecked,
},
// Config files and tooling
{
files: ['*.config.{js,ts,mjs,cjs}', '.storybook/**/*.{js,ts,cjs,mjs}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsdoc/require-jsdoc': 'off',
'no-undef': 'off', // For CommonJS files
},
},
// Exclude shadcn directory from strict rules
{
files: ['src/shadcn/**/*.{js,jsx,ts,tsx}'],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'jsdoc/require-jsdoc': 'off',
'react-refresh/only-export-components': 'off',
},
},
// React - Strict Configuration
{
files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
@@ -223,7 +52,7 @@ export default tseslint.config(
'react/react-in-jsx-scope': 'off', // Not needed with React 17+
'react/prop-types': 'off', // Using TypeScript instead
'react/display-name': 'error',
'react/no-array-index-key': 'warn', // Changed from error to warn
'react/no-array-index-key': 'warn',
'react/no-danger': 'error',
'react/no-deprecated': 'error',
'react/no-direct-mutation-state': 'error',
@@ -261,13 +90,14 @@ export default tseslint.config(
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error', // Changed from warn to error
'react-hooks/exhaustive-deps': 'error',
},
},
// React Refresh
{
files: ['**/*.{jsx,tsx}'],
ignores: ['.config/jest/**/*.{jsx,tsx}'], // Ignore jest config files
ignores: ['.config/jest/**/*.{jsx,tsx}'],
plugins: { 'react-refresh': reactRefresh },
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
@@ -300,6 +130,11 @@ export default tseslint.config(
},
},
// Prettier
prettier
// Set globals for Node scripts.
{
files: ['scripts/**'],
languageOptions: {
globals: globals.node,
},
}
);
-36
View File
@@ -1,36 +0,0 @@
const config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/.config/jest/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@www/(.*)$': '<rootDir>/src/www/$1',
'^@shared/(.*)$': '<rootDir>/src/shared/$1',
'^@test/(.*)$': '<rootDir>/.config/jest/$1',
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.(test|spec).{js,jsx,ts,tsx}',
],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!.config/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', {
tsconfig: {
jsx: 'react-jsx',
},
}],
},
modulePathIgnorePatterns: ['<rootDir>/dist'],
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/dist/'],
};
module.exports = config;
+36 -99
View File
@@ -1,7 +1,7 @@
{
"name": "www.ermnvldmr.com",
"name": "ermnvldmr.com",
"version": "1.0.0",
"description": "Thoughts and creations of mine!",
"description": "ermnvldmr.com monorepo",
"author": "Vladimir Eremin <personal@ermnvldmr.com> (https://ermnvldmr.com)",
"license": "MIT",
"type": "module",
@@ -13,108 +13,45 @@
"url": "https://github.com/deytenit/www.ermnvldmr.com/issues"
},
"homepage": "https://github.com/deytenit/www.ermnvldmr.com#readme",
"files": [
"src/",
"public/"
],
"packageManager": "pnpm@9.12.3",
"engines": {
"node": ">=20",
"pnpm": ">=9.12.3"
},
"scripts": {
"ui:dev": "pnpm --filter @ermnvldmr/ui dev",
"ui:build": "pnpm --filter @ermnvldmr/ui build",
"ui:test": "pnpm --filter @ermnvldmr/ui test",
"ui:storybook": "pnpm --filter @ermnvldmr/ui storybook",
"www:dev": "pnpm --filter @ermnvldmr/www dev",
"www:build": "pnpm --filter @ermnvldmr/www build",
"www:storybook": "pnpm --filter @ermnvldmr/www storybook",
"docs:dev": "pnpm --filter @ermnvldmr/docs dev",
"docs:build": "pnpm --filter @ermnvldmr/docs build",
"dev:ui": "pnpm ui:dev",
"dev:www": "pnpm ui:build && pnpm www:dev",
"dev:docs": "pnpm docs:dev",
"build": "pnpm ui:build && pnpm -r --filter='./services/*' build",
"build:storybooks": "pnpm ui:build && pnpm --filter @ermnvldmr/ui build-storybook && pnpm --filter @ermnvldmr/www build-storybook",
"lint": "pnpm -r lint",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"clean": "pnpm -r clean && rm -rf node_modules"
},
"devDependencies": {
"globals": "^16.0.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
},
"pnpm": {
"overrides": {
"@storybook/react-vite>vite": "^7.0.0",
"@storybook/builder-vite>vite": "^7.0.0",
"@joshwooding/vite-plugin-react-docgen-typescript>vite": "^7.0.0",
"@tailwindcss/vite>vite": "^7.0.0"
"@tailwindcss/vite>vite": "^7.0.0",
"vite-plugin-istanbul>vite": "^7.0.0"
}
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"lint": "eslint --fix .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:storybook": "test-storybook --coverage",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"http-server storybook-static --port 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && pnpm run test:storybook\"",
"check": "npm run lint && npm run typecheck && npm run test",
"clean": "rm -rf dist .turbo node_modules coverage",
"storybook": "storybook dev -p 6006",
"storybook:preview": "http-server storybook-static --port 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@astrojs/mdx": "^4.2.5",
"@astrojs/react": "^4.2.5",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.3.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.10",
"@radix-ui/react-slot": "^1.2.0",
"@tailwindcss/vite": "^4.1.4",
"astro": "^5.7.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-aria": "^3.45.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"simplex-noise": "^4.0.3",
"suncalc": "^1.9.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@jest/globals": "^30.2.0",
"@storybook/addon-a11y": "^8.6.15",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-interactions": "^8.6.15",
"@storybook/addon-themes": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/react-vite": "^8.6.15",
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.24.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/suncalc": "^1.9.2",
"concurrently": "^9.1.2",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "^0.10.2",
"globals": "^16.0.0",
"http-server": "^14.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"playwright": "^1.57.0",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"semantic-release": "^24.2.3",
"semantic-release-export-data": "^1.2.0",
"storybook": "^8.6.15",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.2.8",
"typescript": "^5.8.3",
"typescript-eslint": "^8.31.1",
"vite": "^7.2.2",
"vite-plugin-istanbul": "^7.2.1",
"wait-on": "^8.0.3"
}
}
+169
View File
@@ -0,0 +1,169 @@
// @ts-check
import eslint from '@eslint/js';
import prettier from 'eslint-config-prettier/flat';
import importPlugin from 'eslint-plugin-import';
import jsdoc from 'eslint-plugin-jsdoc';
import globals from 'globals';
import tseslint from 'typescript-eslint';
/**
* Base ESLint configuration
*/
export const baseConfig = tseslint.config(
{
ignores: [
'**/dist',
'**/node_modules',
'**/.github',
'**/.changeset',
'**/coverage',
'.config/**/*',
'*.config.{js,ts,mjs,cjs}',
'!.storybook', // Allow linting of .storybook directory
],
},
// JavaScript
eslint.configs.recommended,
// TypeScript - Strict Configuration (only for .ts/.tsx files)
{
files: ['**/*.{ts,tsx,mts,cts}'],
extends: [
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// TypeScript strict rules
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', disallowTypeAnnotations: false },
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{ allowExpressions: true, allowTypedFunctionExpressions: true },
],
},
},
// Allow triple-slash references in `*.d.ts` files.
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
},
},
// Import/Export Rules
{
plugins: { import: importPlugin },
rules: {
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
'type',
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'import/no-duplicates': 'error',
'import/no-unused-modules': 'error',
'import/no-cycle': 'error',
'import/no-self-import': 'error',
},
},
// JSDoc Requirements - Only for main source code
{
files: ['src/**/*.{ts,tsx}', 'packages/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}'],
plugins: { jsdoc },
rules: {
'jsdoc/require-jsdoc': [
'error',
{
contexts: [
'ExportNamedDeclaration > FunctionDeclaration',
'ExportDefaultDeclaration > FunctionDeclaration',
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
],
},
],
'jsdoc/require-description': 'error',
'jsdoc/require-param': 'error',
'jsdoc/require-returns': 'error',
'jsdoc/require-example': 'warn',
'jsdoc/check-alignment': 'error',
'jsdoc/check-indentation': 'error',
'jsdoc/check-syntax': 'error',
'jsdoc/check-tag-names': 'error',
'jsdoc/check-types': 'error',
'jsdoc/valid-types': 'error',
},
},
// Test files
{
files: ['**/*.{test,spec}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
},
},
rules: {
// Relax rules for test files and jest configuration
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsdoc/require-jsdoc': 'off',
},
},
// CommonJS files - disable all TypeScript rules
{
files: ['**/*.cjs'],
...tseslint.configs.disableTypeChecked,
},
// Config files and tooling
{
files: ['*.config.{js,ts,mjs,cjs}', '.storybook/**/*.{js,ts,cjs,mjs}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsdoc/require-jsdoc': 'off',
'no-undef': 'off', // For CommonJS files
},
},
// Prettier
prettier
);
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@ermnvldmr/eslint-config",
"version": "1.0.0",
"description": "Shared ESLint configuration",
"type": "module",
"main": "./index.js",
"files": [
"index.js",
"react.js",
"astro.js"
],
"dependencies": {
"@eslint/js": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "^0.10.2",
"globals": "^16.0.0",
"typescript-eslint": "^8.31.1"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@ermnvldmr/jest-config",
"version": "1.0.0",
"description": "Shared Jest configuration",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./base": "./dist/base.js",
"./react": "./dist/react.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@jest/globals": "^30.2.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"@types/jest": "^30.0.0"
},
"peerDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"jest-environment-jsdom": "^30.2.0"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { Config } from 'jest';
/**
* Base Jest configuration for TypeScript projects.
*
* @example
* ```typescript
* import { baseJestConfig } from '@ermnvldmr/jest-config/base';
*
* export default {
* ...baseJestConfig,
* // package-specific overrides
* } satisfies Config;
* ```
*/
export const baseJestConfig: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
},
],
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.stories.{ts,tsx}',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
testMatch: [
'**/__tests__/**/*.{ts,tsx}',
'**/*.{test,spec}.{ts,tsx}',
],
};
+6
View File
@@ -0,0 +1,6 @@
/**
* Jest configuration exports for the monorepo.
*/
export { baseJestConfig } from './base.js';
export { reactJestConfig } from './react.js';
+31
View File
@@ -0,0 +1,31 @@
import type { Config } from 'jest';
import { baseJestConfig } from './base.js';
/**
* Jest configuration for React projects with jsdom environment.
*
* @example
* ```typescript
* import { reactJestConfig } from '@ermnvldmr/jest-config/react';
*
* export default reactJestConfig;
* ```
*/
export const reactJestConfig: Config = {
...baseJestConfig,
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
...baseJestConfig.moduleNameMapper,
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
transform: {
...baseJestConfig.transform,
'^.+\\.(js|jsx|ts|tsx)$': [
'ts-jest',
{
useESM: true,
},
],
},
};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}
+3
View File
@@ -0,0 +1,3 @@
import { baseJestConfig } from '@ermnvldmr/jest-config/base';
export default baseJestConfig;
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@ermnvldmr/stl",
"version": "1.0.0",
"description": "Common reusable dependencies",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.2.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/jest-config": "workspace:*",
"@ermnvldmr/vite-config": "workspace:*",
"jest": "^30.2.0",
"typescript": "^5.8.3",
"vite": "^7.2.2"
},
"peerDependencies": {
"tailwindcss": "^4.1.4"
}
}
File renamed without changes.
+22
View File
@@ -0,0 +1,22 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Combines and merges CSS class names using clsx and tailwind-merge.
*
* This utility function combines the power of clsx for conditional class handling
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
*
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
* @returns Merged class string with conflicts resolved
*
* @example
* ```typescript
* cn('px-4 py-2', 'bg-blue-500') // 'px-4 py-2 bg-blue-500'
* cn('px-4', { 'px-8': true }) // 'px-8' (px-8 overrides px-4)
* cn('text-red-500', undefined, 'text-blue-500') // 'text-blue-500'
* ```
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
File renamed without changes.
+2
View File
@@ -0,0 +1,2 @@
export * from './types';
export * from './functions';
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
/**
* Shared type definitions for common patterns.
*/
export type { ClassNameProps } from './classNameProps';
export type { TestIdProps } from './testIdProps';
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
export default createLibConfig('ErmnvldmrSTL', 'src/index.ts');
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@ermnvldmr/storybook-config",
"version": "1.0.0",
"description": "Shared Storybook configuration",
"type": "module",
"main": "./dist/main.js",
"exports": {
".": "./dist/main.js",
"./preview": "./dist/preview.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@storybook/addon-a11y": "^8.6.15",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-interactions": "^8.6.15",
"@storybook/addon-themes": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/react-vite": "^8.6.15",
"@storybook/test": "^8.6.15",
"@tailwindcss/vite": "^4.1.4",
"vite-plugin-istanbul": "^7.2.1"
},
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^7.2.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
import { basePreviewConfig } from './preview.js';
export { baseStorybookConfig } from './main.js';
export { basePreviewConfig };
+48
View File
@@ -0,0 +1,48 @@
import { mergeConfig } from 'vite';
import type { StorybookConfig } from '@storybook/react-vite';
export const baseStorybookConfig: Partial<StorybookConfig> = {
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'@storybook/addon-coverage',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
viteFinal: async (config) => {
try {
const tailwindcss = (await import('@tailwindcss/vite')).default;
const istanbul = (await import('vite-plugin-istanbul')).default;
return mergeConfig(config, {
plugins: [
tailwindcss(),
istanbul({
include: 'src/**/*',
exclude: ['node_modules', 'test/', '**/*.stories.tsx', '**/*.test.tsx'],
extension: ['.js', '.jsx', '.ts', '.tsx'],
requireEnv: false,
cypress: false,
forceBuildInstrument: true,
}),
],
});
} catch (error) {
// Fallback if plugins can't be loaded
console.warn('Failed to load Storybook plugins:', error);
return config;
}
},
};
@@ -1,9 +1,20 @@
import { withThemeByClassName } from '@storybook/addon-themes';
import type { Preview } from '@storybook/react';
import '../src/global.css';
const preview: Preview = {
/**
* Base preview configuration for theme support.
*
* @example
* ```typescript
* import { basePreviewConfig } from '@ermnvldmr/storybook-config/preview';
*
* export default {
* ...basePreviewConfig,
* // additional overrides
* };
* ```
*/
export const basePreviewConfig: Preview = {
parameters: {
controls: {
matchers: {
@@ -35,5 +46,3 @@ const preview: Preview = {
}),
],
};
export default preview;
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}
+26
View File
@@ -0,0 +1,26 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'@storybook/addon-coverage',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
};
export default config;
+15
View File
@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/react';
import '../src/styles/index.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
+3
View File
@@ -0,0 +1,3 @@
import { reactJestConfig } from '@ermnvldmr/jest-config/react';
export default reactJestConfig;
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@ermnvldmr/ui",
"version": "1.0.0",
"description": "Shared UI components and design system",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./styles": "./dist/styles/index.css"
},
"files": [
"dist"
],
"scripts": {
"dev": "vite build --watch",
"build": "tsc && vite build",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"test:storybook": "@storybook/test-runner",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage storybook-static"
},
"dependencies": {
"@ermnvldmr/stl": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.10",
"@radix-ui/react-slot": "^1.2.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.503.0",
"react-aria": "^3.45.0",
"react-icons": "^5.5.0",
"tw-animate-css": "^1.2.8"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/jest-config": "workspace:*",
"@ermnvldmr/storybook-config": "workspace:*",
"@ermnvldmr/vite-config": "workspace:*",
"@storybook/react": "^8.6.15",
"@storybook/react-vite": "^8.6.15",
"@storybook/test": "^8.6.15",
"@tailwindcss/vite": "^4.1.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"storybook": "^8.6.15",
"typescript": "^5.8.3",
"vite": "^7.2.2",
"vite-plugin-istanbul": "^7.2.1"
},
"peerDependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.4"
}
}
@@ -1,8 +1,7 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import type { ClassNameProps, TestIdProps } from '@/shared/types';
import { cn } from '@/shared/helpers';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Tailwind gap spacing scale values.
@@ -0,0 +1,2 @@
export { HStack } from './HStack/HStack';
export { VStack } from './VStack/VStack';
@@ -1,8 +1,8 @@
import { render, screen } from '@test/test-utils';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from './Button';
import { render, screen } from '../../../test-utils';
/**
* Test suite for the newspaper-style Button component
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { useButton } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@/shared/types';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps } from 'react-aria';
/**
@@ -1,9 +1,10 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import type { ClassNameProps, TestIdProps } from '@/shared/types';
import { VStack } from '../../generic/VStack/VStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import { VStack } from '@/components/shared/VStack/VStack';
import { cn } from '@/shared/helpers';
/**
* Props for the newspaper-style Separator component.
@@ -0,0 +1,2 @@
export { Button } from './PaperButton/Button'
export { Separator } from './PaperSeparator/Separator'
+6
View File
@@ -0,0 +1,6 @@
// Export all components
export * from './components/paper';
export * from './components/generic';
// Import styles to ensure they're bundled
import './styles/index.css';
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
File renamed without changes.
+31
View File
@@ -0,0 +1,31 @@
import { render } from '@testing-library/react';
import type { RenderOptions, RenderResult } from '@testing-library/react';
import type { ReactElement } from 'react';
/**
* Custom render function that sets up any required providers
* @param ui - React component to render
* @param options - Render options excluding wrapper
* @returns RenderResult from testing library
* @example
* ```tsx
* render(<Button>Click me</Button>);
* ```
*/
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
): RenderResult {
return render(ui, {
// Add any providers here if needed in the future
...options,
});
}
// Re-export everything from testing library
// eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';
// Override render method
export { customRender as render };
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
export default createLibConfig('ErmnvldmrUI', 'src/index.ts');
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@ermnvldmr/vite-config",
"version": "1.0.0",
"description": "Shared Vite configuration",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./lib": "./dist/lib.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"vite": "^7.2.2",
"vite-plugin-dts": "^4.3.0"
},
"devDependencies": {
"typescript": "^5.8.3",
"@types/node": "^25.0.10"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Vite configuration utilities for the monorepo.
*/
export { createLibConfig } from './lib.js';
+65
View File
@@ -0,0 +1,65 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import type { UserConfig } from 'vite';
/**
* Base Vite configuration for library packages.
*
* @param packageName - The name for the library build
* @param entry - Entry point file path (relative to package root)
* @param external - Array of external dependencies to exclude from bundle
* @returns Vite configuration object
*
* @example
* ```typescript
* import { createLibConfig } from '@ermnvldmr/vite-config/lib';
*
* export default createLibConfig('MyLib', 'src/index.ts', ['react', 'react-dom']);
* ```
*/
export function createLibConfig(
packageName: string,
entry: string = 'src/index.ts',
external: string[] = []
): UserConfig {
return defineConfig({
plugins: [
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(process.cwd(), entry),
name: packageName,
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: [
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'tailwindcss',
...external
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react/jsx-runtime': 'React',
'react/jsx-dev-runtime': 'React',
},
},
},
cssCodeSplit: false,
},
resolve: {
alias: {
'@': resolve(process.cwd(), 'src'),
},
},
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}
+4742 -5131
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
packages:
# packages
- 'packages/*'
# services
- 'services/*'
-12
View File
@@ -1,12 +0,0 @@
export default {
branches: [
{ name: 'next', prerelease: true },
{ name: 'release/*', channel: false },
],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/github',
'semantic-release-export-data',
],
};
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
+12
View File
@@ -0,0 +1,12 @@
---
title: "Ermnvldmr Root Documentation"
description: "Documentation for the root.ermnvldmr.com infrastructure"
type: docs
---
Various notes made during the development of personal infrastructure.
## Sections
- **[Recipes](/recipes/)** -- Step-by-step instructions,
that I don't want to remember each time I need one.
@@ -0,0 +1,24 @@
---
title: "Recipes"
description: "Step-by-step instructions, that I don't want to remember each time I need one."
weight: 50
type: docs
---
Step-by-step instructions,
that I don't want to remember each time I need one.
## What's in here
### [Initial Host Setup](/recipes/initial-setup)
Setting up a new host on Debian from scratch.
### [Xray Client Setup](/ru/recipes/xray-setup) (RU only)
Setting up xray client without any further usage in mind.
## Why “Recipes”?
These are not abstract troubleshooting guides.
These are specific actions you will need to perform.
@@ -0,0 +1,129 @@
---
title: "Initial Host Setup"
description: "Setting up a fresh Debian host from scratch"
weight: 1
type: docs
---
How to set up a fresh Debian host from zero to deployment.
## Add Adam User and Wheel Group
Adam is a core user on each of the system.
It does not has password,
hence only connection via ssh is available,
providing a password-less sudo.
```bash
# Add adam user
useradd -m -s '/bin/bash' adam
# Add wheel group for password-less sudo
groupadd wheel
vi /etc/sudoers.d/99-root-ermnvldmr
```
Fill file with the content:
```visudo
%wheel ALL = (ALL) NOPASSWD: ALL
```
## Configure SSH
Hardening SSHD,
even stupidly changing port.
```bash
# Move public ssh key of user adam to the server
vi /home/adam/.ssh/authorized_keys
# Create additional ssh rules
vi /etc/ssh/sshd_config.d/99-root-ermnvldmr.conf
```
With the following content:
```conf
Port 2222
PasswordAuthentication no
PermitEmptyPasswords no
AllowUsers adam
```
## Install Docker
Follow the official Docker installation guide for Debian --
[docs.docker.com](https://docs.docker.com/engine/install/debian/)
Test Docker installation:
```bash
docker --version
docker compose version
```
## Clone the root.ermnvldmr.com Repository
```bash
# Clone to your preferred location
cd /srv
sudo mkdir root.ermnvldmr.com
sudo chown adam:adam root.ermnvldmr.com
# Clone the repository
git clone git@github.com:deytenit/root.ermnvldmr.com.git root.ermnvldmr.com
# Run the repository initialization
./init.sh
```
## Setup node
> [!IMPORTANT]
> From now on `$NODE` equals node name of the server you are setting up.
> [!NOTE]
> More about root.ermnvldmr.com compositions repository -- [github.com](https://github.com/)
Run node-specific scripts in $NODE/.host/.scripts
in order of their numbering:
```bash
# For example for node daedalus
# Setting up the base components
./daedalus/.host/.scripts/00-base
# Setting up ufw with rules defined in ./daedalus/.host/ufw
./daedalus/.host/.scripts/10-ufw
# Setting up crowdsec with rules defined in ./daedalus/.host/crowd
./daedalus/.host/.scripts/20-crowd
# Setting up cron with tasks defined in ./daedalus/.host/cron
./daedalus/.host/.scripts/30-cron
```
## Setup Storage Tiers
You need to decide where to store your data tiers:
```bash
# Create storage directories
sudo mkdir -p /srv/com-ermnvldmr-root-$NODE-{tier1,tier2,tier3}
# Setup tiers for the node
.scripts/ops/setup-tiers $NODE \
/srv/com-ermnvldmr-root-$NODE-tier1 \
/srv/com-ermnvldmr-root-$NODE-tier2 \
/srv/com-ermnvldmr-root-$NODE-tier3
```
## Setup noroot-users
To harden the containers running on the node,
we create series of noroot-users and shared group
for each project in the compose repository node directory.
```bash
# Setup noroot-users for the node
.scripts/ops/setup-noroot-users $NODE
```
+12
View File
@@ -0,0 +1,12 @@
---
title: "root.ermnvldmr.com"
description: "Документация к инфраструктуре root.ermnvldmr.com"
type: docs
---
Различные заметки, сделанные в процессе разработки этой инфраструктуры.
## Разделы
- **[Рецепты](/recipes/)** -- Пошаговые инструкции,
которые мне не хочется изобретать каждый раз снова.
@@ -0,0 +1,15 @@
---
title: "Рецепты"
description: "Пошаговые инструкции, которые мне не хочется изобретать каждый раз снова."
weight: 50
type: docs
---
Пошаговые инструкции,
которые мне не хочется изобретать каждый раз снова.
## Что здесь
### [Настройка xray-клиента](/recipes/xray-setup)
Настройте xray-client без какого-либо дальнейшего предназначения.
@@ -0,0 +1,196 @@
---
title: "Настройка xray-клиента"
description: "Дружелюбная инструкция по настройке xray-client без какого-либо предназначения"
type: docs
---
Данная инструкция поможет вам пошагово настроить клиент для работы через заданную конфигурацию соединения и особые правила маршрутизации (*routing*).
**Следуйте** каждому шагу внимательно.
> [!NOTE]
> Инструкция предназначена для настройки xray-клиента без дальнейшего предназначения – мы не предоставляем услуги прокси.
## Необходимые данные
- Строка подключения формата:
```
vless://...
```
- Набор правил маршрутизации:
> [!WARNING]
> Данный формат правил **подходит только** для **v2rayN/v2rayNG**!
> **Для happ** правильная строка указана **в самой инструкции**.
```json
[
{"domain":["domain:ru","domain:su","domain:vk.com","domain:yandex.com"],"enabled":true,"locked":false,"outboundTag":"direct","remarks":"Domain RU - Direct"},
{"domain":["geosite:private"],"enabled":true,"locked":false,"outboundTag":"direct","remarks":"Domain Private - Direct"},
{"enabled":true,"ip":["geoip:ru"],"locked":false,"outboundTag":"direct","remarks":"IP RU - Direct"},
{"enabled":true,"ip":["geoip:private"],"locked":false,"outboundTag":"direct","remarks":"IP Private - Direct"}
]
```
## Android (v2rayNG)
> [!IMPORTANT]
> NekoBox (MatsuriDayo) более не рекомендован к использованию из-за малой активности в разработке – **удалите** его.
> [!TIP]
> Наглядный пример настройки — [на видео](/videos/recipes-xray-setup-v2rayng.webm)
### Шаг 1. Установите приложение
- **Скачайте** v2rayNG с [официального репозитория](https://github.com/2dust/v2rayNG/releases).
Обычно нужен файл `v2rayNG_x.x.x_universal.apk`.
- **Установите** скачанный `.apk` файл на Android-устройство.
### Шаг 2. Добавьте конфигурацию
- **Откройте** приложение v2rayNG.
- **Нажмите** "+" в правом верхнем углу.
- **Выберите** `Import config from Clipboard` (предварительно **скопируйте** ссылку конфигурации в буфер обмена).
### Шаг 3. Настройте правила маршрутизации
- **Вернитесь** на главный экран приложения.
- **Нажмите** на "бургер-меню" (три полоски) в левом верхнем углу.
- **Выберите** `Routing Settings`.
- **Нажмите** на три точки в правом верхнем углу.
- **Выберите** `Import ruleset from clipboard` (предварительно **скопируйте** [JSON с правилами](#необходимые-данные)).
- **Нажмите** на Domain strategy и **выберите** `IPOnDemand`.
### Шаг 4. Запустите подключение
- **Вернитесь** на главный экран.
- **Нажмите** кнопку ▶ для запуска соединения.
## Windows / MacOS / Linux (v2rayN)
### Шаг 1. Установите приложение
- **Скачайте** нужную версию приложения с [официального репозитория](https://github.com/2dust/v2rayN/releases):
- Windows: `v2rayN-windows-64-desktop.zip`
- Linux: `v2rayN-linux-64.zip`
- MacOS: `v2rayN-macos-arm64.dmg`
> [!TIP]
> **Чтобы увидеть** все файлы для скачивания **нажмите** на ссылку под файлами.
>
> ![Показать все файлы для скачивания](/images/recipes-xray-setup-v2rayn-download.webp)
#### Windows
- **Распакуйте** файл `.zip` в любое удобное место.
![Контекстное меню – распаковать всё](/images/recipes-xray-setup-v2rayn-windows-unzip.webp)
- **Переместите** распакованную папку в более естественное место –
**вырежте** папку.
![Вырезка распакованной папки](/images/recipes-xray-setup-v2rayn-windows-cut.webp)
- Затем **вставьте** в папку `Документы`.
![Вставка в папку Документы](/images/recipes-xray-setup-v2rayn-windows-paste.webp)
- В перемещённой папке **будет** лежать `v2rayN` приложение.
> [!TIP]
> **Чтобы не потерять** приложение – **отправьте** ярлык на Рабочий Стол.
>
> ![Отправить ярлык на Рабочий Стол](/images/recipes-xray-setup-v2rayn-windows-shortcut.webp)
#### Linux
Если вы **пользуетесь Linux**, я думаю, у вас достаточно знаний, чтобы **настроить всё самостоятельно**.
В этом разделе будут рассмотрены **частые проблемы** и **пути их решения**.
#### MacOS
- **Откройте** файл `.dmg` и **перетащите** приложение в папку "Программы".
- **Откройте** терминал и **выполните** команду:
```
sudo xattr -cr /Applications/v2rayN.app
```
- После первого открытия, **Откройте** системные настройки:
- **Выберете** пункт `Privacy & Security`
- **Прокрутите** вниз до уведомления о блокировке "xray"
- **Нажмите** `Allow Anyway`
### Шаг 2. Добавьте конфигурацию
- **Откройте** v2rayN.
- **Нажмите** `Configurations` в левом верхнем углу окна.
- **Выберите** `Import Share Links from clipboard (Ctrl+V)` (предварительно **скопируйте** конфигурационную ссылку).
![Импортирование Конфигурации](/images/recipes-xray-setup-v2rayn-import-config.webp)
### Шаг 3. Настройте правила маршрутизации
- **Перейдите** в `Settings` → `Routing Settings`.
![Открытие Настроек Маршрутизации](/images/recipes-xray-setup-v2rayn-open-routing.webp)
- В открывшемся окне:
- **Нажмите** `Add` в верхнем левом углу.
- В новом окне в поле Remarks **введите** произвольное имя, например: "RU - Direct".
- В поле Domain strategy **выберите** `IPOnDemand`.
- В вверху окна **нажмите** `Import Rules From Clipboard` (предварительно **скопируйте** [JSON с правилами](#необходимые-данные)).
- В появившемся окне "Do you want to append rules? ..." – **нажмите** `No`.
![Импортирование Правил Маршрутизации](/images/recipes-xray-setup-v2rayn-add-routing.webp)
- **Нажимайте** `Confirm` до тех пор, пока не **вернётесь** в основной интерфейс.
### Шаг 4. Запустите подключение
- **Выберите** созданные правила маршрутизации из второго выпадающего списка снизу.
![Выбор Правил Маршрутизации](/images/recipes-xray-setup-v2rayn-select-routing.webp)
- **Нажмите** `Enable Tun` для запуска соединения (**следуйте** запросам v2rayN).
## iOS / iPadOS (happ)
### Шаг 1. Установите приложение
- **Установите** _happ_ из [App Store](https://apps.apple.com/us/app/happ-proxy-utility/id6504287215).
### Шаг 2. Добавьте конфигурацию
- **Откройте** приложение.
- **Нажмите** "+" в правом верхнем углу.
- **Выберите** `Import from clipboard` (предварительно **скопируйте** ссылку конфигурации в буфер обмена).
![Добавление конфигурации](/images/recipes-xray-setup-happ-import-config.webp)
### Шаг 3. Откройте настройки роутинга
- **Перейдите** по значку шестиренки в левом верхнем углу.
- **Выберите** `Routing Rules`.
![Открытие настроек роутинга](/images/recipes-xray-setup-happ-open-routing.webp)
### Шаг 4. Импортируйте роутинг
- **Нажмите** `...` (три точки) в правом верхнем углу.
- **Выберите** `Import from clipboard` (предварительно **скопируйте** правила, см. ниже).
> [!IMPORTANT]
> Формат правил отличается от v2rayN/v2rayNG!
> Эта строка - эквивалентна правилам сверху.
>
> ```
> happ://routing/add/eyJEbnNIb3N0cyI6eyJkbnMuZ29vZ2xlIjoiOC44LjguOCIsImNsb3VkZmxhcmUtZG5zLmNvbSI6IjEuMS4xLjEifSwiQmxvY2tJcCI6W10sIkZha2VEbnMiOnRydWUsIlJlbW90ZUROU1R5cGUiOiJEb1UiLCJEb21lc3RpY0ROU0lwIjoiOC44LjguOCIsIkRpcmVjdFNpdGVzIjpbImRvbWFpbjpydSIsImRvbWFpbjpzdSIsImRvbWFpbjp2ay5jb20iLCJkb21haW46eWFuZGV4LmNvbSIsImdlb3NpdGU6cHJpdmF0ZSJdLCJHZW9pcFVybCI6Imh0dHBzOlwvXC9naXRodWIuY29tXC9Mb3lhbHNvbGRpZXJcL3YycmF5LXJ1bGVzLWRhdFwvcmVsZWFzZXNcL2xhdGVzdFwvZG93bmxvYWRcL2dlb2lwLmRhdCIsIkdsb2JhbFByb3h5Ijp0cnVlLCJEaXJlY3RJcCI6WyJnZW9pcDpydSIsImdlb2lwOnByaXZhdGUiXSwiUHJveHlTaXRlcyI6W10sIk5hbWUiOiJEZWZhdWx0IiwiR2Vvc2l0ZVVybCI6Imh0dHBzOlwvXC9naXRodWIuY29tXC9Mb3lhbHNvbGRpZXJcL3YycmF5LXJ1bGVzLWRhdFwvcmVsZWFzZXNcL2xhdGVzdFwvZG93bmxvYWRcL2dlb3NpdGUuZGF0IiwiQmxvY2tTaXRlcyI6W10sIkxhc3RVcGRhdGVkIjoxNzU5NDMxODk5LCJVc2VDaHVua0ZpbGVzIjp0cnVlLCJSZW1vdGVETlNEb21haW4iOiJodHRwczpcL1wvY2xvdWRmbGFyZS1kbnMuY29tXC9kbnMtcXVlcnkiLCJEb21haW5TdHJhdGVneSI6IklQSWZOb25NYXRjaCIsIkRvbWVzdGljRE5TRG9tYWluIjoiaHR0cHM6XC9cL2Rucy5nb29nbGVcL2Rucy1xdWVyeSIsIlJlbW90ZUROU0lwIjoiMS4xLjEuMSIsIlByb3h5SXAiOltdLCJEb21lc3RpY0ROU1R5cGUiOiJEb1UifQ==
> ```
![Импортирование правил](/images/recipes-xray-setup-happ-add-routing.webp)
### Шаг 5. Роутинг установлен
- **После** настройки роутинга,
содержимое экрана должно быть похоже на картинку снизу.
- **Вернитесь** на главный экран.
- **Нажмите** кнопку вкключения для запуска соединения.
![Финальный экран](/images/recipes-xray-setup-happ-routing-final.webp)
+134
View File
@@ -0,0 +1,134 @@
baseURL: 'https://docs.root.ermnvldmr.com'
defaultContentLanguage: 'en'
defaultContentLanguageInSubdir: true
title: 'root.ermnvldmr.com'
description: 'Documentation for the root.ermnvldmr.com infrastructure'
# Multilingual configuration
languages:
en:
languageCode: 'en-us'
languageName: 'English'
title: 'root.ermnvldmr.com'
description: 'Documentation for the root.ermnvldmr.com infrastructure'
weight: 1
contentDir: 'content/en'
menu:
main:
# - name: Architecture
# url: /en/architecture/
# weight: 10
# - name: Operations
# url: /en/operations/
# weight: 20
# - name: Concepts
# url: /en/concepts/
# weight: 30
- name: Recipes
url: /en/recipes/
weight: 40
- name: Search
weight: 50
params:
type: search
ru:
languageCode: 'ru'
languageName: 'Русский'
title: 'root.ermnvldmr.com'
description: 'Документация для инфраструктуры root.ermnvldmr.com'
weight: 2
contentDir: 'content/ru'
menu:
main:
- name: Архитектура
url: /ru/architecture/
weight: 10
- name: Операции
url: /ru/operations/
weight: 20
- name: Концепции
url: /ru/concepts/
weight: 30
- name: Рецепты
url: /ru/recipes/
weight: 40
- name: Поиск
weight: 50
params:
type: search
# Theme configuration
theme: hextra
enableGitInfo: true
# Hextra theme parameters
params:
description: "Documentation for the root.ermnvldmr.com infrastructure"
# Search configuration
search:
enable: true
type: flexsearch
flexsearch:
# index page by: content | summary | heading | title
index: content
# full | forward | reverse | strict
# https://github.com/nextapps-de/flexsearch/#tokenizer-prefix-search
tokenize: forward
displayUpdatedDate: true
dateFormat: "January 2, 2006"
# Navigation configuration
navbar:
displayTitle: true
width: wide
displayLogo: false
# Footer configuration
footer:
displayCopyright: true
displayPoweredBy: true
# Edit page configuration
editURL:
enable: true
base: "https://github.com/deytenit/root.ermnvldmr.com/edit/trunk/docs/content"
page:
width: wide
theme:
default: system
displayToggle: true
# Markdown configuration
markup:
highlight:
style: github
lineNos: true
noClasses: false
goldmark:
renderer:
unsafe: true
# Output formats
outputs:
home: [HTML]
page: [HTML]
section: [HTML]
# URL configuration to preserve existing structure
permalinks:
architecture: /architecture/:slug/
operations: /operations/:slug/
reference: /reference/:slug/
troubleshooting: /troubleshooting/:slug/
# Deployment configuration
deployment:
targets:
- name: production
URL: docs.root.ermnvldmr.com
cloudFrontDistributionID: # TBD during deployment
+1
View File
@@ -0,0 +1 @@
copyright: "© 2025 Eremin Vladimir. All rights reserved."
+6
View File
@@ -0,0 +1,6 @@
copyright: "© 2025 Ерёмин Владимир. Все права защищены."
note: Кстати
tip: Полезно
important: Важно
warning: Внимание
caution: Осторожно
@@ -0,0 +1,46 @@
<div class="service-card">
<div class="service-header">
<h3>{{ .Get "name" }}</h3>
<span class="container-name">{{ .Get "container" }}</span>
</div>
<div class="service-details">
<div class="service-row">
<strong>Image:</strong> <code>{{ .Get "image" }}</code>
</div>
<div class="service-row">
<strong>Purpose:</strong> {{ .Get "purpose" }}
</div>
{{ with .Get "ports" }}
<div class="service-row">
<strong>Ports:</strong> <code>{{ . }}</code>
</div>
{{ end }}
{{ with .Get "networks" }}
<div class="service-row">
<strong>Networks:</strong> <code>{{ . }}</code>
</div>
{{ end }}
{{ with .Get "volumes" }}
<div class="service-row">
<strong>Volumes:</strong> <code>{{ . }}</code>
</div>
{{ end }}
{{ with .Get "features" }}
<div class="service-row">
<strong>Features:</strong> {{ . }}
</div>
{{ end }}
{{ with .Get "management_url" }}
<div class="service-row">
<strong>Management URL:</strong> <a href="{{ . }}" target="_blank">{{ . }}</a>
</div>
{{ end }}
</div>
</div>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ermnvldmr/docs",
"version": "1.0.0",
"description": "docs.root.ermnvldmr.com documentation site",
"scripts": {
"dev": "hugo server --buildDrafts --buildExpired",
"build": "hugo --gc --minify",
"clean": "rm -rf public resources",
"lint": "echo 'No linting for Hugo site'",
"typecheck": "echo 'No typecheck for Hugo site'"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*"
},
"engines": {
"hugo": ">=0.110.0"
}
}
+1
View File
@@ -0,0 +1 @@
docs.root.ermnvldmr.com
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,2 @@
/*! tailwindcss v4.1.8 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--hx-font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--hx-font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--hx-color-red-100:oklch(93.6% .032 17.717);--hx-color-red-200:oklch(88.5% .062 18.334);--hx-color-red-900:oklch(39.6% .141 25.723);--hx-color-orange-50:oklch(98% .016 73.684);--hx-color-orange-100:oklch(95.4% .038 75.164);--hx-color-orange-300:oklch(83.7% .128 66.29);--hx-color-orange-400:oklch(75% .183 55.934);--hx-color-orange-800:oklch(47% .157 37.304);--hx-color-amber-100:oklch(96.2% .059 95.617);--hx-color-amber-200:oklch(92.4% .12 95.746);--hx-color-amber-900:oklch(41.4% .112 45.904);--hx-color-yellow-50:oklch(98.7% .026 102.212);--hx-color-yellow-100:oklch(97.3% .071 103.193);--hx-color-yellow-200:oklch(94.5% .129 101.54);--hx-color-yellow-700:oklch(55.4% .135 66.442);--hx-color-yellow-900:oklch(42.1% .095 57.708);--hx-color-green-100:oklch(96.2% .044 156.743);--hx-color-green-200:oklch(92.5% .084 155.995);--hx-color-green-900:oklch(39.3% .095 152.535);--hx-color-blue-100:oklch(93.2% .032 255.585);--hx-color-blue-200:oklch(88.2% .059 254.128);--hx-color-blue-900:oklch(37.9% .146 265.522);--hx-color-indigo-100:oklch(93% .034 272.788);--hx-color-indigo-200:oklch(87% .065 274.039);--hx-color-indigo-900:oklch(35.9% .144 278.697);--hx-color-slate-50:oklch(98.4% .003 247.858);--hx-color-slate-100:oklch(96.8% .007 247.896);--hx-color-slate-900:oklch(20.8% .042 265.755);--hx-color-gray-50:oklch(98.5% .002 247.839);--hx-color-gray-100:oklch(96.7% .003 264.542);--hx-color-gray-200:oklch(92.8% .006 264.531);--hx-color-gray-300:oklch(87.2% .01 258.338);--hx-color-gray-400:oklch(70.7% .022 261.325);--hx-color-gray-500:oklch(55.1% .027 264.364);--hx-color-gray-600:oklch(44.6% .03 256.802);--hx-color-gray-700:oklch(37.3% .034 259.733);--hx-color-gray-800:oklch(27.8% .033 256.848);--hx-color-gray-900:oklch(21% .034 264.665);--hx-color-neutral-50:oklch(98.5% 0 0);--hx-color-neutral-200:oklch(92.2% 0 0);--hx-color-neutral-300:oklch(87% 0 0);--hx-color-neutral-400:oklch(70.8% 0 0);--hx-color-neutral-500:oklch(55.6% 0 0);--hx-color-neutral-600:oklch(43.9% 0 0);--hx-color-neutral-700:oklch(37.1% 0 0);--hx-color-neutral-800:oklch(26.9% 0 0);--hx-color-neutral-900:oklch(20.5% 0 0);--hx-color-black:#000;--hx-color-white:#fff;--hx-spacing:.25rem;--hx-breakpoint-xl:80rem;--hx-container-6xl:72rem;--hx-text-xs:.75rem;--hx-text-xs--line-height:calc(1/.75);--hx-text-sm:.875rem;--hx-text-sm--line-height:calc(1.25/.875);--hx-text-base:1rem;--hx-text-base--line-height:calc(1.5/1);--hx-text-lg:1.125rem;--hx-text-lg--line-height:calc(1.75/1.125);--hx-text-xl:1.25rem;--hx-text-xl--line-height:calc(1.75/1.25);--hx-text-2xl:1.5rem;--hx-text-2xl--line-height:calc(2/1.5);--hx-text-3xl:1.875rem;--hx-text-3xl--line-height:calc(2.25/1.875);--hx-text-4xl:2.25rem;--hx-text-4xl--line-height:calc(2.5/2.25);--hx-text-5xl:3rem;--hx-text-5xl--line-height:1;--hx-font-weight-normal:400;--hx-font-weight-medium:500;--hx-font-weight-semibold:600;--hx-font-weight-bold:700;--hx-font-weight-extrabold:800;--hx-tracking-tighter:-.05em;--hx-tracking-tight:-.025em;--hx-leading-tight:1.25;--hx-radius-xs:.125rem;--hx-radius-sm:.25rem;--hx-radius-md:.375rem;--hx-radius-lg:.5rem;--hx-radius-xl:.75rem;--hx-radius-3xl:1.5rem;--hx-ease-iLine truncated
@@ -0,0 +1 @@
:root{--hextra-max-page-width:90rem;--hextra-max-navbar-width:90rem;--hextra-max-footer-width:80rem}.hextra-max-page-width{max-width:var(--hextra-max-page-width)}.hextra-max-navbar-width{max-width:var(--hextra-max-navbar-width)}.hextra-max-footer-width{max-width:var(--hextra-max-footer-width)}/*!tailwindcss v4.1.8 | MIT License | https://tailwindcss.com*/@layer properties{@supports(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--hx-font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--hx-font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--hx-color-red-100:oklch(93.6% .032 17.717);--hx-color-red-200:oklch(88.5% .062 18.334);--hx-color-red-900:oklch(39.6% .141 25.723);--hx-color-orange-50:oklch(98% .016 73.684);--hx-color-orange-100:oklch(95.4% .038 75.164);--hx-color-orange-300:oklch(83.7% .128 66.29);--hx-color-orange-400:oklch(75% .183 55.934);--hx-color-orange-800:oklch(47% .157 37.304);--hx-color-amber-100:oklch(96.2% .059 95.617);--hx-color-amber-200:oklch(92.4% .12 95.746);--hx-color-amber-900:oklch(41.4% .112 45.904);--hx-color-yellow-50:oklch(98.7% .026 102.212);--hx-color-yellow-100:oklch(97.3% .071 103.193);--hx-color-yellow-200:oklch(94.5% .129 101.54);--hx-color-yellow-700:oklch(55.4% .135 66.442);--hx-color-yellow-900:oklch(42.1% .095 57.708);--hx-color-green-100:oklch(96.2% .044 156.743);--hx-color-green-200:oklch(92.5% .084 155.995);--hx-color-green-900:oklch(39.3% .095 152.535);--hx-color-blue-100:oklch(93.2% .032 255.585);--hx-color-blue-200:oklch(88.2% .059 254.128);--hx-color-blue-900:oklch(37.9% .146 265.522);--hx-color-indigo-100:oklch(93% .034 272.788);--hx-color-indigo-200:oklch(87% .065 274.039);--hx-color-indigo-900:oklch(35.9% .144 278.697);--hx-color-slate-50:oklch(98.4% .003 247.858);--hx-color-slate-100:oklch(96.8% .007 247.896);--hx-color-slate-900:oklch(20.8% .042 265.755);--hx-color-gray-50:oklch(98.5% .002 247.839);--hx-color-gray-100:oklch(96.7% .003 264.542);--hx-color-gray-200:oklch(92.8% .006 264.531);--hx-color-gray-300:oklch(87.2% .01 258.338);--hx-color-gray-400:oklch(70.7% .022 261.325);--hx-color-gray-500:oklch(55.1% .027 264.364);--hx-color-gray-600:oklch(44.6% .03 256.802);--hx-color-gray-700:oklch(37.3% .034 259.733);--hx-color-gray-800:oklch(27.8% .033 256.848);--hx-color-gray-900:oklch(21% .034 264.665);--hx-color-neutral-50:oklch(98.5% 0 0);--hx-color-neutral-200:oklch(92.2% 0 0);--hx-color-neutral-300:oklch(87% 0 0);--hx-color-neutral-400:oklch(70.8% 0 0);--hx-color-neutral-500:oklch(55.6% 0 0);--hx-color-neutral-600:oklch(43.9% 0 0);--hx-color-neutral-700:oklch(37.1% 0 0);--hx-color-neutral-800:oklch(26.9% 0 0);--hx-color-neutral-900:oklch(20.5% 0 0);--hx-color-black:#000;--hx-color-white:#fff;--hx-spacing:.25rem;--hx-breakpoint-xl:80rem;--hx-container-6xl:72rem;--hx-text-xs:.75rem;--hx-text-xs--line-height:calc(1/.75);--hx-text-sm:.875rem;--hx-text-sm--line-height:calc(1.25/.875);--hx-text-base:1rem;--hx-text-base--line-height:calc(1.5/1);--hx-text-lg:1.125rem;--hx-text-lg--line-height:calc(1.75/1.125);--hx-text-xl:1.25rem;--hx-text-xl--line-height:calc(1.75/1.25);--hx-text-2xl:1.5rem;--hx-text-2xl--line-height:calc(2/1.5);--hx-text-3xl:1.875rem;--hx-text-3xl--line-height:calc(2.25/1.875);--hx-text-4xl:2.25rem;--hx-text-4xl--line-height:calc(2.5/2.25);--hx-text-5xl:3rem;--hx-text-5xl--line-height:1;--hx-font-weiLine truncated
View File
Whitespace-only changes.
+21
View File
@@ -0,0 +1,21 @@
/* Hugo template to derive CSS variables from site and page parameters */
/* Do not remove the following comment. It is used by Hugo to render CSS variables.*/
:root {
--hextra-max-page-width: 90rem;
--hextra-max-navbar-width: 90rem;
--hextra-max-footer-width: 80rem;
}
.hextra-max-page-width {
max-width: var(--hextra-max-page-width);
}
.hextra-max-navbar-width {
max-width: var(--hextra-max-navbar-width);
}
.hextra-max-footer-width {
max-width: var(--hextra-max-footer-width);
}
+1
View File
@@ -0,0 +1 @@
{"/en/recipes/":{"data":{"":"","whats-in-here#What\u0026rsquo;s in here":"Step-by-step instructions, that I don’t want to remember each time I need one.\nWhat’s in here Initial Host Setup Setting up a new host on Debian from scratch.\nXray Client Setup (RU only) Setting up xray client without any further usage in mind.","why-recipes#Why “Recipes”?":"These are not abstract troubleshooting guides.\nThese are specific actions you will need to perform."},"title":"Recipes"},"/en/recipes/initial-setup/":{"data":{"":"How to set up a fresh Debian host from zero to deployment.","add-adam-user-and-wheel-group#Add Adam User and Wheel Group":"Adam is a core user on each of the system. It does not has password, hence only connection via ssh is available, providing a password-less sudo.\n1 2 3 4 5 6 # Add adam user useradd -m -s '/bin/bash' adam # Add wheel group for password-less sudo groupadd wheel vi /etc/sudoers.d/99-root-ermnvldmr Fill file with the content:\n%wheel ALL = (ALL) NOPASSWD: ALL ","clone-the-rootermnvldmrcom-repository#Clone the root.ermnvldmr.com Repository":" 1 2 3 4 5 6 7 8 9 10 # Clone to your preferred location cd /srv sudo mkdir root.ermnvldmr.com sudo chown adam:adam root.ermnvldmr.com # Clone the repository git clone git@github.com:deytenit/root.ermnvldmr.com.git root.ermnvldmr.com # Run the repository initialization ./init.sh ","configure-ssh#Configure SSH":"Hardening SSHD, even stupidly changing port.\n1 2 3 4 5 # Move public ssh key of user adam to the server vi /home/adam/.ssh/authorized_keys # Create additional ssh rules vi /etc/ssh/sshd_config.d/99-root-ermnvldmr.conf With the following content:\nPort 2222 PasswordAuthentication no PermitEmptyPasswords no AllowUsers adam ","install-docker#Install Docker":"Follow the official Docker installation guide for Debian – docs.docker.com\nTest Docker installation:\n1 2 docker --version docker compose version ","setup-node#Setup node":" Important\nFrom now on $NODE equals node name of the server you are setting up.\nNote\nMore about root.ermnvldmr.com compositions repository – github.com\nRun node-specific scripts in $NODE/.host/.scripts in order of their numbering:\n1 2 3 4 5 6 7 8 9 10 # For example for node daedalus # Setting up the base components ./daedalus/.host/.scripts/00-base # Setting up ufw with rules defined in ./daedalus/.host/ufw ./daedalus/.host/.scripts/10-ufw # Setting up crowdsec with rules defined in ./daedalus/.host/crowd ./daedalus/.host/.scripts/20-crowd # Setting up cron with tasks defined in ./daedalus/.host/cron ./daedalus/.host/.scripts/30-cron ","setup-noroot-users#Setup noroot-users":"To harden the containers running on the node, we create series of noroot-users and shared group for each project in the compose repository node directory.\n1 2 # Setup noroot-users for the node .scripts/ops/setup-noroot-users $NODE ","setup-storage-tiers#Setup Storage Tiers":"You need to decide where to store your data tiers:\n1 2 3 4 5 6 7 8 # Create storage directories sudo mkdir -p /srv/com-ermnvldmr-root-$NODE-{tier1,tier2,tier3} # Setup tiers for the node .scripts/ops/setup-tiers $NODE \\ /srv/com-ermnvldmr-root-$NODE-tier1 \\ /srv/com-ermnvldmr-root-$NODE-tier2 \\ /srv/com-ermnvldmr-root-$NODE-tier3 "},"title":"Initial Host Setup"}}
+437
View File
@@ -0,0 +1,437 @@
// Search functionality using FlexSearch.
// Change shortcut key to cmd+k on Mac, iPad or iPhone.
document.addEventListener("DOMContentLoaded", function () {
if (/iPad|iPhone|Macintosh/.test(navigator.userAgent)) {
// select the kbd element under the .search-wrapper class
const keys = document.querySelectorAll(".search-wrapper kbd");
keys.forEach(key => {
key.innerHTML = '<span class="hx:text-xs">⌘</span>K';
});
}
});
// Render the search data as JSON.
//
//
//
//
(function () {
const searchDataURL = '/en.search-data.json';
const inputElements = document.querySelectorAll('.search-input');
for (const el of inputElements) {
el.addEventListener('focus', init);
el.addEventListener('keyup', search);
el.addEventListener('keydown', handleKeyDown);
el.addEventListener('input', handleInputChange);
}
const shortcutElements = document.querySelectorAll('.search-wrapper kbd');
function setShortcutElementsOpacity(opacity) {
shortcutElements.forEach(el => {
el.style.opacity = opacity;
});
}
function handleInputChange(e) {
const opacity = e.target.value.length > 0 ? 0 : 100;
setShortcutElementsOpacity(opacity);
}
// Get the search wrapper, input, and results elements.
function getActiveSearchElement() {
const inputs = Array.from(document.querySelectorAll('.search-wrapper')).filter(el => el.clientHeight > 0);
if (inputs.length === 1) {
return {
wrapper: inputs[0],
inputElement: inputs[0].querySelector('.search-input'),
resultsElement: inputs[0].querySelector('.search-results')
};
}
return undefined;
}
const INPUTS = ['input', 'select', 'button', 'textarea']
// Focus the search input when pressing ctrl+k/cmd+k or /.
document.addEventListener('keydown', function (e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const activeElement = document.activeElement;
const tagName = activeElement && activeElement.tagName;
if (
inputElement === activeElement ||
!tagName ||
INPUTS.includes(tagName) ||
(activeElement && activeElement.isContentEditable))
return;
if (
e.key === '/' ||
(e.key === 'k' &&
(e.metaKey /* for Mac */ || /* for non-Mac */ e.ctrlKey))
) {
e.preventDefault();
inputElement.focus();
} else if (e.key === 'Escape' && inputElement.value) {
inputElement.blur();
}
});
// Dismiss the search results when clicking outside the search box.
document.addEventListener('mousedown', function (e) {
const { inputElement, resultsElement } = getActiveSearchElement();
if (!inputElement || !resultsElement) return;
if (
e.target !== inputElement &&
e.target !== resultsElement &&
!resultsElement.contains(e.target)
) {
setShortcutElementsOpacity(100);
hideSearchResults();
}
});
// Get the currently active result and its index.
function getActiveResult() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return { result: undefined, index: -1 };
const result = resultsElement.querySelector('.active');
if (!result) return { result: undefined, index: -1 };
const index = parseInt(result.dataset.index, 10);
return { result, index };
}
// Set the active result by index.
function setActiveResult(index) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
const { result: activeResult } = getActiveResult();
activeResult && activeResult.classList.remove('active');
const result = resultsElement.querySelector(`[data-index="${index}"]`);
if (result) {
result.classList.add('active');
result.focus();
}
}
// Get the number of search results from the DOM.
function getResultsLength() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return 0;
return resultsElement.dataset.count;
}
// Finish the search by hiding the results and clearing the input.
function finishSearch() {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
hideSearchResults();
inputElement.value = '';
inputElement.blur();
}
function hideSearchResults() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
resultsElement.classList.add('hx:hidden');
}
// Handle keyboard events.
function handleKeyDown(e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const resultsLength = getResultsLength();
const { result: activeResult, index: activeIndex } = getActiveResult();
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (activeIndex > 0) setActiveResult(activeIndex - 1);
break;
case 'ArrowDown':
e.preventDefault();
if (activeIndex + 1 < resultsLength) setActiveResult(activeIndex + 1);
break;
case 'Enter':
e.preventDefault();
if (activeResult) {
activeResult.click();
}
finishSearch();
case 'Escape':
e.preventDefault();
hideSearchResults();
// Clear the input when pressing escape
inputElement.value = '';
inputElement.dispatchEvent(new Event('input'));
// Remove focus from the input
inputElement.blur();
break;
}
}
// Initializes the search.
function init(e) {
e.target.removeEventListener('focus', init);
if (!(window.pageIndex && window.sectionIndex)) {
preloadIndex();
}
}
/**
* Preloads the search index by fetching data and adding it to the FlexSearch index.
* @returns {Promise<void>} A promise that resolves when the index is preloaded.
*/
async function preloadIndex() {
const tokenize = 'forward';
// https://github.com/TryGhost/Ghost/pull/21148
const regex = new RegExp(
`[\u{4E00}-\u{9FFF}\u{3040}-\u{309F}\u{30A0}-\u{30FF}\u{AC00}-\u{D7A3}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{2CEB0}-\u{2EBEF}\u{30000}-\u{3134F}\u{31350}-\u{323AF}\u{2EBF0}-\u{2EE5F}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]|[0-9A-Za-zа-я\u00C0-\u017F\u0400-\u04FF\u0600-\u06FF\u0980-\u09FF\u1E00-\u1EFF\u0590-\u05FF]+`,
'mug'
);
const encode = (str) => { return ('' + str).toLowerCase().match(regex) ?? []; }
window.pageIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'crumb'],
index: "content"
}
});
window.sectionIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'content', 'url', 'display', 'crumb'],
index: "content",
tag: [{
field: "pageId"
}]
}
});
const resp = await fetch(searchDataURL);
const data = await resp.json();
let pageId = 0;
for (const route in data) {
let pageContent = '';
++pageId;
const urlParts = route.split('/').filter(x => x != "" && !x.startsWith('#'));
let crumb = '';
let searchUrl = '/';
for (let i = 0; i < urlParts.length; i++) {
const urlPart = urlParts[i];
searchUrl += urlPart + '/'
const crumbData = data[searchUrl];
if (!crumbData) {
console.warn('Excluded page', searchUrl, '- will not be included for search result breadcrumb for', route);
continue;
}
let title = data[searchUrl].title;
if (title == "_index") {
title = urlPart.split("-").map(x => x).join(" ");
}
crumb += title;
if (i < urlParts.length - 1) {
crumb += ' > ';
}
}
for (const heading in data[route].data) {
const [hash, text] = heading.split('#');
const url = route.trimEnd('/') + (hash ? '#' + hash : '');
const title = text || data[route].title;
const content = data[route].data[heading] || '';
const paragraphs = content.split('\n').filter(Boolean);
sectionIndex.add({
id: url,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: title,
...(paragraphs[0] && { display: paragraphs[0] })
});
for (let i = 0; i < paragraphs.length; i++) {
sectionIndex.add({
id: `${url}_${i}`,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: paragraphs[i]
});
}
pageContent += ` ${title} ${content}`;
}
window.pageIndex.add({
id: pageId,
title: data[route].title,
crumb,
content: pageContent
});
}
}
/**
* Performs a search based on the provided query and displays the results.
* @param {Event} e - The event object.
*/
function search(e) {
const query = e.target.value;
if (!e.target.value) {
hideSearchResults();
return;
}
const { resultsElement } = getActiveSearchElement();
while (resultsElement.firstChild) {
resultsElement.removeChild(resultsElement.firstChild);
}
resultsElement.classList.remove('hx:hidden');
const pageResults = window.pageIndex.search(query, 5, { enrich: true, suggest: true })[0]?.result || [];
const results = [];
const pageTitleMatches = {};
for (let i = 0; i < pageResults.length; i++) {
const result = pageResults[i];
pageTitleMatches[i] = 0;
// Show the top 5 results for each page
const sectionResults = window.sectionIndex.search(query, 5, { enrich: true, suggest: true, tag: { 'pageId': `page_${result.id}` } })[0]?.result || [];
let isFirstItemOfPage = true
const occurred = {}
for (let j = 0; j < sectionResults.length; j++) {
const { doc } = sectionResults[j]
const isMatchingTitle = doc.display !== undefined
if (isMatchingTitle) {
pageTitleMatches[i]++
}
const { url, title } = doc
const content = doc.display || doc.content
if (occurred[url + '@' + content]) continue
occurred[url + '@' + content] = true
results.push({
_page_rk: i,
_section_rk: j,
route: url,
prefix: isFirstItemOfPage ? result.doc.crumb : undefined,
children: { title, content }
})
isFirstItemOfPage = false
}
}
const sortedResults = results
.sort((a, b) => {
// Sort by number of matches in the title.
if (a._page_rk === b._page_rk) {
return a._section_rk - b._section_rk
}
if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) {
return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]
}
return a._page_rk - b._page_rk
})
.map(res => ({
id: `${res._page_rk}_${res._section_rk}`,
route: res.route,
prefix: res.prefix,
children: res.children
}));
displayResults(sortedResults, query);
}
/**
* Displays the search results on the page.
*
* @param {Array} results - The array of search results.
* @param {string} query - The search query.
*/
function displayResults(results, query) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
if (!results.length) {
resultsElement.innerHTML = `<span class="no-result">No results found.</span>`;
return;
}
// Highlight the query in the result text.
function highlightMatches(text, query) {
const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(escapedQuery, 'gi');
return text.replace(regex, (match) => `<span class="match">${match}</span>`);
}
// Create a DOM element from the HTML string.
function createElement(str) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return div.firstChild;
}
function handleMouseMove(e) {
const target = e.target.closest('a');
if (target) {
const active = resultsElement.querySelector('a.active');
if (active) {
active.classList.remove('active');
}
target.classList.add('active');
}
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.prefix) {
fragment.appendChild(createElement(`
<div class="prefix">${result.prefix}</div>`));
}
let li = createElement(`
<li>
<a data-index="${i}" href="${result.route}" class=${i === 0 ? "active" : ""}>
<div class="title">`+ highlightMatches(result.children.title, query) + `</div>` +
(result.children.content ?
`<div class="excerpt">` + highlightMatches(result.children.content, query) + `</div>` : '') + `
</a>
</li>`);
li.addEventListener('mousemove', handleMouseMove);
li.addEventListener('keydown', handleKeyDown);
li.querySelector('a').addEventListener('click', finishSearch);
fragment.appendChild(li);
}
resultsElement.appendChild(fragment);
resultsElement.dataset.count = results.length;
}
})();
Loaded 100 of 496 files, more files were not shown because too many files have changed in this diff. Show more