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);
});
});