website refactor

This commit is contained in:
2026-01-12 01:45:19 +01:00
parent fefd8d1cd6
commit 61db9116f6
11 changed files with 375 additions and 3 deletions

View File

@@ -0,0 +1,16 @@
/**
* ApiClient contract
*
* Transport boundary for API calls.
* Returns API Transport DTOs only.
*
* Based on WEBSITE_CONTRACT.md:
* - API Transport DTOs are returned by the backend API over HTTP
* - Website uses ApiClient to communicate with the API
*/
export interface ApiClient<TApiDto = unknown> {
/**
* Execute an API call and return the transport DTO
*/
execute(...args: unknown[]): Promise<TApiDto>;
}

View File

@@ -0,0 +1,29 @@
/**
* DisplayObject contract
*
* Deterministic, reusable, UI-only formatting/mapping logic.
*
* Based on DISPLAY_OBJECTS.md:
* - Class-based
* - Immutable
* - Deterministic
* - Side-effect free
* - No Intl.* or toLocale*
* - No business rules
*/
export interface DisplayObject {
/**
* Format or map the display object
*
* @returns Primitive values only (strings, numbers, booleans)
*/
format(): unknown;
/**
* Optional: Get multiple display variants
*
* Allows a single DisplayObject to expose multiple presentation formats
*/
variants?(): Record<string, unknown>;
}

View File

@@ -1,4 +1,5 @@
import type { PageQueryResult } from '@/lib/page-queries/PageQueryResult';
import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult";
/**
* PageQuery contract interface

View File

@@ -0,0 +1,21 @@
/**
* Presenter contract
*
* Pure, deterministic transformation between presentation models.
*
* Based on PRESENTERS.md:
* - Deterministic
* - Side-effect free
* - No HTTP/API calls
* - Maps between Page DTO, ViewModel, and ViewData
*/
export interface Presenter<TInput, TOutput> {
/**
* Transform input to output
*
* @param input - The input presentation model (Page DTO, ViewModel, etc.)
* @returns The output presentation model (ViewModel, ViewData, etc.)
*/
present(input: TInput): TOutput;
}

View File

@@ -0,0 +1,37 @@
/**
* Service contract
*
* Orchestration boundary for server-side operations.
* Returns API DTOs or Page DTOs only.
* Must be stateless.
*
* Based on WEBSITE_CONTRACT.md:
* - Services orchestrate IO and composition
* - They do not prepare UI
* - They return ApiDto or PageDto only
*/
/**
* Base service interface for orchestration operations
*/
export interface Service<TApiDto = unknown, TPageDto = unknown> {
/**
* Execute a service operation
* Returns either API Transport DTO or Page DTO
*/
execute(...args: unknown[]): Promise<TApiDto | TPageDto>;
}
/**
* Service that returns API Transport DTOs
*/
export interface ApiService<TApiDto = unknown> extends Service<TApiDto, never> {
execute(...args: unknown[]): Promise<TApiDto>;
}
/**
* Service that returns Page DTOs
*/
export interface PageService<TPageDto = unknown> extends Service<never, TPageDto> {
execute(...args: unknown[]): Promise<TPageDto>;
}

View File

@@ -0,0 +1,44 @@
/**
* Primitive types for contracts
*
* Reusable type definitions that can be used across all contracts.
*/
/**
* Represents any JSON-serializable value
*/
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Represents a JSON-serializable object (not array)
*/
export type JsonObject = {
[key: string]: JsonValue;
};
/**
* Represents a JSON-serializable array
*/
export type JsonArray = JsonValue[];
/**
* Helper type to ensure a type is JSON-serializable
*
* Usage:
* ```typescript
* type MyData = JsonSerializable<{
* title: string;
* count: number;
* items: string[];
* }>;
* ```
*/
export type JsonSerializable<T> = {
[K in keyof T]: T[K] extends JsonValue ? T[K] : never;
};

View File

@@ -0,0 +1,38 @@
/**
* ViewData contract
*
* Represents the shape of data that can be passed to Templates.
*
* Based on VIEW_DATA.md:
* - JSON-serializable only
* - Contains only template-ready values (strings/numbers/booleans)
* - MUST NOT contain class instances
*
* This is a type-level contract, not a class-based one.
*/
import type { JsonValue, JsonObject } from '../types/primitives';
/**
* Base interface for ViewData objects
*
* All ViewData must be JSON-serializable.
* This type ensures no class instances or functions are included.
*/
export interface ViewData extends JsonObject {
[key: string]: JsonValue;
}
/**
* Helper type to ensure a type is ViewData-compatible
*
* Usage:
* ```typescript
* type MyViewData = ViewData & {
* title: string;
* count: number;
* items: string[];
* };
* ```
*/
export type ViewDataOf<T extends ViewData> = T;

View File

@@ -0,0 +1,36 @@
/**
* ViewModelBase contract
*
* Base class for all ViewModels.
*
* Based on VIEW_MODELS.md:
* - Client-only classes
* - Fully prepared UI state
* - Never serialized
* - Never passed to Templates
* - Compose Display Objects for reusable formatting
* - Expose UI-specific computed properties
*
* Based on WEBSITE_CONTRACT.md:
* - ViewModels are client-only
* - Must not expose methods that return Page DTO or API DTO
*
* Architecture Flow:
* 1. PageQuery returns Page DTO (server)
* 2. Presenter transforms Page DTO → ViewModel (client)
* 3. Presenter transforms ViewModel → ViewData (client)
* 4. Template receives ViewData only
*
* ViewModels provide UI state and helpers.
* Presenters handle the transformation to ViewData.
*/
export abstract class ViewModel {
/**
* Optional: Validate the ViewModel state
*
* Can be used to ensure the ViewModel is in a valid state
* before a Presenter converts it to ViewData.
*/
validate?(): boolean;
}

View File

@@ -161,6 +161,9 @@ export class ArchitectureGuardrails {
this.checkSortingFiltering(filePath, content);
this.checkNullReturns(filePath, content);
// NEW: Enforce PageQuery contract
this.checkPageQueryContract(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
@@ -226,6 +229,9 @@ export class ArchitectureGuardrails {
this.checkUseClientDirective(filePath, content);
this.checkViewModelPageQueryImports(filePath, content);
// NEW: Enforce ViewModel contract
this.checkViewModelContract(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
@@ -243,6 +249,9 @@ export class ArchitectureGuardrails {
// Rule 6: Client-only guardrails - presenters should not use HTTP
this.checkHttpCalls(filePath, content);
// NEW: Enforce Presenter contract
this.checkPresenterContract(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
@@ -1078,6 +1087,109 @@ export class ArchitectureGuardrails {
});
}
// ============================================================================
// NEW: VIOLATION CHECKS - CONTRACT ENFORCEMENT
// ============================================================================
private checkPageQueryContract(filePath: string, content: string): void {
// Check if file contains a class that should implement PageQuery
const classMatch = content.match(/class\s+(\w+PageQuery)\s+{/);
if (!classMatch) return;
// Check if it implements PageQuery interface
if (!content.includes('implements PageQuery<')) {
this.addViolation(
'pagequery-must-implement-contract',
filePath,
1,
'PageQuery class must implement PageQuery<TPageDto, TParams> interface'
);
}
// Check if it has execute method
if (!content.includes('execute(')) {
this.addViolation(
'pagequery-must-have-execute',
filePath,
1,
'PageQuery class must have execute(params) method'
);
}
// Check if execute returns Promise<PageQueryResult>
const executeMatch = content.match(/execute\([^)]*\):\s*Promise<PageQueryResult/);
if (!executeMatch) {
this.addViolation(
'pagequery-execute-return-type',
filePath,
1,
'PageQuery execute() must return Promise<PageQueryResult<TPageDto>>'
);
}
}
private checkPresenterContract(filePath: string, content: string): void {
// Check if file contains a class that should implement Presenter
const classMatch = content.match(/class\s+(\w+Presenter)\s+{/);
if (!classMatch) return;
// Check if it implements Presenter interface
if (!content.includes('implements Presenter<')) {
this.addViolation(
'presenter-must-implement-contract',
filePath,
1,
'Presenter class must implement Presenter<TInput, TOutput> interface'
);
}
// Check if it has present method
if (!content.includes('present(')) {
this.addViolation(
'presenter-must-have-present',
filePath,
1,
'Presenter class must have present(input) method'
);
}
// Check for 'use client' directive
if (!content.includes("'use client'") && !content.includes('"use client"')) {
this.addViolation(
'presenter-must-be-client',
filePath,
1,
'Presenter must have \'use client\' directive at top-level'
);
}
}
private checkViewModelContract(filePath: string, content: string): void {
// Check if file contains a class that should extend ViewModel
const classMatch = content.match(/class\s+(\w+ViewModel)\s+{/);
if (!classMatch) return;
// Check if it extends ViewModel
if (!content.includes('extends ViewModel')) {
this.addViolation(
'viewmodel-must-extend-contract',
filePath,
1,
'ViewModel class must extend ViewModel base class'
);
}
// Check for 'use client' directive
if (!content.includes("'use client'") && !content.includes('"use client"')) {
this.addViolation(
'viewmodel-must-be-client',
filePath,
1,
'ViewModel must have \'use client\' directive at top-level'
);
}
}
// ============================================================================
// HELPERS
// ============================================================================

View File

@@ -139,4 +139,42 @@ describe('Architecture Guardrails', () => {
expect(violations.length).toBe(0);
});
// NEW: Contract enforcement tests
it('should enforce: PageQuery classes must implement PageQuery contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'pagequery-must-implement-contract' ||
v.ruleName === 'pagequery-must-have-execute' ||
v.ruleName === 'pagequery-execute-return-type'
);
expect(violations.length).toBe(0);
});
it('should enforce: Presenter classes must implement Presenter contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'presenter-must-implement-contract' ||
v.ruleName === 'presenter-must-have-present' ||
v.ruleName === 'presenter-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: ViewModel classes must extend ViewModel contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'viewmodel-must-extend-contract' ||
v.ruleName === 'viewmodel-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: DisplayObject files must export only classes', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-non-class-display-exports'
);
expect(violations.length).toBe(0);
});
});

View File

@@ -123,7 +123,7 @@ See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
Canonical placement in this repo:
- colocated with ViewModels in `apps/website/lib/view-models/**`
- `apps/website/lib/presenters/**`
### 4.2 Display Object
@@ -250,4 +250,4 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1).
6. Page Queries do not format; they only compose.
7. Presenters are pure and deterministic.
8. Server Actions are the only write entry point.
9. Authorization always belongs to the API.
9. Authorization always belongs to the API.