@simplysm/sd-claude 14.0.95 → 14.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ ORM 호출, 파일 변환, 비즈니스 로직 등은 위 두 경우에 해당
39
39
  | 앱에서 공유 마스터 데이터 사용 (provider 정의·항목 추가, 선택 컨트롤의 관리·선택 모달, 좌측 선택+우측 상세 레이아웃) | [client-shared-data.md](./manuals/client-shared-data.md) |
40
40
  | 클라이언트·서버 간 실시간 이벤트 정의·발생·구독 | [event.md](./manuals/event.md) |
41
41
  | 앱 메뉴 구조·권한 정의 추가/수정 | [client-app-structure.md](./manuals/client-app-structure.md) |
42
+ | 클라이언트 SSG(프리렌더·SEO) 셋업, `prerender` 설정, SSR-safe 화면 작성 | [client-ssg.md](./manuals/client-ssg.md) |
42
43
  | ORM 쿼리 작성(조회 흐름·안티패턴), 컬럼 nullable/default·유니크 정책, 삭제 전략 | [orm.md](./manuals/orm.md) |
43
44
  | 이종 엔티티를 한 목록으로 합쳐 표시 (UNION) | [orm-union.md](./manuals/orm-union.md) |
44
45
  | CRUD 처리에 데이터 변경 이력 적재·조회·표시 (누가·언제·무엇을 변경, 목록의 수정일시·수정자 컬럼) | [data-log.md](./manuals/data-log.md) |
@@ -33,6 +33,8 @@ provideSdAngular(opt: { clientName: string }): EnvironmentProviders
33
33
 
34
34
  `makeEnvironmentProviders` 로 다음을 한 번에 등록: `IMAGE_CONFIG`(이미지 경고 비활성), ng-icons 기본 설정(strokeWidth 1.5, size 1.33em), 테마 dark/fontSize 의 localStorage 복원·저장 effect, 전역 `unhandledrejection`/`error` 리스너→`ErrorHandler`, `SdAngularConfigProvider`(clientName 주입), `SdOptionEventPlugin`(이벤트 수식어 지원), `ErrorHandler=SdGlobalErrorHandlerPlugin`, `provideZonelessChangeDetection()`, `SwUpdate` 주기 점검(5분 시작 → 실패 시 지수 백오프 최대 60분, 업데이트 발견 시 confirm 후 reload), 라우팅 네비게이션을 `SdBusyProvider.globalBusyCount` 와 연동. 앱 `ApplicationConfig.providers` 에 1회 추가.
35
35
 
36
+ SSR(프리렌더) 안전: 부트스트랩 경로의 브라우저 의존 동작(테마 localStorage 복원, window 에러 리스너, SW 업데이트 점검, busy 오버레이, 에러 오버레이, `setupBgTheme`/`setupRipple`, core-browser prototype 확장)은 서버 플랫폼에서 무동작 — `prerender`(SSG) 클라이언트에서 그대로 사용 가능. 그 외 컴포넌트의 SSR-safe 여부는 개별 확인 필요.
37
+
36
38
  ```ts
37
39
  export const appConfig: ApplicationConfig = {
38
40
  providers: [provideRouter(routes), provideSdAngular({ clientName: "my-app" })],
@@ -94,6 +94,7 @@ interface SdClientPackageConfig {
94
94
  exclude?: string[];
95
95
  browserSupport?: SdBrowserSupportConfig;
96
96
  pwa?: false | SdPwaConfig;
97
+ prerender?: string[];
97
98
  }
98
99
  ```
99
100
 
@@ -106,6 +107,10 @@ interface SdClientPackageConfig {
106
107
  - exclude?: string[] — Capacitor/Electron `package.json` 에 추가(번들에서 빼 외부 패키지로 둘)할 패키지 목록.
107
108
  - browserSupport?: SdBrowserSupportConfig — 브라우저 호환(browserslist/PostCSS/legacyModule) 설정.
108
109
  - pwa?: false | SdPwaConfig — PWA 설정. `false` 면 비활성화, 미지정 시 기본값으로 활성화, 객체면 manifest 커스텀. PWA 가 필요 없으면 `false`.
110
+ - prerender?: string[] — SSG(빌드 타임 프리렌더) 라우트 목록(예: `["/", "/about"]`). SEO 가 필요한 공개 페이지에 지정. 프로덕션 빌드에서만 동작하며 dev/watch 모드는 기존 SPA 그대로. 동작:
111
+ - `src/main.server.ts` 가 서버 부트스트랩(`(context: BootstrapContext) => bootstrapApplication(App, config, context)`)을 default export 해야 하며, 앱 설정에 `provideClientHydration()`, 서버 설정에 `provideServerRendering()` 포함. `@angular/platform-server` 의존성 필요 (Angular 표준 SSR 셋업과 동일).
112
+ - 라우트별 `<경로>/index.html` 을 서버 렌더 결과로 생성 (`"/"` 는 `index.html` 대체). SPA 셸은 `index.csr.html` 로 별도 보존되어 비프리렌더 라우트 딥링크 폴백에 사용 (service-server 정적 핸들러가 처리).
113
+ - 라우트 1건이라도 렌더 실패 시 빌드 전체 실패.
109
114
 
110
115
  ### SdServerPackageConfig (server)
111
116
 
@@ -79,7 +79,7 @@ function createWebSocketHandler(
79
79
 
80
80
  - `handleHttpRequest(req, reply, jwtSecret, runMethod)` — `/api/:service/:method` 처리. `x-sd-client-name` 헤더 필수, `Authorization: Bearer <token>` 검증(실패 시 401). GET 은 `?json=` 쿼리, POST 는 배열 본문에서 파라미터를 받아 `runMethod` 실행. 본문이 배열이 아니면 400, 그 외 HTTP 메서드는 405.
81
81
  - `handleUpload(req, reply, rootPath, jwtSecret)` — `/upload` multipart 처리. multipart 아니면 400, 인증 토큰 필수(없거나 무효면 401). 파일을 `rootPath/www/uploads/<uuid><ext>` 로 저장하고 `ServiceUploadResult[]`(`{ path, filename, size }`) 반환. 도중 실패 시 그 요청에서 저장한 파일을 모두 롤백 삭제 후 500.
82
- - `handleStaticFile(req, reply, rootPath, urlPath)` — `rootPath/www` 하위 정적 파일 전송. `www` 밖 경로는 차단(throw), 디렉터리면 슬래시 리다이렉트 후 `index.html`, `.` 으로 시작하는 숨김 파일은 403, 미존재는 404, 그 외 전송 실패는 500 HTML 응답.
82
+ - `handleStaticFile(req, reply, rootPath, urlPath)` — `rootPath/www` 하위 정적 파일 전송. `www` 밖 경로는 차단(throw), 디렉터리면 슬래시 리다이렉트 후 `index.html`, `.` 으로 시작하는 숨김 파일은 403, 미존재는 404, 그 외 전송 실패는 500 HTML 응답. SPA 폴백: 미존재 + 확장자 없는 페이지 요청이면 `www` 루트 방향으로 가장 가까운 `index.csr.html`(SSG 클라이언트의 SPA 셸)을 찾아 반환 — 셸 파일이 없는 기존 클라이언트는 그대로 404.
83
83
 
84
84
  ## ServerProtocolWrapper / createServerProtocolWrapper
85
85
 
@@ -7,7 +7,7 @@
7
7
  common 패키지에 클라이언트별 `AppStructureItem[]` 상수를 두고, 앱 부트스트랩에서 `SdAppStructureProvider.initialize(items)` 로 연결함.
8
8
 
9
9
  ```ts
10
- // common/src/app-structure-items.ts
10
+ // common/src/app-structure.ts
11
11
  import type { AppStructureItem } from "@simplysm/service-common";
12
12
  import { tablerBox } from "@ng-icons/tabler-icons"; // 아이콘은 @ng-icons 의 SVG 문자열 상수
13
13
 
@@ -504,7 +504,11 @@ constructor() {
504
504
 
505
505
  ## detail 데이터 흐름
506
506
 
507
- detail 컴포넌트는 식별자를 받아 자체 로드·저장을 수행하고, 변경·삭제 `submitted` 부모에게 알림.
507
+ detail 컴포넌트는 식별자를 받아 자체 로드·저장을 수행하고, 저장·삭제 결과를 부모에게 알림. 알림 output 은 사용 맥락에 따라 두 종류이며 **서로 독립**(배타 아님):
508
+
509
+ - **`submitted`** (`output<boolean>`) — 트리거: detail 을 컨트롤로 임베드한 부모가 저장을 감지해 refresh 해야 할 때. 부모가 `(submitted)="doRefresh()"` 로 받음.
510
+ - **`close`** (`SdModalContentDef` 의 output) — 트리거: detail 을 모달로 띄울 때. 저장·삭제 후 `close.emit(payload)` 로 결과를 반환하고, 호출 측은 `showAsync` 반환값으로 받아 refresh ([모달 호출](#모달-호출) 참조).
511
+ - 한 detail 이 모달·임베드 양쪽으로 쓰이면 둘 다 둠. 모달 전용이면 `close` 만, 임베드 전용이면 `submitted` 만, page 전용·공유데이터 뷰면 둘 다 없이 self-refresh·`emitAsync` 로 처리. 아래 시그널·예시는 `submitted`(임베드) 기준.
508
512
 
509
513
  ### 시그널 구성
510
514
 
@@ -601,11 +605,12 @@ async onSubmit(): Promise<void> {
601
605
 
602
606
  **핵심 약속**:
603
607
 
604
- - 식별자는 `input.required<>`받음.
608
+ - 식별자 input 은 조회·수정 전용이면 `input.required<number>()`, 신규 등록까지 한 detail 처리하면 `input<number>()`(undefined = 신규).
605
609
  - 로드 후 `_orgData = obj.clone(loaded)` 로 원본 보관.
606
610
  - 페이지 이탈 가드는 `setupCanDeactivate` + `obj.equal` 비교로 처리.
607
611
  - 저장 완료 후 `_refresh()` 로 다시 로드 → `submitted.emit(true)`.
608
612
  - 삭제·취소 등 다른 액션도 끝에 `submitted.emit(true)` 를 emit 해 부모(list) 가 새로고침할 수 있게 함.
613
+ - 위 `submitted` 는 임베드(컨트롤) 사용 시의 통지. 모달로 띄우는 detail 은 대신 `close.emit(payload)` 로 결과를 반환(호출 측이 `showAsync` 반환으로 refresh). 둘은 독립이라 양쪽으로 쓰이면 함께 둠.
609
614
 
610
615
  ## 시트 컬럼·셀 표준
611
616
 
@@ -734,7 +739,7 @@ private readonly _appService = inject(AppServiceProvider);
734
739
 
735
740
  await this._appService.user.someMethod(...);
736
741
 
737
- const listenerKey = await this._appService.authInfoEvent.addListener(info, async (data) => { ... });
742
+ const listenerKey = await this._appService.authInfoChangedEvent.addListener(info, async () => { ... });
738
743
  ```
739
744
 
740
745
  Provider 정의·서비스·이벤트 호출 추가 컨벤션은 [client-service.md](./client-service.md) 참조.
@@ -19,6 +19,7 @@
19
19
  [selectMode]="selectMode() ?? 'multi'"
20
20
  [key]="'<도메인-키>'"
21
21
  [items]="items()"
22
+ [currDeletedItems]="deletedItems()"
22
23
  [trackByFn]="trackByFn"
23
24
  [(selectedKeys)]="selectedKeys"
24
25
  [(currentPage)]="page"
@@ -39,6 +40,8 @@
39
40
  </sd-crud-list>
40
41
  ```
41
42
 
43
+ - **`[currDeletedItems]`** — 삭제(soft delete)된 행을 시트에서 취소선으로 구분하고 "선택 복구" 버튼을 띄우는 입력. `deletedItems = computed(() => this.items().filter((i) => i.isDeleted))` 를 넘김. 삭제항목 포함 검색을 지원하는 목록에는 필수 — 빠뜨리면 삭제 행이 일반 행과 구분되지 않고 복구 버튼이 나오지 않음.
44
+
42
45
  ### 슬롯 규약
43
46
 
44
47
  | 슬롯 | 용도 |
@@ -114,7 +117,7 @@ async onEdit(item: IItem, event: Event): Promise<void> {
114
117
  ```html
115
118
  <ng-template #toolTpl>
116
119
  <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
117
- <ng-icon [svg]="tablerUpload" />
120
+ <ng-icon [svg]="tablerDownload" />
118
121
  엑셀 다운로드
119
122
  </sd-button>
120
123
  </ng-template>
@@ -209,7 +212,7 @@ async onUploadExcelButtonClick(): Promise<void> {
209
212
  const dbRows =
210
213
  ids.length === 0
211
214
  ? []
212
- : await this._appOrm.connectWithoutTransAsync((db) =>
215
+ : await this._appOrm.connectAsync((db) =>
213
216
  db
214
217
  .employee()
215
218
  .where((c) => [expr.in(c.id, ids)])
@@ -359,7 +362,7 @@ async onDelete(targets: IItem[]): Promise<void> {
359
362
  }
360
363
  ```
361
364
 
362
- **단건 삭제 (detail)**: `sd-crud-detail` 표준 호출에는 `(delete)` output 이 없으므로, 삭제 버튼을 `#commandTpl` 슬롯에 두고 `(click)="onDelete()"` 로 배선. 목록의 `_refresh()` 대신 `submitted.emit(true)` 부모(list) 에 통지.
365
+ **단건 삭제 (detail)**: `sd-crud-detail` 표준 호출에는 `(delete)` output 이 없으므로, 삭제 버튼을 슬롯에 직접 둠 — 모달로 띄우는 detail 은 `#bottomCommandTpl`(모달 "확인" 버튼과 같은 하단 줄)에 두고 `(click)="onDelete()"` 로 배선. 목록의 `_refresh()` 대신 detail 통지 output(임베드면 `submitted.emit(true)`, 모달이면 `close.emit(payload)`)으로 부모(list) 에 통지.
363
366
 
364
367
  ```ts
365
368
  async onDelete(): Promise<void> {
@@ -435,4 +438,4 @@ for (const id of ids) {
435
438
  - 삭제·복구·이력 적재는 한 `connectAsync` 트랜잭션 안에서 수행 — 데이터만 바뀌고 이력이 빠지거나 그 반대가 되지 않게 함.
436
439
  - 벌크 복구는 하나라도 충돌하면 전체 롤백(원자성). 충돌분만 빼고 나머지를 복구하지 않음.
437
440
  - 활성 유니크 검증은 복구 경로에서 빠뜨리지 않음 — 단건은 선검증, 벌크는 후검증. 활성 유니크가 없는 모델이면 생략 가능.
438
- - 단건(detail)은 삭제 후 [client-component.md "detail 데이터 흐름"](./client-component.md) 의 계약대로 `submitted.emit(true)` 로 부모에 통지(modal 컨텍스트에선 모달 호스트가 위에 `close` 닫음 `emit` 대체 아님). 복구 후엔 닫지 않고 refresh — 복구 직후 상세를 계속 보도록.
441
+ - 단건(detail)은 삭제 후 부모에 통지 임베드(컨트롤) `submitted.emit(true)`, 모달이면 `close.emit(payload)`결과 반환(호출 측이 `showAsync` 반환으로 refresh). output 독립이며 사용 맥락에 따라 한쪽 또는 양쪽 ([client-component.md "detail 데이터 흐름"](./client-component.md) 참조). 복구 후엔 닫지 않고 refresh — 복구 직후 상세를 계속 보도록.
@@ -7,7 +7,7 @@
7
7
 
8
8
  ## AppOrmProvider 를 정의하려면 (새 앱 1회성)
9
9
 
10
- 앱의 DbContext·DB명·스키마를 한 곳에 고정하고, 트랜잭션 유무별 진입 메서드를 제공.
10
+ 앱의 DbContext·DB명·스키마를 한 곳에 고정하고, `connectAsync` 메서드로 쿼리를 실행.
11
11
 
12
12
  ```ts
13
13
  @Injectable({ providedIn: "root" })
@@ -24,13 +24,6 @@ export class AppOrmProvider {
24
24
  callback,
25
25
  );
26
26
  }
27
-
28
- connectWithoutTransAsync<R>(callback: (db: MainDbContext) => Promise<R>): Promise<R> {
29
- return this._appService.orm.connectWithoutTransaction(
30
- { /* 같은 옵션 */ },
31
- callback,
32
- );
33
- }
34
27
  }
35
28
  ```
36
29
 
@@ -38,7 +31,7 @@ export class AppOrmProvider {
38
31
 
39
32
  - `@Injectable({ providedIn: "root" })`.
40
33
  - DbContext 는 앱별로 정의 (예: `@adtek/db-main` 의 `MainDbContext`). 스키마 정의는 [orm.md](./orm.md).
41
- - 기본 메서드는 `connectAsync` (트랜잭션 포함). `connectWithoutTransAsync` 는 initialize 등 트랜잭션 안에서 동작하지 않는 작업 전용 헬퍼.
34
+ - 진입 메서드는 `connectAsync` (트랜잭션 포함).
42
35
  - 콜백의 반환값이 그대로 메서드의 반환값이 됨.
43
36
 
44
37
  ## 화면·프로바이더에서 쿼리를 실행하려면
@@ -54,9 +47,7 @@ const rows = await this._appOrm.connectAsync(async (db) => {
54
47
  ```
55
48
 
56
49
  - 콜백 인자 `db` 는 `MainDbContext`. 테이블·뷰 빌더와 쿼리 작성은 [orm.md](./orm.md).
57
- - 트랜잭션이 곤란한 작업(initialize 등)만 `connectWithoutTransAsync` 사용.
58
50
 
59
51
  ## 지킬 것
60
52
 
61
- - DB 옵션(`DbClass`·`connOpt`·`dbContextOpt`)은 `AppOrmProvider` 한 곳에만 두고, 화면·프로바이더는 `connectAsync`/`connectWithoutTransAsync` 만 호출. 옵션을 호출부에 흩뿌리지 않음.
62
- - 기본은 `connectAsync`. 트랜잭션 없이 돌려야 하는 명확한 이유가 있을 때만 `connectWithoutTransAsync`.
53
+ - DB 옵션(`DbClass`·`connOpt`·`dbContextOpt`)은 `AppOrmProvider` 한 곳에만 두고, 화면·프로바이더는 `connectAsync` 만 호출. 옵션을 호출부에 흩뿌리지 않음.
@@ -12,12 +12,14 @@ ORM 사용은 [client-orm.md](./client-orm.md), 이벤트 정의·발생 메커
12
12
  서버 연결·서비스·이벤트·ORM 진입점을 한 root provider 에 모음. 서비스·이벤트는 `private _xxx?` 캐시 필드 + getter 로 lazy 노출(`??=`).
13
13
 
14
14
  ```ts
15
+ export const APP_MAIN_SERVICE_KEY = "MAIN";
16
+
15
17
  @Injectable({ providedIn: "root" })
16
18
  export class AppServiceProvider {
17
19
  private readonly _sdServiceClientFactory = inject(SdServiceClientFactoryProvider);
18
20
 
19
21
  get client() {
20
- return this._sdServiceClientFactory.get("MAIN");
22
+ return this._sdServiceClientFactory.get(APP_MAIN_SERVICE_KEY);
21
23
  }
22
24
 
23
25
  private _orm?: OrmClientConnector;
@@ -30,13 +32,22 @@ export class AppServiceProvider {
30
32
  return (this._user ??= this.client.getService<UserServiceMethods>("User"));
31
33
  }
32
34
 
33
- private _authInfoEvent?: ClientEventProxy<typeof AuthInfoEvent>;
34
- get authInfoEvent(): ClientEventProxy<typeof AuthInfoEvent> {
35
- return (this._authInfoEvent ??= this.client.getEvent(AuthInfoEvent));
35
+ private _authInfoChangedEvent?: ClientEventProxy<typeof AuthInfoChangedEvent>;
36
+ get authInfoChangedEvent(): ClientEventProxy<typeof AuthInfoChangedEvent> {
37
+ return (this._authInfoChangedEvent ??= this.client.getEvent(AuthInfoChangedEvent));
36
38
  }
37
39
 
38
40
  async connectAsync() {
39
- await this._sdServiceClientFactory.connectAsync("MAIN");
41
+ await this._sdServiceClientFactory.connectAsync(
42
+ APP_MAIN_SERVICE_KEY,
43
+ Boolean(env("SERVER_HOST"))
44
+ ? {
45
+ host: env("SERVER_HOST"),
46
+ port: num.parseInt(env("SERVER_PORT")),
47
+ ssl: parseBoolEnv(env("SERVER_SSL")),
48
+ }
49
+ : {},
50
+ );
40
51
  }
41
52
  }
42
53
  ```
@@ -44,9 +55,9 @@ export class AppServiceProvider {
44
55
  **약속**:
45
56
 
46
57
  - `@Injectable({ providedIn: "root" })`.
47
- - `client` getter — `SdServiceClientFactoryProvider.get("MAIN")` 결과. 서비스·이벤트·ORM 의 공통 진입점.
58
+ - `client` getter — `SdServiceClientFactoryProvider.get(APP_MAIN_SERVICE_KEY)` 결과. 서비스·이벤트·ORM 의 공통 진입점. 서비스 키(`"MAIN"`)는 `client.get`·`connectAsync` 등 여러 곳에서 참조하므로 상수로 추출.
48
59
  - `orm` getter — `createOrmClientConnector(this.client)` 결과. DB 설정을 얹는 `AppOrmProvider` 가 이 위에 올라감 ([client-orm.md](./client-orm.md)).
49
- - `connectAsync()` — 앱 부트스트랩 시점에 서버 연결 수행. `addListener` 등 통신은 이 호출 이후에만 가능.
60
+ - `connectAsync()` — 앱 부트스트랩 시점에 서버 연결 수행. 클라이언트·서버를 다른 호스트로 배포할 때를 위해 env(`SERVER_HOST`·`SERVER_PORT`·`SERVER_SSL`)로 연결 옵션을 주입(미설정이면 same-origin). `env`·`num`·`parseBoolEnv` 는 `@simplysm/core-common`. `addListener` 등 통신은 이 호출 이후에만 가능.
50
61
 
51
62
  ## 부트스트랩에서 서버에 연결하려면
52
63
 
@@ -12,10 +12,10 @@
12
12
 
13
13
  ```ts
14
14
  export function useSharedSignal<K extends keyof TAppSharedData>(
15
- name: K,
15
+ dataKey: K,
16
16
  ): SharedDataHandle<TAppSharedData[K]> {
17
17
  const appSharedData = inject(AppSharedDataProvider);
18
- return appSharedData.getHandle(name);
18
+ return appSharedData.getHandle(dataKey);
19
19
  }
20
20
 
21
21
  @Injectable({ providedIn: "root" })
@@ -65,7 +65,23 @@ export interface ISharedCustomer extends SharedDataBase<number> {
65
65
 
66
66
  - `@Injectable({ providedIn: "root" })` 사용, `SdSharedDataProvider<TAppSharedData>` 를 상속.
67
67
  - 등록은 `override initialize()` 안에서 `this.register(name, opts)` 호출로 수행.
68
- - `useSharedSignal<K>(name)` 헬퍼를 함께 export — 컴포넌트는 inject 없이 이름만으로 접근.
68
+ - `useSharedSignal<K>(dataKey)` 헬퍼를 함께 export — 컴포넌트는 inject 없이 이름만으로 접근.
69
+
70
+ ## 부트스트랩에 연결하려면 (새 앱 1회성)
71
+
72
+ 라이브러리 공유데이터 컨트롤(`sd-shared-data-select` · `sd-shared-data-select-list`)은 base 토큰 `SdSharedDataProvider` 를 inject 하므로, 부트스트랩 providers 에 앱 provider 를 그 토큰의 별칭으로 등록.
73
+
74
+ ```ts
75
+ // 앱 부트스트랩 (main.ts)
76
+ bootstrapApplication(AppRoot, {
77
+ providers: [
78
+ // ...
79
+ { provide: SdSharedDataProvider, useExisting: AppSharedDataProvider },
80
+ ],
81
+ });
82
+ ```
83
+
84
+ - 이 별칭이 없으면 컨트롤이 데이터가 등록된 `AppSharedDataProvider` 가 아니라 빈 base 인스턴스를 잡아, 공유데이터 select 컨트롤에 항목이 표시되지 않음.
69
85
 
70
86
  ## 마스터 데이터 항목을 추가하려면
71
87
 
@@ -0,0 +1,89 @@
1
+ # 클라이언트 SSG(빌드 타임 프리렌더) 매뉴얼
2
+
3
+ 공개 페이지의 검색 노출(SEO)이 필요한 클라이언트를 만들 때 참조. 지정한 라우트의 HTML 을 프로덕션 빌드 시점에 미리 생성해, 검색봇이 완성된 HTML 을 받게 함.
4
+
5
+ 동작 개요:
6
+
7
+ ```
8
+ 빌드(pnpm build):
9
+ browser 번들 (기존 SPA 와 동일)
10
+ + src/main.server.ts 진입의 node 번들 → 라우트별 <경로>/index.html 생성
11
+ + SPA 셸을 index.csr.html 로 별도 보존
12
+
13
+ 운영(service-server 정적 서빙):
14
+ /q (프리렌더됨) → 완성된 HTML 즉시 응답 → browser 번들이 hydration 후 SPA 동작
15
+ /r/abc (프리렌더 안 됨) → index.csr.html 셸 + 브라우저에서 데이터 로드 (기존 SPA 와 동일)
16
+ ```
17
+
18
+ - dev/watch 모드에는 적용되지 않음 — 개발은 기존 SPA dev 서버 그대로, SSG 동작 확인은 프로덕션 빌드로.
19
+ - 라우트 1건이라도 렌더 실패하면 빌드 전체 실패.
20
+
21
+ ## SSG 클라이언트를 셋업하려면
22
+
23
+ `sd-cli init` 으로 워크스페이스를 새로 만들 때는 클라이언트별 "SSG 를 쓸까요?" 질문에서 켜면 아래 절차가 자동 반영됨 (기본 `prerender: ["/"]`). 아래는 기존 클라이언트에 수동으로 추가하는 절차.
24
+
25
+ ① `sd.config.ts` 의 client 패키지에 `prerender` 로 라우트 목록 지정:
26
+
27
+ ```ts
28
+ "client-portal": {
29
+ target: "client",
30
+ server: "server",
31
+ prerender: ["/", "/about"],
32
+ },
33
+ ```
34
+
35
+ ② `@angular/platform-server` 의존성 추가 (클라이언트 패키지 `package.json`, Angular 패키지들과 동일 버전).
36
+
37
+ ③ 라우팅을 path 방식으로 — 검색엔진은 hash(`/#/about`) 뒤를 별개 페이지로 보지 않으므로 `withHashLocation()` 제거:
38
+
39
+ ```ts
40
+ provideRouter(routes), // withHashLocation() 없이
41
+ ```
42
+
43
+ ④ 앱 설정에 hydration 추가:
44
+
45
+ ```ts
46
+ import { provideClientHydration } from "@angular/platform-browser";
47
+
48
+ providers: [..., provideClientHydration()],
49
+ ```
50
+
51
+ ⑤ `src/main.server.ts` 작성 (Angular 표준 — 서버 부트스트랩 default export):
52
+
53
+ ```ts
54
+ import { bootstrapApplication, type BootstrapContext } from "@angular/platform-browser";
55
+ import { provideServerRendering } from "@angular/platform-server";
56
+ import { AppRoot } from "./app.root";
57
+ import { appProviders } from "./app.providers"; // main.ts 와 공유하는 providers
58
+
59
+ const bootstrap = (context: BootstrapContext) =>
60
+ bootstrapApplication(AppRoot, { providers: [...appProviders, provideServerRendering()] }, context);
61
+
62
+ export default bootstrap;
63
+ ```
64
+
65
+ ## 프리렌더 화면 코드를 SSR-safe 하게 작성하려면
66
+
67
+ 프리렌더 라우트에서 쓰는 코드는 빌드 시 node 에서 한 번 실행됨. 컴포넌트 생성·초기화 시점에 브라우저 전역(`window`/`document`/`localStorage`)을 직접 만지면 빌드가 실패함 (빌드 에러로 즉시 드러남).
68
+
69
+ - 이벤트 핸들러 안의 브라우저 API 는 무방 — 서버에서는 실행되지 않음.
70
+ - 생성자·`effect()` 등 초기화 경로의 브라우저 API 는 가드:
71
+
72
+ ```ts
73
+ import { inject, PLATFORM_ID } from "@angular/core";
74
+ import { isPlatformBrowser } from "@angular/common";
75
+
76
+ if (isPlatformBrowser(inject(PLATFORM_ID))) {
77
+ // 브라우저 전용 초기화
78
+ }
79
+ ```
80
+
81
+ - 또는 `afterNextRender(() => { ... })` — 브라우저에서만 실행됨.
82
+ - `@simplysm/angular` 부트스트랩 경로(provideSdAngular·테마·busy 등)는 이미 가드되어 그대로 사용 가능. 그 외 컴포넌트는 프리렌더 화면에 쓸 때 개별 확인 — 빌드가 깨지면 그 컴포넌트의 초기화 경로를 가드.
83
+
84
+ ## 지킬 것
85
+
86
+ - SEO 대상 페이지는 빌드 시점에 URL 이 고정된 라우트만 가능 — 동적 URL(예: `/r/:id`) 은 프리렌더 불가, 셸 폴백으로 동작하며 검색 노출 안 됨. 동적 페이지 SEO 가 필요해지면 SSG 가 아니라 SSR 검토.
87
+ - `prerender` 라우트는 `"/"` 로 시작.
88
+ - 프리렌더 결과는 빌드 시점 데이터로 고정 — 콘텐츠 갱신은 재빌드·재배포로 반영됨을 전제로 라우트를 선정.
89
+ - 기존 SPA 클라이언트에 영향 없음 — `prerender` 미설정 클라이언트는 동작이 바뀌지 않으므로, SEO 필요 페이지를 별도 클라이언트 패키지로 분리하는 구성을 우선 검토.
@@ -203,6 +203,7 @@ X 함수에 캐시 도입 검토 중. 기존 의존성 확인 결과 lru-cache
203
203
  | 명령·승인 | "고쳐줘", "응 그렇게", "적용해" | "해줘", "응", "ㅇㅇ", "진행" | 도구 호출 (실행) |
204
204
  | 의문·요청 (원인·방법·가능성) | "왜 ~?", "어떻게 ~안될까?", "~방법 있어?" | "왜", "어떻게", "?" | [문제 발생 시](#문제-발생-시) 워크플로 따름 |
205
205
  | 제안·아이디어 | "X 하면 어때?", "Y 가 좋을듯", "X 할 생각을 해", "X 검토해봐" | "어때", "좋을듯", "할까?", "생각을 해", "검토해봐", "고려해" | 텍스트 응답 (검토·대안 제시) → 합의 후 실행 |
206
+ | 추측·가능성·소망 표명 | "~나오지 않을까?", "~수도 있고", "~면 좋겠다" | "않을까", "수도", "~듯", "~면 좋겠" | 텍스트 응답 (확정 아님 — 합의 전 결정 근거 불가) |
206
207
  | 문제 기술·현상 보고 | "이거 안돼", "버그 있어" | "안돼", "버그" | [문제 발생 시](#문제-발생-시) 워크플로 따름 |
207
208
  | 위치·맥락 정보 단독 | "X 파일에..", "Y 섹션쪽에.." | "X에", "Y쪽에" | 의도 확인 질문 또는 다음 발언 대기 |
208
209
 
@@ -1,4 +1,5 @@
1
1
  {
2
+ "defaultShell": "powershell",
2
3
  "hooks": {
3
4
  "PreToolUse": [
4
5
  {
@@ -44,9 +44,8 @@ spec 본문 수정이 필요한 경우(인라인 `[OPEN]` 해소·섹션 `[OPEN]
44
44
 
45
45
  1. **sd-spec SKILL.md Read** — 룰 인지: 부분 수정 인라인 경로·신뢰도 표기·형식 준수(§ 작성법 Read)·이름 기반 참조 cascade.
46
46
  2. **sd-spec "부분 수정 (인라인)" 경로로 Edit** — 정밀 반영(도메인 답이 이미 확정된 인라인 `[OPEN]`·6단계 결정 반영)은 바로 Edit, 초안·판단 필요분(섹션 `[OPEN]` 초안·구조 변경·모순 정정)은 진단·초안 제시 → 합의 후 Edit. 수정분은 사후 보고.
47
- 3. **의존 추적** — 수정된 § 항목을 의존으로 가진 §4.x/§5.x/§6.x 를 이름 기반 참조 cascade(`[카테고리.이름]` grep 전수 추적) + 관련 섹션 한 줄로 탐색. 본 작업 단위 자신은 제외 (작업 중).
48
- 4. **마커 제거** — 의존 §4.x/§5.x/§6.x 헤더의 `[구현]` 마커 제거 (이전 구현 무효화).
49
- - 영향 단위에 번호를 매겨 사용자에게 모아서 "맞나요?" 질문 — 전체 동의 → 일괄 제거 / 일부 유지 지목 → 지목 항목 마커 유지.
47
+ 3. **의존 추적** — sd-spec "§4~6 `[구현]` 마커 제거" 절의 의존 식별 규칙으로 수정된 § 의존으로 가진 §4.x/§5.x/§6.x 를 전수 추적. 본 작업 단위 자신은 제외 (작업 중).
48
+ 4. **마커 제거** — 추적된 §4.x/§5.x/§6.x 헤더의 `[구현]` 마커 제거 (이전 구현 무효화). 합의 절차도 같은 절 따름.
50
49
 
51
50
  본 작업 단위(§4.x/§5.x/§6.x 자신)의 헤더에는 9단계에서 `[구현]` 마커를 부착.
52
51
 
@@ -245,6 +244,8 @@ subagent 출력을 받은 후 분류별로 처리:
245
244
 
246
245
  ### 8단계: 시연 검증
247
246
 
247
+ **시연 스킵 조건**: 대상 단위에 세션 시작 시점부터 이미 `[구현]` 마커가 있었고(이전 시연 완료) + 이번 세션이 코드 파일을 하나도 생성·수정하지 않음(5단계 편집 0건 + 6단계 (a) 코드 위반 0건) → 본 단계 스킵 후 9단계 진입. 마커가 없던 단위(첫 검증)는 코드 변경이 없어도 시연 수행.
248
+
248
249
  사용자에게 요청: "**dev 서버**를 실행하고 접속 주소를 알려달라."
249
250
 
250
251
  **dev 서버·외부 프로세스 자체 실행 금지**: assistant 가 직접 `ng serve`·`npm run dev`·`vite`·`pnpm dev` 등으로 dev 서버를 띄우지 않음. 사용자가 띄워 주소를 회신할 때까지 대기. 사용자가 본 단계의 스킵을 지시하거나 시연 불가 환경이 명시되면 본 단계 스킵 후 9단계 진입.