website refactor
This commit is contained in:
16
apps/website/lib/contracts/api/ApiClient.ts
Normal file
16
apps/website/lib/contracts/api/ApiClient.ts
Normal 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>;
|
||||
}
|
||||
29
apps/website/lib/contracts/display-objects/DisplayObject.ts
Normal file
29
apps/website/lib/contracts/display-objects/DisplayObject.ts
Normal 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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
21
apps/website/lib/contracts/presenters/Presenter.ts
Normal file
21
apps/website/lib/contracts/presenters/Presenter.ts
Normal 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;
|
||||
}
|
||||
37
apps/website/lib/contracts/services/Service.ts
Normal file
37
apps/website/lib/contracts/services/Service.ts
Normal 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>;
|
||||
}
|
||||
44
apps/website/lib/contracts/types/primitives.ts
Normal file
44
apps/website/lib/contracts/types/primitives.ts
Normal 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;
|
||||
};
|
||||
38
apps/website/lib/contracts/view-data/ViewData.ts
Normal file
38
apps/website/lib/contracts/view-data/ViewData.ts
Normal 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;
|
||||
36
apps/website/lib/contracts/view-models/ViewModel.ts
Normal file
36
apps/website/lib/contracts/view-models/ViewModel.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user