Skip to content

Commit 624325e

Browse files
authored
Merge pull request #98 from wellwind/codex/review-clean-architecture-plan
feat(shared): add cross-slice ports and stubs
2 parents 15466c0 + 27785dc commit 624325e

File tree

10 files changed

+180
-6
lines changed

10 files changed

+180
-6
lines changed

docs/clean-architecture-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ src/
111111
### Phase 1 – Shared Core & Contracts
112112
- ✅ 建立 `shared/core` 骨架並搬遷 `PostMeta`/分類/標籤型別,新增 `@shared/core``@shared/testing` path alias 供程式與測試共用。
113113
- ✅ 在 `shared/testing` 提供 `postMetaBuilder`,開始以共用 builder 取代既有測試內的臨時工廠函式。
114-
- 定義跨切片 ports(`AnalyticsPort``PlatformPort``SeoMetaPort`)並提供暫行實作;後續將補齊 Markdown/日期契約與其適配層。
114+
- 定義跨切片 ports(`AnalyticsPort``PlatformPort``SeoMetaPort`)並提供暫行實作;後續將補齊 Markdown/日期契約與其適配層。
115115

116116
### Phase 2 – Scaffold Feature Slices
117117
- Create directory skeletons for `blog`, `post-detail`, `search`, `taxonomy`, `layout` mirroring the proposed structure.

docs/phase-1-plan.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- [x] 搬遷 `PostMeta`、分類/標籤型別至共享模型,並更新現有呼叫端引用。
1212
- [x] 規劃 Markdown 與日期相關契約的遷移策略,並準備空殼模組待後續填補。
1313
- [x] 建立 `shared/testing` 的 builders/mocks 架構,並將既有測試改用共用 `postMetaBuilder`
14-
- [ ] 定義並匯出跨切片 ports 的介面與暫行實作。
14+
- [x] 定義並匯出跨切片 ports 的介面與暫行實作。
1515
- [x] 更新 `tsconfig` path alias 與相關建置腳本,確保新路徑可用。
1616
- [x] 調整 `docs/clean-architecture-plan.md` 與其他文件,反映 Phase 1 的實作現況與剩餘工作。
1717

@@ -31,3 +31,4 @@
3131

3232
## 進度紀錄
3333
- 2025-09-19:完成 `PostMeta` 模型搬遷與 `shared/core` 骨架建立,新增 `@shared/core``@shared/testing` path alias,並以 `postMetaBuilder` 取代部份測試中的臨時建構函式。已執行 `npm run lint``npm run test` 確認既有流程穩定,尚待定義跨切片 ports 與補齊 Markdown/日期契約實作,文件同步更新進行中。
34+
- 2025-09-20:補齊 `AnalyticsPort``PlatformPort``SeoMetaPort` 介面與暫行實作,並提供對應的測試替身,準備後續切片改造時的依賴注入介面。
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export interface AnalyticsView {
2+
name: string;
3+
}
4+
5+
export interface AnalyticsPage {
6+
id: string;
7+
url: string;
8+
title?: string;
9+
}
10+
11+
export type AnalyticsEventAttributes = Record<string, unknown>;
12+
13+
export interface AnalyticsSpan {
14+
setAttribute(name: string, value: unknown): void;
15+
end(): void;
16+
}
17+
18+
export interface AnalyticsTracer {
19+
startSpan(name: string, attributes?: AnalyticsEventAttributes): AnalyticsSpan;
20+
}
21+
22+
export interface AnalyticsPort {
23+
readonly isAvailable: boolean;
24+
pushEvent(eventName: string, attributes?: AnalyticsEventAttributes): void;
25+
setView(view: AnalyticsView): void;
26+
getView(): AnalyticsView | null;
27+
setPage(page: AnalyticsPage): void;
28+
getTracer(instrumentation: string): AnalyticsTracer | null;
29+
}
30+
31+
export const createNoopAnalyticsPort = (): AnalyticsPort => ({
32+
isAvailable: false,
33+
pushEvent: () => undefined,
34+
setView: () => undefined,
35+
getView: () => null,
36+
setPage: () => undefined,
37+
getTracer: () => null,
38+
});

src/shared/core/ports/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
// 跨切片 Ports 將在後續階段定義。
2-
export {};
1+
export * from './analytics.port';
2+
export * from './platform.port';
3+
export * from './seo-meta.port';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export type StateGetter<T> = () => T;
2+
3+
export interface PlatformPort {
4+
readonly isServer: boolean;
5+
readonly isBrowser: boolean;
6+
readonly isSmallScreen: StateGetter<boolean>;
7+
}
8+
9+
export interface PlatformPortOptions {
10+
isServer?: boolean;
11+
isBrowser?: boolean;
12+
isSmallScreen?: boolean;
13+
}
14+
15+
export const createPlatformPort = (
16+
options: PlatformPortOptions = {},
17+
): PlatformPort => {
18+
const isServer = options.isServer ?? false;
19+
const isBrowser = options.isBrowser ?? !isServer;
20+
const isSmallScreenValue = options.isSmallScreen ?? false;
21+
22+
return {
23+
isServer,
24+
isBrowser,
25+
isSmallScreen: () => isSmallScreenValue,
26+
};
27+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type SeoMetaType = 'website' | 'article';
2+
3+
export interface SeoMetaPayload {
4+
title: string;
5+
description: string;
6+
keywords: string[];
7+
type: SeoMetaType;
8+
ogImage?: string;
9+
}
10+
11+
export interface SeoMetaPort {
12+
resetMeta(meta: SeoMetaPayload): void;
13+
}
14+
15+
export const createNoopSeoMetaPort = (): SeoMetaPort => ({
16+
resetMeta: () => undefined,
17+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
AnalyticsEventAttributes,
3+
AnalyticsPage,
4+
AnalyticsPort,
5+
AnalyticsTracer,
6+
AnalyticsView,
7+
} from '@shared/core';
8+
9+
export interface LoggedAnalyticsEvent {
10+
name: string;
11+
attributes?: AnalyticsEventAttributes;
12+
}
13+
14+
const noopTracer: AnalyticsTracer = {
15+
startSpan: () => ({
16+
setAttribute: () => undefined,
17+
end: () => undefined,
18+
}),
19+
};
20+
21+
export class AnalyticsPortSpy implements AnalyticsPort {
22+
readonly isAvailable = true;
23+
readonly events: LoggedAnalyticsEvent[] = [];
24+
readonly views: AnalyticsView[] = [];
25+
readonly pages: AnalyticsPage[] = [];
26+
27+
private currentView: AnalyticsView | null = null;
28+
29+
pushEvent(name: string, attributes?: AnalyticsEventAttributes): void {
30+
this.events.push({ name, attributes });
31+
}
32+
33+
setView(view: AnalyticsView): void {
34+
this.currentView = view;
35+
this.views.push(view);
36+
}
37+
38+
getView(): AnalyticsView | null {
39+
return this.currentView;
40+
}
41+
42+
setPage(page: AnalyticsPage): void {
43+
this.pages.push(page);
44+
}
45+
46+
getTracer(): AnalyticsTracer | null {
47+
return noopTracer;
48+
}
49+
}

src/shared/testing/mocks/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
// Ports 將於後續階段提供相對應的測試替身。
2-
export {};
1+
export * from './analytics-port.mock';
2+
export * from './platform-port.mock';
3+
export * from './seo-meta-port.mock';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { PlatformPort, PlatformPortOptions, StateGetter } from '@shared/core';
2+
3+
export class PlatformPortStub implements PlatformPort {
4+
isServer: boolean;
5+
isBrowser: boolean;
6+
private isSmallScreenValue: boolean;
7+
8+
constructor(options: PlatformPortOptions = {}) {
9+
this.isServer = options.isServer ?? false;
10+
this.isBrowser = options.isBrowser ?? !this.isServer;
11+
this.isSmallScreenValue = options.isSmallScreen ?? false;
12+
}
13+
14+
readonly isSmallScreen: StateGetter<boolean> = () => this.isSmallScreenValue;
15+
16+
setIsServer(isServer: boolean) {
17+
this.isServer = isServer;
18+
}
19+
20+
setIsBrowser(isBrowser: boolean) {
21+
this.isBrowser = isBrowser;
22+
}
23+
24+
setIsSmallScreen(isSmallScreen: boolean) {
25+
this.isSmallScreenValue = isSmallScreen;
26+
}
27+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { SeoMetaPayload, SeoMetaPort } from '@shared/core';
2+
3+
export class SeoMetaPortSpy implements SeoMetaPort {
4+
readonly calls: SeoMetaPayload[] = [];
5+
6+
resetMeta(meta: SeoMetaPayload): void {
7+
this.calls.push(meta);
8+
}
9+
10+
get lastCall(): SeoMetaPayload | undefined {
11+
return this.calls.at(-1);
12+
}
13+
}

0 commit comments

Comments
 (0)