@zalkera/client 0.17.0 → 0.18.0
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.
- package/bin/validate-storefront.mjs +346 -12
- package/contracts/aeo-surface-guarantees.json +4 -4
- package/dist/index.cjs +4 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -28
- package/dist/index.d.ts +40 -28
- package/dist/index.js +4 -9
- package/dist/index.js.map +1 -1
- package/llms.txt +39 -24
- package/package.json +2 -1
|
@@ -8,16 +8,26 @@
|
|
|
8
8
|
* 검사:
|
|
9
9
|
* E1 "use client" 파일에서 @zalkera/client 를 값으로 import → baseUrl/토큰 노출 위험.
|
|
10
10
|
* E2 "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera) import → 같은 위험.
|
|
11
|
+
* E3 **값**이 새는 형태 — `NEXT_PUBLIC_` 접두가 붙은 시크릿(소스·`.env*` 양쪽) · 소스에 박힌
|
|
12
|
+
* 스토어프론트 키 리터럴(`oqsk_…`). E1·E2 가 모듈만 보고 값은 아무도 안 보던 자리다.
|
|
11
13
|
* W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
|
|
12
14
|
* C1 ISR-우선 게이트(memo31 §0-12) — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
|
|
13
15
|
* codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
|
|
14
16
|
* 정당화된 예외는 파일에 `// zalkera-allow-dynamic: <이유>` 마커를 두면 경고로 강등된다.
|
|
15
17
|
* C1b layout 폭발반경 게이트 — layout/template 이 **import 로 도달하는 서버 모듈**에서 동적 API 를
|
|
16
18
|
* 쓰면 실패. C1 과 달리 파일 하나가 아니라 import 그래프를 본다.
|
|
19
|
+
* C1p·C1pa·X1p **Pages Router**(`pages/**`) — 위 규칙들은 첫 세그먼트가 `app` 이어야 도는 App
|
|
20
|
+
* Router 전용이었고, Pages Router 레포에서는 서빙 책임 축이 통째로 안 재진 채 통과했다.
|
|
21
|
+
* C1p=`getServerSideProps` · C1pa=`_app`/`_document` 의 `getInitialProps`(사이트 전체 폭발반경) ·
|
|
22
|
+
* X1p=`pages/api` 가드 **유무**(위치는 못 잰다 — 그래서 경고다). 자세한 경계는 P 절 주석.
|
|
17
23
|
* S1 error — .tsx 에서 죽은 레거시 토큰 `var(--oneq-` 참조. → `bg-primary`/`text-primary` 유틸리티로.
|
|
18
24
|
* S2 warning — JSX 인라인 `style={{`(CSS 변수 주입 `style={{"--` 은 면제). 스타일은 유틸리티 클래스로.
|
|
19
25
|
* S3 error — src/app/globals.css 부재 또는 root layout 이 그걸 import 하지 않음(배선 회귀 방지).
|
|
20
|
-
* S4 warning — className
|
|
26
|
+
* S4 warning — className 에 박힌 **색 리터럴**. 색을 싣는 유틸리티 전량(`bg`·`text`·`border`·`ring`·
|
|
27
|
+
* `outline`·`divide`·`placeholder`·`caret`·`accent`·`decoration`·`fill`·`stroke`·그라디언트
|
|
28
|
+
* `from`/`via`/`to`)에 hex·색 함수(`rgb(`·`hsl(`·`oklch(`…)·색 이름이 오면 잡는다. 값이 색이
|
|
29
|
+
* 아니면(`border-[2px]`·`to-[50%]`) 안 잡고, `var(…)`·`theme(…)` 경유는 면제다. 테넌트 색은
|
|
30
|
+
* 토큰 경유가 규약. 초판이 `-[#` 세 형태만 봐서 18가지가 새던 자리 — [COLOR_UTILITY_ROOTS] 참고.
|
|
21
31
|
* S5 warning — src/app/globals.css 외의 .css 파일 존재(단일 CSS 원칙).
|
|
22
32
|
* C2 error — 섹션 렌더러 switch 가 계약(@zalkera/client SECTION_CONTRACT)을 덮지 못함. 계약에 있는
|
|
23
33
|
* 타입을 렌더러가 모르면 그 섹션은 **조용히 안 그려진다**(미지 타입 스킵이 계약이라 에러도 안 난다).
|
|
@@ -115,8 +125,11 @@ function resolveSourceRoot() {
|
|
|
115
125
|
const given = process.argv.slice(2).find((a) => !a.startsWith("-"));
|
|
116
126
|
if (!given) return "./src";
|
|
117
127
|
// 이미 소스 루트면 그대로. 아니면 그 아래 src/ 가 소스 루트인지 본다.
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
// ⚠ `pages` 도 함께 본다 — Pages Router 만 쓰는 레포(`app/` 없음)에서 이 판정이 레포 루트를
|
|
129
|
+
// 소스 루트로 오인했고, 그러면 `pages/` 를 찾는 좌표가 한 칸씩 어긋난다.
|
|
130
|
+
const isSourceRoot = (d) => existsSync(join(d, "app")) || existsSync(join(d, "pages"));
|
|
131
|
+
if (isSourceRoot(given)) return given;
|
|
132
|
+
if (isSourceRoot(join(given, "src"))) return join(given, "src");
|
|
120
133
|
return given; // 둘 다 아니면 준 대로 두고 아래 존재 검사가 말하게 한다
|
|
121
134
|
}
|
|
122
135
|
|
|
@@ -132,6 +145,122 @@ const FOREIGN_TOKEN_CLASSES = [
|
|
|
132
145
|
"text-card-foreground", "text-popover-foreground", "text-muted-foreground", "text-accent-foreground",
|
|
133
146
|
"text-destructive", "ring-ring", "border-input",
|
|
134
147
|
];
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* ── S4 의 어휘: 색을 싣는 유틸리티 · 색으로 읽히는 값 ────────────────────────────
|
|
151
|
+
*
|
|
152
|
+
* ⚠ **초판은 `bg-[#`·`text-[#`·`border-[#` 세 형태만 봤다.** 실측으로 18가지가 그대로 빠져나갔다
|
|
153
|
+
* (2026-08-01): 색 함수(`bg-[rgb(255,0,0)]`·`text-[hsl(…)]`·`bg-[oklch(…)]`), 색 이름(`bg-[red]`),
|
|
154
|
+
* 그리고 **접두가 다른 색 유틸리티 전부** — 그라디언트(`from-`·`via-`·`to-`), 링(`ring-`),
|
|
155
|
+
* 아웃라인·구분선·플레이스홀더·캐럿·강조(`outline-`·`divide-`·`placeholder-`·`caret-`·`accent-`),
|
|
156
|
+
* SVG(`fill-`·`stroke-`), 밑줄(`decoration-`). `bg-[#…]` 만 막고 `from-[#…]` 을 열어 두는 것은
|
|
157
|
+
* 규칙이 아니라 우연이다 — **`text-[#f00]` 를 고치라고 하면 `text-[rgb(255,0,0)]` 이 나온다.**
|
|
158
|
+
*
|
|
159
|
+
* 판정을 **접두 하나로** 하지 않고 접두 + **값의 모양** 둘로 하는 이유: 같은 접두가 색이 아닌 값도
|
|
160
|
+
* 싣는다. `border-[2px]`(두께) · `text-[14px]`(글자 크기) · `to-[50%]`(그라디언트 정지 위치) ·
|
|
161
|
+
* `stroke-[1.5]`(선 두께) · `bg-[url('/x.png')]`(배경 이미지) · `bg-[length:200px]`. 접두만 보면
|
|
162
|
+
* 이 전부가 빨개지고, 그러면 사람이 규칙을 끄지 정상 코드를 고치지 않는다.
|
|
163
|
+
*
|
|
164
|
+
* ⚠ **`shadow-` 는 일부러 뺐다** — 그림자 값(`shadow-[0_2px_8px_rgba(0,0,0,.1)]`)은 거의 항상 중립
|
|
165
|
+
* 검정이고 브랜드 색 표면이 아니다. 넣으면 정상 코드가 대량으로 빨개진다. 즉 이 축은 **안 잰다**.
|
|
166
|
+
*/
|
|
167
|
+
const COLOR_UTILITY_ROOTS = new Set([
|
|
168
|
+
"bg", "text", "border", "ring", "outline", "decoration", "divide",
|
|
169
|
+
"placeholder", "caret", "accent", "fill", "stroke", "from", "via", "to",
|
|
170
|
+
]);
|
|
171
|
+
/** 색 함수. `color-mix(` 까지 — Tailwind 임의값 안에서 전부 유효하다. */
|
|
172
|
+
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\s*\(/;
|
|
173
|
+
/** `#rgb`~`#rrggbbaa`. 뒤에 영숫자가 더 붙으면 색이 아니다(해시 문자열 등). */
|
|
174
|
+
const HEX_COLOR = /#[0-9a-fA-F]{3,8}(?![0-9a-zA-Z])/;
|
|
175
|
+
/**
|
|
176
|
+
* CSS 색 이름 — **전수가 아니다**(표준은 148개). 브랜드색으로 실제로 쓰일 만한 것만 추렸다.
|
|
177
|
+
* 빠진 이름은 이 규칙이 **못 잡는다**; 전수를 실어도 되지만 그 목록은 사람이 관리하는 목록이 되고,
|
|
178
|
+
* 여기서 얻는 것보다 드리프트 비용이 크다. `transparent`·`currentColor`·`inherit` 은 색 결정이
|
|
179
|
+
* 아니라 위임이라 일부러 뺐다(Tailwind 에 `bg-transparent`·`text-current` 가 이미 있다).
|
|
180
|
+
*/
|
|
181
|
+
const NAMED_COLORS = new Set([
|
|
182
|
+
"red", "blue", "green", "black", "white", "gray", "grey", "orange", "purple", "pink", "yellow",
|
|
183
|
+
"brown", "cyan", "magenta", "navy", "teal", "olive", "maroon", "lime", "aqua", "silver", "gold",
|
|
184
|
+
"indigo", "violet", "crimson", "coral", "salmon", "tomato", "khaki", "beige", "ivory", "azure",
|
|
185
|
+
"plum", "orchid", "turquoise", "lavender", "tan", "wheat", "snow", "skyblue", "hotpink",
|
|
186
|
+
"darkblue", "darkred", "darkgreen", "lightblue", "lightgray", "lightgrey", "midnightblue",
|
|
187
|
+
"steelblue", "slategray", "slategrey", "firebrick", "forestgreen", "seagreen", "royalblue",
|
|
188
|
+
"dodgerblue", "chocolate", "sienna", "peru",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 임의값 클래스 토큰 `[variant:]*<utility>-[<value>]`.
|
|
193
|
+
* 앞은 **단어·하이픈이 아닌 문자**로 끊어 식별자 중간(`my-bg-[…]`)에 걸리지 않게 한다. 변형 접두
|
|
194
|
+
* (`hover:`·`md:`·`dark:`)는 몇 겹이든 흡수한다 — `hover:bg-[#f00]` 도 같은 위반이다.
|
|
195
|
+
*/
|
|
196
|
+
const ARBITRARY_CLASS = /(?:^|[^\w-])(?:[\w.-]+:)*!?(-?[a-z][a-zA-Z0-9-]*)-\[([^\]\s]*)\]/g;
|
|
197
|
+
/** 임의 **속성** 문법 `[color:#f00]`·`[background-color:red]` — 유틸리티 접두 없이 색을 박는 우회로. */
|
|
198
|
+
const ARBITRARY_COLOR_PROPERTY = /(?:^|[^\w-])(?:[\w.-]+:)*\[([a-zA-Z-]*color)\s*:\s*([^\]\s]*)\]/g;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 이 임의값이 **색 리터럴**인가. `var(…)`·`theme(…)` 은 토큰 경유라 규약이 권하는 길이고 면제다
|
|
202
|
+
* (`bg-[color:var(--brand)]` 는 위반이 아니다).
|
|
203
|
+
*/
|
|
204
|
+
function isColorLiteral(value) {
|
|
205
|
+
if (/var\(|theme\(/.test(value)) return false;
|
|
206
|
+
if (HEX_COLOR.test(value) || COLOR_FUNCTION.test(value)) return true;
|
|
207
|
+
// `color:red` 처럼 타입 힌트가 앞에 붙은 형태에서 값만 떼어 본다.
|
|
208
|
+
return NAMED_COLORS.has(value.replace(/^[a-zA-Z-]+:/, "").toLowerCase());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 한 파일에서 색 리터럴을 박은 클래스 토큰 전량(중복 제거·정렬). */
|
|
212
|
+
function hardcodedColorClasses(text) {
|
|
213
|
+
const hits = new Set();
|
|
214
|
+
for (const m of text.matchAll(ARBITRARY_CLASS)) {
|
|
215
|
+
const [, utility, value] = m;
|
|
216
|
+
if (!COLOR_UTILITY_ROOTS.has(utility.split("-")[0])) continue;
|
|
217
|
+
if (isColorLiteral(value)) hits.add(`${utility}-[${value}]`);
|
|
218
|
+
}
|
|
219
|
+
for (const m of text.matchAll(ARBITRARY_COLOR_PROPERTY)) {
|
|
220
|
+
const [, property, value] = m;
|
|
221
|
+
if (isColorLiteral(value)) hits.add(`[${property}:${value}]`);
|
|
222
|
+
}
|
|
223
|
+
return [...hits].sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* ── E3 : 시크릿이 브라우저 번들에 실린다 ────────────────────────────────────────
|
|
228
|
+
*
|
|
229
|
+
* E1·E2 는 **모듈**이 새는 형태(클라이언트 파일이 서버 클라이언트를 물었다)만 봤다. 정작 새는 것이
|
|
230
|
+
* **값**인 형태는 아무도 안 재고 있었다. `.env.example` 은 그 규약을 문장으로 적어 뒀는데
|
|
231
|
+
* ("`ZALKERA_STOREFRONT_KEY` 는 서버 전용 — `NEXT_PUBLIC_` 접두사 절대 금지") 기계는 한 번도 안 봤다.
|
|
232
|
+
*
|
|
233
|
+
* 이 축이 **사실 판정**인 이유: `NEXT_PUBLIC_` 은 취향이 아니라 Next 의 계약이다 — 그 접두가 붙은
|
|
234
|
+
* 환경변수는 빌드 시 값이 클라이언트 번들에 **문자 그대로 치환**된다. 그래서 "노출될 수 있다"가 아니라
|
|
235
|
+
* **이미 노출됐다**. 오탐 여지가 없으므로 X 축(이름을 재는 교차오리진 가드)과 달리 `servingSink` 다.
|
|
236
|
+
*
|
|
237
|
+
* ⚠ **못 재는 것**: 이름이 시크릿임을 안 밝힌 값(`NEXT_PUBLIC_FOO=oqsk_…`)은 이름만으로는 못 가른다.
|
|
238
|
+
* 그래서 **값 쪽 잣대**를 하나 더 둔다 — 소스에 박힌 `oqsk_` 리터럴. 둘 다 피한 유출은 못 잡는다.
|
|
239
|
+
*/
|
|
240
|
+
/** 서버 전용임이 이름에 드러난 환경변수에 `NEXT_PUBLIC_` 이 붙은 형태. */
|
|
241
|
+
const PUBLIC_SECRET_ENV = /\bNEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PRIVATE_KEY|STOREFRONT_KEY)[A-Z0-9_]*\b/g;
|
|
242
|
+
/** 스토어프론트 서버 시크릿 키 리터럴(memo78 `oqsk_…`). 소스에 박히면 그 자체로 유출이다. */
|
|
243
|
+
const STOREFRONT_KEY_LITERAL = /\boqsk_[A-Za-z0-9_-]{8,}/g;
|
|
244
|
+
|
|
245
|
+
/** 이 텍스트에서 발견된 시크릿 노출 형태의 사람용 사유 문자열들. */
|
|
246
|
+
function secretExposures(text) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const name of new Set(text.match(PUBLIC_SECRET_ENV) ?? [])) {
|
|
249
|
+
out.push(
|
|
250
|
+
`${name} — NEXT_PUBLIC_ 접두가 붙은 값은 Next 가 브라우저 번들에 그대로 박습니다. ` +
|
|
251
|
+
`서버 전용 시크릿이면 접두를 떼고 서버(route handler·RSC)에서만 읽으세요.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (STOREFRONT_KEY_LITERAL.test(text)) {
|
|
255
|
+
STOREFRONT_KEY_LITERAL.lastIndex = 0; // `g` 정규식의 상태를 다음 파일로 흘리지 않는다.
|
|
256
|
+
out.push(
|
|
257
|
+
`스토어프론트 서버 시크릿 키(oqsk_…)가 소스에 박혀 있습니다 — 소스는 zip 으로 유통되고 ` +
|
|
258
|
+
`레포에 남습니다. 값은 환경변수(ZALKERA_STOREFRONT_KEY)로만 넣고 즉시 재발급하세요.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
135
264
|
const errors = [];
|
|
136
265
|
const warnings = [];
|
|
137
266
|
const layoutFiles = [];
|
|
@@ -206,8 +335,19 @@ function servingSink() {
|
|
|
206
335
|
* 양쪽으로 다 틀린다(심의 실측):
|
|
207
336
|
* · **거짓 음성** — 라우트 안에 동명 `function assertSameOrigin(){return null}` 을 선언하면 통과한다.
|
|
208
337
|
* 인자로 엉뚱한 `Request` 를 넘겨도 통과한다. 즉 **통과가 안전을 뜻하지 않는다.**
|
|
209
|
-
* · **거짓
|
|
210
|
-
*
|
|
338
|
+
* · **거짓 양성 가능성** — 상용 서빙 중인 두 사이트(bix·credium)가 **둘 다 걸린다**(실측 2/2).
|
|
339
|
+
* 자기 방식으로 막은 라우트도 우리 심볼을 안 쓰면 걸린다는 뜻이다.
|
|
340
|
+
*
|
|
341
|
+
* ⚠ **초판 주석 정정(2026-08-01 보안 심의).** 여기 원래 *"둘 다 reCAPTCHA + rate limit 으로 같은 위협을
|
|
342
|
+
* 이미 막고 있다"* 고 적었는데 **재 보지 않고 쓴 문장이었고 거짓이었다.** 심의가 서버를 띄워 실측하니
|
|
343
|
+
* bix `/api/contact` 는 교차 오리진 JSON·교차사이트 form 을 **둘 다 200 으로 접수**했고, rate limit 은
|
|
344
|
+
* `x-forwarded-for` 위조로 완전 우회됐으며, reCAPTCHA 는 `.env.example` 기본값이 `false` 였다.
|
|
345
|
+
* **그 사이트는 실제로 안 막고 있었다.**
|
|
346
|
+
*
|
|
347
|
+
* 그래도 이 축을 관문에서 빼는 판단은 유지한다 — 근거가 "다른 수단으로 막고 있다"가 아니라
|
|
348
|
+
* **"이 검사가 안전을 재지 못한다"** 이기 때문이다(위 거짓 음성). 통과해도 안전하지 않은 검사를
|
|
349
|
+
* 관문에 놓으면 안전하다는 착각만 판다. 다만 **막지 않는 대신 반드시 보여야 한다** —
|
|
350
|
+
* 업로드·검수 결과에 이 경고를 띄우는 것이 이 결정의 나머지 절반이고, 그것 없이는 방치다.
|
|
211
351
|
*
|
|
212
352
|
* 통과가 안전을 뜻하지 않고 실패가 위험을 뜻하지 않는 검사를 관문에 놓으면, 얻는 것은 심리적 안심뿐이고
|
|
213
353
|
* 잃는 것은 신뢰다 — 게이트의 첫 동작이 **상용 전량 중단**이 된다.
|
|
@@ -965,6 +1105,9 @@ function checkContentContract() {
|
|
|
965
1105
|
// 필수 참조를 `requiredRefs`(이제 빈 배열)에서 이 키로 옮겼는데 이 검사기는 옛 키만 읽고 있었다 —
|
|
966
1106
|
// **참조가 하나도 없는 섹션이 통과했다**(실측). N5 가 존재 이유로 삼는 바로 그 결함이 무검출이었다.
|
|
967
1107
|
// 팩 게이트(`pack-preset.mjs`)는 anyOf 를 집행하고 있어 **어휘 사본 둘이 갈라진 상태**였다.
|
|
1108
|
+
// ⚠ rev 6 에서 그 두 타입이 어휘에서 삭제돼 **오늘 이 축을 쓰는 타입은 0 이다**(memo142).
|
|
1109
|
+
// 코드를 남기는 것은 계약 기계를 유지하기 위해서다 — 여기를 지우면 참조 필수 타입이 다시
|
|
1110
|
+
// 생기는 날 같은 무검출이 재발한다(그것이 이 주석이 기록하는 사고다).
|
|
968
1111
|
for (const group of spec?.requiredRefsAnyOf ?? []) {
|
|
969
1112
|
if (!group.some(isFilled)) {
|
|
970
1113
|
const names = group.map(sourceKeyOf).join(" 또는 ");
|
|
@@ -1044,8 +1187,10 @@ function check(file) {
|
|
|
1044
1187
|
}
|
|
1045
1188
|
|
|
1046
1189
|
// S1: 죽은 레거시 토큰. globals.css @theme 로 부활한 유틸리티로 대체해야 한다.
|
|
1190
|
+
// ⚠ `.tsx` 만 보던 것을 `.jsx` 까지 넓혔다 — 타입스크립트를 안 쓰는 레포에서 같은 죽은 토큰이
|
|
1191
|
+
// 그대로 통과했다(BYO 는 JS 레포가 정상이다).
|
|
1047
1192
|
const s1 = styleSink("S1");
|
|
1048
|
-
if (s1 && /\.
|
|
1193
|
+
if (s1 && /\.[jt]sx$/.test(file) && /var\(--oneq-/.test(text)) {
|
|
1049
1194
|
s1.push(
|
|
1050
1195
|
`[S1] ${rel}: 죽은 레거시 토큰 var(--oneq-*) 참조. ` +
|
|
1051
1196
|
`--oneq-primary → text-primary/bg-primary, --oneq-bg → text-primary-foreground 유틸리티로 대체하라(globals.css @theme).`,
|
|
@@ -1066,13 +1211,25 @@ function check(file) {
|
|
|
1066
1211
|
}
|
|
1067
1212
|
|
|
1068
1213
|
// S4: className 색 하드코딩(임의값). 테넌트 색은 primary 토큰 경유가 규약이다.
|
|
1214
|
+
// 어떤 형태를 잡고 무엇을 일부러 안 잡는지는 [COLOR_UTILITY_ROOTS] 위 주석에 있다.
|
|
1069
1215
|
const s4 = styleSink("S4");
|
|
1070
|
-
if (s4
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1216
|
+
if (s4) {
|
|
1217
|
+
const hits = hardcodedColorClasses(text);
|
|
1218
|
+
if (hits.length > 0) {
|
|
1219
|
+
s4.push(
|
|
1220
|
+
`[S4] ${rel}: className 에 색 리터럴 — ${hits.slice(0, 6).join(" · ")}` +
|
|
1221
|
+
`${hits.length > 6 ? ` 외 ${hits.length - 6}건` : ""}. 브랜드색 하드코딩은 ` +
|
|
1222
|
+
`콘솔의 '말로 색 바꾸기'를 무력화한다. bg-primary 등 토큰을, 중립은 slate 스케일을, ` +
|
|
1223
|
+
`꼭 임의값이어야 하면 토큰 경유(bg-[color:var(--color-primary)])를 쓰라.`,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1075
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
// E3: 시크릿이 브라우저 번들에 실리는 형태. **사실을 잰다** — `NEXT_PUBLIC_` 접두는 Next 가
|
|
1229
|
+
// 빌드 시 값을 클라이언트 번들에 **문자 그대로 박아 넣는다**는 프레임워크 계약이라, 그 접두가
|
|
1230
|
+
// 붙은 시크릿은 "노출될 수도 있다"가 아니라 **이미 노출된 것**이다(.env.example §12 가 같은 말을
|
|
1231
|
+
// 문장으로 적어 뒀는데 검사기는 한 번도 안 봤다). 소스에 박은 키 리터럴도 같은 축이다.
|
|
1232
|
+
for (const hit of secretExposures(text)) servingSink().push(`[E3] ${rel}: ${hit}`);
|
|
1076
1233
|
}
|
|
1077
1234
|
|
|
1078
1235
|
/**
|
|
@@ -1528,6 +1685,181 @@ function checkCrossOriginGuards() {
|
|
|
1528
1685
|
}
|
|
1529
1686
|
}
|
|
1530
1687
|
|
|
1688
|
+
/*
|
|
1689
|
+
* ── P : Pages Router (`pages/**`) ───────────────────────────────────────────
|
|
1690
|
+
*
|
|
1691
|
+
* ⚠ **이 검사기는 오늘까지 App Router 만 봤다.** `isSeoPageFile`·`isLayoutFile` 은 첫 세그먼트가
|
|
1692
|
+
* `app` 이어야 하고, `checkCrossOriginGuards` 는 `join(root,"app")` 밑의 `route.ts` 만 모은다.
|
|
1693
|
+
* 그래서 Pages Router 로 짠 레포에서는 **서빙 책임 축(C 동적 렌더 · X 교차 오리진)이 통째로
|
|
1694
|
+
* 안 재졌다** — 규칙이 없는 게 아니라 좌표가 안 닿아서 `✅ 통과` 가 찍혔다. 검사기가 죽는 것보다
|
|
1695
|
+
* 나쁜 상태다: 안 재고도 재는 척했다.
|
|
1696
|
+
*
|
|
1697
|
+
* Pages Router 는 우리 본보기의 형상이 아니지만 **우리 규칙이 아니다**(요건 1 — 어휘를 강제할 수
|
|
1698
|
+
* 없다). 고객이 `npx zalkera-validate` 를 자기 소스에 돌리고, 업로드 zip 이 그 소스일 수 있다.
|
|
1699
|
+
* 서빙 책임을 지는 자리에서 안 재는 라우트가 있으면 그건 그냥 구멍이다.
|
|
1700
|
+
*
|
|
1701
|
+
* 재는 것 / 안 재는 것을 못박아 둔다:
|
|
1702
|
+
* · C1p **잰다** — `getServerSideProps` 는 그 페이지를 매 요청 SSR 로 만든다(사실). App Router 의
|
|
1703
|
+
* C1 과 같은 축·같은 마커(`zalkera-allow-dynamic`)·같은 SEO 제외 세그먼트.
|
|
1704
|
+
* · C1pa **잰다** — `_app`/`_document` 의 `getInitialProps` 는 **사이트 전체**의 정적 최적화를 끈다.
|
|
1705
|
+
* 폭발반경이 C1b(layout) 과 같아서 따로 센다.
|
|
1706
|
+
* · X1p **경고만** — `pages/api` 핸들러는 하나의 default export 가 `req.method` 로 갈라지는 형태라,
|
|
1707
|
+
* App Router 의 X1 이 재는 "가드가 본문의 첫 구문인가"를 **여기서는 못 잰다**. 호출이 있는지
|
|
1708
|
+
* **유무만** 본다. 즉 **통과가 안전을 뜻하지 않는다** — X1 과 같은 이유로 관문에 안 올린다.
|
|
1709
|
+
* · E 별도 규칙을 두지 않는다. Pages Router 의 컴포넌트 모듈은 기본적으로 브라우저에 실리지만,
|
|
1710
|
+
* Next 가 `getServerSideProps`/`getStaticProps` 안에서만 쓰인 import 를 걷어내므로 "서버
|
|
1711
|
+
* 클라이언트를 import 했다"만으로는 유출을 **판정할 수 없다**. 값 축은 E3 가 전 파일에서 잰다.
|
|
1712
|
+
*/
|
|
1713
|
+
|
|
1714
|
+
/** Pages Router 루트(`<src>/pages` 또는 레포 루트 `pages`). 없으면 null — 부재는 결함이 아니다. */
|
|
1715
|
+
function pagesRoot() {
|
|
1716
|
+
for (const cand of [join(root, "pages"), join(resolve(root, ".."), "pages")]) {
|
|
1717
|
+
try {
|
|
1718
|
+
if (statSync(cand).isDirectory()) return cand;
|
|
1719
|
+
} catch {
|
|
1720
|
+
/* 다음 후보 */
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** 디렉터리 아래 소스 파일 전량(재귀). `node_modules`·`.next` 는 건너뛴다. */
|
|
1727
|
+
function collectSourceFiles(dir, into = []) {
|
|
1728
|
+
let entries;
|
|
1729
|
+
try {
|
|
1730
|
+
entries = readdirSync(dir, {withFileTypes: true});
|
|
1731
|
+
} catch {
|
|
1732
|
+
return into;
|
|
1733
|
+
}
|
|
1734
|
+
for (const e of entries) {
|
|
1735
|
+
if (e.name === "node_modules" || e.name === ".next") continue;
|
|
1736
|
+
const full = join(dir, e.name);
|
|
1737
|
+
if (e.isDirectory()) collectSourceFiles(full, into);
|
|
1738
|
+
else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name)) into.push(full);
|
|
1739
|
+
}
|
|
1740
|
+
return into;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** `pages/` 기준 상대 세그먼트가 SEO 제외 경로인가 — C1 과 **같은 목록**을 쓴다. */
|
|
1744
|
+
function isExcludedPagesRoute(pagesDir, file) {
|
|
1745
|
+
return relative(pagesDir, file)
|
|
1746
|
+
.split(sep)
|
|
1747
|
+
.slice(0, -1)
|
|
1748
|
+
.map(normalizeSegment)
|
|
1749
|
+
.some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function checkPagesRouter() {
|
|
1753
|
+
const pagesDir = pagesRoot();
|
|
1754
|
+
if (!pagesDir) return; // Pages Router 를 안 쓴다 — 잴 것이 없다.
|
|
1755
|
+
|
|
1756
|
+
const apiDir = join(pagesDir, "api");
|
|
1757
|
+
const isApi = (file) => !relative(apiDir, file).startsWith("..");
|
|
1758
|
+
|
|
1759
|
+
for (const file of collectSourceFiles(pagesDir)) {
|
|
1760
|
+
const rel = relative(process.cwd(), file);
|
|
1761
|
+
const raw = readFileSync(file, "utf8");
|
|
1762
|
+
const text = stripComments(raw);
|
|
1763
|
+
const stem = basename(file).replace(/\.[^.]+$/, "");
|
|
1764
|
+
|
|
1765
|
+
if (isApi(file)) {
|
|
1766
|
+
// ── X1p — 가드 **유무**만. 위치는 못 잰다(위 주석).
|
|
1767
|
+
const code = stripLiterals(raw);
|
|
1768
|
+
// 파일 상단 마커로 면제. X1 과 같은 마커·같은 "첫 export 앞까지" 규칙.
|
|
1769
|
+
const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
|
|
1770
|
+
if (/\/\/\s*zalkera-allow-cross-origin:/.test(raw.slice(0, head.length || raw.length))) continue;
|
|
1771
|
+
// 비-GET 을 405 로 되돌리는 읽기 전용 라우트는 뺀다. 값 검사라 **원문**에 건다
|
|
1772
|
+
// (stripLiterals 를 거치면 `"GET"` 이 공백이 된다 — X3 가 그렇게 죽어 있었다).
|
|
1773
|
+
if (/\breq\.method\s*!==\s*["']GET["']/.test(raw)) continue;
|
|
1774
|
+
if (!/(?:\w+\s*\.\s*)?assertSameOrigin\s*\(/.test(code)) {
|
|
1775
|
+
crossOriginSink().push(
|
|
1776
|
+
`[X1p] ${rel}: pages/api 핸들러에 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 ` +
|
|
1777
|
+
`열립니다(memo118). 이 형태(default export + req.method 분기)에서는 검사기가 **가드의 ` +
|
|
1778
|
+
`위치를 못 잽니다** — 호출 유무만 봅니다. 읽기 전용이면 비-GET 을 405 로 되돌리거나 ` +
|
|
1779
|
+
`파일 상단에 \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`,
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
// X2 — 읽기 GET 면제의 전제(교차 오리진 JS 가 응답을 못 읽는다)가 CORS 헤더로 무너진다.
|
|
1783
|
+
if (/Access-Control-Allow-Origin/i.test(raw)) {
|
|
1784
|
+
crossOriginSink().push(
|
|
1785
|
+
`[X2] ${rel} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가 무너집니다(memo118 §7-2).`,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ── C1pa — `_app`/`_document` 의 getInitialProps: 사이트 전체 정적 최적화 해제.
|
|
1792
|
+
if (stem === "_app" || stem === "_document") {
|
|
1793
|
+
if (/\bgetInitialProps\b/.test(text)) {
|
|
1794
|
+
const allow = raw.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
|
|
1795
|
+
const detail =
|
|
1796
|
+
`${rel}: ${stem} 이 getInitialProps 를 씁니다 — **사이트 전 라우트**의 자동 정적 최적화가 ` +
|
|
1797
|
+
`꺼져 모든 페이지가 매 요청 SSR 이 됩니다(폭발반경이 layout 과 같습니다)`;
|
|
1798
|
+
if (allow) warnings.push(`[C1pa] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
|
|
1799
|
+
else
|
|
1800
|
+
servingSink().push(
|
|
1801
|
+
`[C1pa] ${detail}. 데이터가 필요하면 페이지별 getStaticProps(ISR)로 내리고, ` +
|
|
1802
|
+
`꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하세요.`,
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
if (stem.startsWith("_")) continue; // `_error`·`_middleware` 등 — 페이지가 아니다.
|
|
1808
|
+
|
|
1809
|
+
// ── C1p — SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제.
|
|
1810
|
+
if (isExcludedPagesRoute(pagesDir, file)) continue;
|
|
1811
|
+
const gssp =
|
|
1812
|
+
/export\s+(?:async\s+)?function\s+getServerSideProps\b/.test(text) ||
|
|
1813
|
+
/export\s+(?:const|let|var)\s+getServerSideProps\b/.test(text) ||
|
|
1814
|
+
/export\s*\{[^}]*\bgetServerSideProps\b/.test(text);
|
|
1815
|
+
if (!gssp) continue;
|
|
1816
|
+
const allow = raw.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
|
|
1817
|
+
const detail = `${rel}: SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제합니다`;
|
|
1818
|
+
if (allow) warnings.push(`[C1p] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
|
|
1819
|
+
else
|
|
1820
|
+
servingSink().push(
|
|
1821
|
+
`[C1p] ${detail}(memo31 §0-12). getStaticProps + revalidate(ISR)로 바꾸고, 실시간·개인화 값은 ` +
|
|
1822
|
+
`클라이언트에서 가져오세요. 꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하세요.`,
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/*
|
|
1828
|
+
* ── E3 의 나머지 절반 : `.env*` 파일 ─────────────────────────────────────────
|
|
1829
|
+
*
|
|
1830
|
+
* 이름을 바꿔서 새는 사고는 소스가 아니라 **환경 파일**에서 난다 — "서버에서 못 읽네" 하고
|
|
1831
|
+
* `NEXT_PUBLIC_` 을 붙이는 순간 그 값은 번들에 박힌다. zip 에 `.env` 가 섞여 오는 일도 실제로 있다.
|
|
1832
|
+
* 값은 안 본다(형태만 본다) — 검사기가 시크릿 값을 읽어 출력에 실으면 그게 또 하나의 유출이다.
|
|
1833
|
+
*/
|
|
1834
|
+
function checkEnvFiles() {
|
|
1835
|
+
const repoRoot = resolve(root, "..");
|
|
1836
|
+
let names;
|
|
1837
|
+
try {
|
|
1838
|
+
names = readdirSync(repoRoot).filter((n) => n === ".env" || n.startsWith(".env."));
|
|
1839
|
+
} catch {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
for (const name of names.sort()) {
|
|
1843
|
+
let lines;
|
|
1844
|
+
try {
|
|
1845
|
+
lines = readFileSync(join(repoRoot, name), "utf8").split("\n");
|
|
1846
|
+
} catch {
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
for (const line of lines) {
|
|
1850
|
+
if (/^\s*#/.test(line)) continue; // 주석 — `.env.example` 이 금지 규약을 문장으로 적어 뒀다.
|
|
1851
|
+
const m = line.match(PUBLIC_SECRET_ENV);
|
|
1852
|
+
PUBLIC_SECRET_ENV.lastIndex = 0;
|
|
1853
|
+
if (m) {
|
|
1854
|
+
servingSink().push(
|
|
1855
|
+
`[E3] ${name}: ${m[0]} — NEXT_PUBLIC_ 접두가 붙은 값은 브라우저 번들에 그대로 박힙니다. ` +
|
|
1856
|
+
`서버 전용 시크릿이면 접두를 떼세요(값이 이미 유통됐다면 재발급까지).`,
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1531
1863
|
try {
|
|
1532
1864
|
statSync(root);
|
|
1533
1865
|
} catch {
|
|
@@ -1542,7 +1874,9 @@ checkThemeWiring(); // S8 — L1 배선(declared 전용).
|
|
|
1542
1874
|
checkSectionCoverage();
|
|
1543
1875
|
checkContentContract(); // N1~N5 — 콘텐츠 파일 계약(선언 조건화).
|
|
1544
1876
|
checkDocCoordinates(); // D1·D2 — 문서 좌표가 실물을 가리키는가.
|
|
1545
|
-
checkCrossOriginGuards(); // X1·X2 — 교차사이트 위조 가드(memo118).
|
|
1877
|
+
checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조 가드(memo118).
|
|
1878
|
+
checkPagesRouter(); // C1p·C1pa·X1p — Pages Router 좌표(App Router 전용이던 사각).
|
|
1879
|
+
checkEnvFiles(); // E3 — `.env*` 의 NEXT_PUBLIC_ 시크릿.
|
|
1546
1880
|
|
|
1547
1881
|
/**
|
|
1548
1882
|
* `llms.txt` 운반본 드리프트 — **fail-soft**.
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
"canonicalHere": "이 파일이 정본이다. zalkera-client/llms.txt §5.1 은 같은 계약의 **사람/AI용 운반체**이고, 대조는 zalkera-client/scripts/sync-aeo-guarantees.mjs 가 기계로 센다. 채널이 늘어도 사본을 늘리지 않는다 — AGENTS.md·콘솔 문서·MCP 는 참조 링크만 든다(memo119 §5-13).",
|
|
7
7
|
"floorNotCeiling": "보장은 바닥이지 천장이 아니다. 이 표를 못 채우는 템플릿도 만들 수 있고 적재될 수 있다 — 못 거는 것은 **그 카테고리 간판**뿐이고, 그것도 결함 판정이 아니라 진열 축의 사실 판정이다(§4.1·오너 정정 A).",
|
|
8
8
|
"whereTheWeightIs": "보장의 무게는 **라우트**에 있다. 현행 보장(Product·Organization·BlogPosting·BreadcrumbList)은 라우트·렌더러의 속성이고, 섹션이 직접 산출하는 것은 FAQPage 하나다. **섹션 어휘를 0개 쓰는 사이트도 라우트 보장은 똑같이 받는다**(§1.0-a).",
|
|
9
|
-
"verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 storefront-template/scripts/check-aeo-surfaces.mjs 이고, 실행 자리는 promote 전 스모크 개시다.",
|
|
9
|
+
"verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 storefront-template/scripts/check-aeo-surfaces.mjs 이고, 실행 자리는 promote 전 스모크 개시다. **측정 재료의 출처**(memo142): 목록형 표면의 원소는 업무 데이터에서 온다 — 팩 시드는 상품·갈래를 만들지 않으므로, 시험 테넌트에 파트너 API 로 적재한 뒤 크롤한다. 빈 테넌트에서 나온 원소 0개짜리 통과는 형상만 본 가짜 PASS 다(COMMERCE unblockedNote 의 실측이 그 사례다).",
|
|
10
10
|
"requiredVsPlanned": "`required` 는 **이 rev 가 지금 강제하는 것**이고, `planned` 는 같은 카테고리의 목표 표면 중 **아직 우리 본보기도 못 내는 것**이다. planned 는 게이트가 아니라 T6 작업 지시서이고, 검사기는 이를 실패가 아니라 PLANNED_MISSING 으로 보고한다. `realized` 는 **그 지시가 실제로 이행됐는가**를 적는 별개 축이다 — 이행됐다고 자동으로 required 가 되지는 않는다. required 로 올리는 것은 이 표의 rev 상향이고, 그 시점은 **이행분이 개시된 산출물에 실제로 나타나는 발행 단위**다 — 코드 병합이 아니다(판정 지점이 산출물이므로 잣대의 상향 시점도 산출물이어야 한다. 병합만으로 표를 올리면 아직 재빌드 안 된 팩·구 SDK 로 빌드된 팩이 통과 못 할 요구를 받는다). rev 2 가 그 실례다: `openingHours` 는 저장 자리(백엔드 컬럼)만 섰고 `@zalkera/client` 0.9.0 미발행·템플릿 소비 0 이라, rev 2 는 T6 병합이 아니라 **client 발행 + 본보기 팩 재빌드**가 끝난 단위에서만 설 수 있다. 이 분리가 없으면 둘 중 하나를 해야 한다 — 규범을 낮추거나(보장이 거짓말이 된다), 오늘 아무도 못 거는 표를 강제하거나(카탈로그가 빈 선반이 된다).",
|
|
11
11
|
"growth": "표는 자란다. rev 상향 시 **기존 등재물의 소급 탈락은 금지**다 — 구 rev 판정은 판정 당시 rev 를 병기한 배지로 유효하게 지속되고, 신 rev 요구는 신규 등재·재promote 부터 건다. 순수 라벨 카테고리가 나중에 이 표에 편입되면 기존 등재물은 유예한다(§1.0-c). 조용한 소급은 이 설계가 죽이려던 조용한 실패의 재생산이다.",
|
|
12
12
|
"publication": "`published` 는 그 표면의 규범이 **공개 문서(llms.txt §5.1)에 이미 나가 있는가**다. false = 내부 정본에만 있다 = 아직 공표 전. 내부 정본이 앞서 있는 것은 무해하다(작업 지시서다). 그러나 **공표가 본보기를 앞지르면 안 된다** — '레시피가 본보기를 앞지르지 않는다'(§1.5-a). false→true 뒤집기는 T6 그린과 같은 발행 단위 이후이고(T4b), 대조 스크립트가 양방향으로 센다: published 인데 §5.1 에 없으면 실패, published 가 아닌데 §5.1 에 이미 있으면 **역방향 실패**(공표가 표를 앞질렀다).",
|
|
13
|
-
"categoryStorage": "카테고리는 `theme.
|
|
13
|
+
"categoryStorage": "카테고리는 `theme.categories` 자유 문자열 **배열**이다 — enum 으로 못박지 않는다(memo119 §5-6 · 배열화는 memo141 §2-1). **여기 항목이 있는 이름만** 게이트가 걸리고, 없는 이름은 순수 라벨이다(예: '미니멀'). 한 팩이 보장 이름을 여럿 걸 수 있고, 그때는 **주장한 이름 전부**가 스냅샷에서 개별 PASS 여야 공개된다(AND·memo141 §2-2) — 절반만 지킨 약속은 거짓이라, 부분 통과를 표기로 흡수하지 않고 promote 를 막는다. 부분만 걸고 싶으면 주장을 줄이면 된다. 값 이름을 업종어가 아니라 유형어로 두는 이유는 형제 계약의 명명 규약과 같다: 업종어를 박으면 재사용이 죽는다(DOCTOR_INTRO 교훈). 그래서 예약 칸은 `BEAUTY` 가 아니라 `BOOKING` 이다 — 예약을 파는 것은 뷰티만이 아니다."
|
|
14
14
|
},
|
|
15
15
|
|
|
16
16
|
"routes": {
|
|
@@ -131,10 +131,10 @@
|
|
|
131
131
|
"shipNote": "**축소 보장표로 등재된 상태다**(§4.2). ItemList·openingHours 를 지금 required 로 올리면 오늘 아무 팩도 이 칸을 못 걸고, 낮춰 적으면 보장이 거짓이 된다. 그래서 지금 참인 것만 강제하고 나머지는 planned 로 rev 관리한다 — rev 2 에서 required 로 올라간다. **그 rev 2 는 T6 병합 시점이 아니다**: openingHours 는 저장 자리만 서 있고 `@zalkera/client` 0.9.0(타입 추가)이 미발행이라 템플릿이 그 값을 소비하지 못해 그래프에 아무것도 안 나간다. rev 2 가 서는 단위는 **client 발행 + 본보기 팩 재빌드**다(conventions.requiredVsPlanned).",
|
|
132
132
|
"required": [
|
|
133
133
|
{"id": "home-localbusiness", "route": "home", "jsonLd": ["LocalBusiness", "BeautySalon", "HealthAndBeautyBusiness"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "예약을 파는 곳은 **물리 점포**다. Organization 으로 두면 '어디로 가면 되는가'가 기계에 안 읽힌다. organizationJsonLd 는 config.businessType==BEAUTY 일 때 BeautySalon 으로 좁힌다 — 즉 이 요구는 렌더러가 아니라 **테넌트 설정**이 만족시킨다."},
|
|
134
|
-
{"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이
|
|
134
|
+
{"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면을 재려면 카탈로그에 상품이 있어야 한다** — 종전 문면은 '시드가 상품을 만들 수 있어야'였으나 memo142 로 시드가 상품을 만들지 않는다. 전제는 사라진 것이 아니라 **거처가 바뀌었다**: 측정은 시험 테넌트에 파트너 API(콘솔과 같은 엔드포인트)로 상품을 적재한 뒤 크롤한다. 빈 테넌트에서 잰 값은 재료가 없는 것이지 통과가 아니다."}
|
|
135
135
|
],
|
|
136
136
|
"planned": [
|
|
137
|
-
{"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "
|
|
137
|
+
{"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "**운반체가 바뀌었다**(memo142 · contractRev 6). 종전 운반체이던 `SERVICE_MENU` 섹션은 어휘에서 삭제됐다 — 진열은 선언이 아니라 소스가 `@zalkera/client` 를 직접 호출해 그리는 일이기 때문이다. 그래서 이 ItemList 를 내는 자리는 **`/products` 목록 라우트**(전 팩 공유·직접 호출)와 소스가 홈에 조합하는 진열 레일이다. 산출 자체는 이미 돈다(라우트가 낸다). 여기서 required 로 올리는 것은 이 표의 **rev 2** 이고, 그 단위는 **본보기 팩을 다시 빌드해 개시하고 시험 테넌트에 상품을 적재해 잰 뒤**다 — 같은 rev 의 openingHours 는 거기에 더해 client 발행까지 필요하다. id 를 그대로 두는 것은 append-only 로그의 추적성 때문이다(운반체가 바뀌었지 약속이 바뀐 것이 아니다).", "why": "시술 목록의 정위치를 홈의 한 섹션 타입으로 못박았던 것이 rev 1 의 판단이었다(§4.2 W-F5). memo142 가 그 판단을 뒤집는다 — 목록을 **어디에 어떻게** 그릴지는 어휘가 아니라 소스의 몫이고, 어휘에 고정하면 그 진열의 디자인이 얼어붙어 해자(자연어로 다양한 디자인)와 반대로 간다. 그래서 `route` 는 여전히 `any` 다: 홈이든 `/products` 든 **개시된 페이지 어딘가에서 ItemList 가 나오는가**만 잰다(판정 지점은 소스가 아니라 산출물 — `conventions.verdictFromArtifact`)."},
|
|
138
138
|
{"id": "opening-hours", "route": "home", "jsonLdProperty": "openingHours", "tranche": "T6-ⓓ", "realized": "PARTIAL", "realizedNote": "**절반이다.** 저장 자리는 섰다(백엔드 site_config 확장 + 전체교체 경로 4곳). 템플릿 렌더는 @zalkera/client 0.9.0 발행 뒤라 아직 못 나간다(발행은 2FA 라 오너 몫). 저장 자리만으로는 그래프가 안 나가므로 이 항목이 여전히 red 로 잡히는 것이 맞다.", "why": "**전 레포에 저장할 필드 자체가 없다** — 렌더러 문제가 아니라 스키마 문제다. 거처는 예약 척추 위저드의 영업시간 입력(§2.4)이고, site_config 확장 마이그레이션이 선행한다."}
|
|
139
139
|
],
|
|
140
140
|
"why": "§4.2 예약 칸. 상세는 지금 나가고, 목록(ItemList)·영업시간은 T6 이후다."
|
package/dist/index.cjs
CHANGED
|
@@ -306,17 +306,12 @@ function safeJsonParse(text) {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
// src/sections.ts
|
|
309
|
-
var SECTION_CONTRACT_REV =
|
|
309
|
+
var SECTION_CONTRACT_REV = 6;
|
|
310
310
|
var SECTION_CONTRACT = [
|
|
311
|
-
// 뷰티(memo47) —
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// 정위치는 별도 라우트가 아니라 홈의 이 섹션이라, FAQ_LIST→FAQPage 와 같은 형태로 섹션이 직접 낸다.
|
|
315
|
-
// rev 3 에서 productIds 의 `?` 가 떨어졌다 — 목록을 내겠다고 선언한 섹션이 목록을 안 가리키는 상태가
|
|
316
|
-
// 계약상 성립하지 않게 됐다(BOOKING_CTA.productId 는 rev 1 부터 필수였다·비대칭 해소).
|
|
317
|
-
{ type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: "ItemList", requiredRefs: [], requiredRefsAnyOf: [["productIds", "categorySlug"]] },
|
|
311
|
+
// 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
|
|
312
|
+
// 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
|
|
313
|
+
// 진열이 낸다(보장표 `service-menu-itemlist` 의 route 는 원래 `any` — 판정 지점은 산출물이다).
|
|
318
314
|
{ type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
319
|
-
{ type: "BOOKING_CTA", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [["productId", "categorySlug"]] },
|
|
320
315
|
{ type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
321
316
|
// 기업 마케팅(memo102 §2)
|
|
322
317
|
{ type: "HERO", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|