@zalkera/client 0.21.10 → 0.22.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/README.md +29 -2
- package/bin/validate-storefront.mjs +114 -36
- package/dist/index.cjs +9 -9
- package/dist/index.d.cts +29 -25
- package/dist/index.d.ts +29 -25
- package/dist/index.js +9 -9
- package/llms.txt +53 -10
- package/package.json +14 -7
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ export const zalkera = createZalkeraClient({
|
|
|
48
48
|
const posts = await zalkera.listPosts({size: 10, sort: "publishedAt,desc"});
|
|
49
49
|
const post = await zalkera.getPost("hello-world");
|
|
50
50
|
const site = await zalkera.getSiteConfig({tags: ["site-config"]});
|
|
51
|
-
const product = await zalkera.getProduct("tee", {tags: ["
|
|
51
|
+
const product = await zalkera.getProduct("tee", {tags: ["products", "site-config"]});
|
|
52
52
|
await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -160,9 +160,36 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
160
160
|
### ISR 캐시 태그 (`ReadOptions`)
|
|
161
161
|
|
|
162
162
|
- 읽기 메서드에 `{tags: [...]}`를 넘기면 그 fetch에 `next.tags`가 실려 백엔드가 `revalidateTag`로 온디맨드 무효화.
|
|
163
|
-
-
|
|
163
|
+
- 태그는 **백엔드가 실제로 발화하는 둘뿐**이다: `site-config`(테마·레이아웃·회사정보) ·
|
|
164
|
+
`products`(상품·가격·재고). ⚠ `product:{slug}` 같은 세분 태그는 **기대하지 마라** — 백엔드가
|
|
165
|
+
일부러 안 붙인다(오퍼레이션의 상품 참조가 slug 와 일치한다는 보장이 없다). 상품 페이지도
|
|
166
|
+
`products` 로 받는다. 자세한 것은 `llms.txt` 의 「ISR 캐시 태그」 절.
|
|
164
167
|
- 안 넘기면 세그먼트 기본 캐시만 적용(하위호환).
|
|
165
168
|
|
|
169
|
+
## 스토어프론트 검사기 (`zalkera-validate`)
|
|
170
|
+
|
|
171
|
+
이 패키지는 **소스 규약 검사기**를 같이 배송한다. 스토어프론트 레포에서:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npx zalkera-validate ./src # 권고 모드 — 고객이 손으로 부르는 자리
|
|
175
|
+
npx zalkera-validate ./src --gate # 관문 모드 — 우리가 서빙할 때 쓴다
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
종료코드는 넷이다: `0` 통과 · `1` 위반 · `2` 인자·전제 오류 · `7` 판정 불능. `--gate` 는 **못 잰 자리**가 있으면
|
|
179
|
+
`7` 을 낸다 — 통과가 아니라 "판정 불능"이라는 뜻이다.
|
|
180
|
+
|
|
181
|
+
**모드가 severity 를 바꾼다.** `package.json` 의 `zalkera.styling`·`zalkera.content` 를 선언하면
|
|
182
|
+
그 축이 경고에서 오류로 올라간다(선언한 계약을 지키는지 보는 것이다). 어느 축이 무엇인지는
|
|
183
|
+
검사기가 첫 줄에 스스로 찍는다:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npx zalkera-validate ./src | grep '규약 모드'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
⚠ **통과가 안전을 뜻하지 않는다.** 교차사이트 위조 가드 축(`X1`~`X3`)은 어느 모드에서도 경고다 —
|
|
190
|
+
그 검사는 가드를 **불렀는가**만 보고 가드가 옳은지는 못 본다. `⚠️ [X1]` 이 보이면 사람이 고쳐야 한다.
|
|
191
|
+
|
|
192
|
+
|
|
166
193
|
## 서버 사이드 전용 (중요)
|
|
167
194
|
|
|
168
195
|
- 공개 API는 `X-Tenant`를 그대로 믿는다(비인증) → 브라우저 직접 호출 시 `baseUrl` 노출 + CORS 개방 필요.
|
|
@@ -476,6 +476,9 @@ let singletonFound = false;
|
|
|
476
476
|
* package.json 을 못 찾거나 파싱 실패하면 none(안전 — S 안 들이댄다).
|
|
477
477
|
* 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서 읽는다(memo75 §2).
|
|
478
478
|
*/
|
|
479
|
+
/** 모르는 `zalkera.styling` 값. 모드는 `none` 이지만 조용히 넘어가지 않는다. */
|
|
480
|
+
let styleDeclarationNote = null;
|
|
481
|
+
|
|
479
482
|
function detectStyleMode(srcDir) {
|
|
480
483
|
let dir = resolve(srcDir);
|
|
481
484
|
for (let i = 0; i < 12; i++) {
|
|
@@ -487,9 +490,19 @@ function detectStyleMode(srcDir) {
|
|
|
487
490
|
const declText = readSafe(pkgPath, "스타일 규약 모드 판정(package.json)");
|
|
488
491
|
if (declText === null) return "none";
|
|
489
492
|
const pkg = JSON.parse(declText);
|
|
490
|
-
|
|
493
|
+
// ⚠ `??` 로만 읽으면 `styling: null` 이 **경고 없이 조용히 none** 이 된다 —
|
|
494
|
+
// `false`·`0`·`{}` 는 경고가 나는데 `null` 만 새는 비대칭이었다. 존재로 판정한다.
|
|
495
|
+
const decl = pkg?.zalkera ?? {};
|
|
496
|
+
const legacy = pkg?.oneque ?? {};
|
|
497
|
+
const styling = "styling" in decl ? decl.styling : "styling" in legacy ? legacy.styling : undefined;
|
|
491
498
|
if (styling === "tailwind-tokens") return "declared";
|
|
492
|
-
if (styling !== undefined)
|
|
499
|
+
if (styling !== undefined) {
|
|
500
|
+
// ⚠ **오타 한 글자가 S 규칙군을 통째로 끄는 자리다**(`"tailwnid-tokens"` → 모드 none ·
|
|
501
|
+
// 오류 3건이 0건으로). 콘텐츠축은 같은 형상에 `[N0]` 을 내는데 스타일축에는 대응물이
|
|
502
|
+
// 없었다 — "선언 안 함"과 "모르는 값"은 다른 사실이다.
|
|
503
|
+
styleDeclarationNote = String(styling);
|
|
504
|
+
return "none";
|
|
505
|
+
}
|
|
493
506
|
const deps = {...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {})};
|
|
494
507
|
return Object.prototype.hasOwnProperty.call(deps, "tailwindcss") ? "inferred" : "none";
|
|
495
508
|
}
|
|
@@ -539,19 +552,13 @@ function servingSink() {
|
|
|
539
552
|
* 양쪽으로 다 틀린다(심의 실측):
|
|
540
553
|
* · **거짓 음성** — 라우트 안에 동명 `function assertSameOrigin(){return null}` 을 선언하면 통과한다.
|
|
541
554
|
* 인자로 엉뚱한 `Request` 를 넘겨도 통과한다. 즉 **통과가 안전을 뜻하지 않는다.**
|
|
542
|
-
* · **거짓 양성 가능성** —
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
* ⚠ **초판 주석 정정(2026-08-01 보안 심의).** 여기 원래 *"둘 다 reCAPTCHA + rate limit 으로 같은 위협을
|
|
546
|
-
* 이미 막고 있다"* 고 적었는데 **재 보지 않고 쓴 문장이었고 거짓이었다.** 심의가 서버를 띄워 실측하니
|
|
547
|
-
* bix `/api/contact` 는 교차 오리진 JSON·교차사이트 form 을 **둘 다 200 으로 접수**했고, rate limit 은
|
|
548
|
-
* `x-forwarded-for` 위조로 완전 우회됐으며, reCAPTCHA 는 `.env.example` 기본값이 `false` 였다.
|
|
549
|
-
* **그 사이트는 실제로 안 막고 있었다.**
|
|
555
|
+
* · **거짓 양성 가능성** — 자기 방식으로 막은 라우트도 우리 심볼을 안 쓰면 걸린다. 실사용 사이트로
|
|
556
|
+
* 확인했다.
|
|
550
557
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
558
|
+
* ⚠ 관문에서 빼는 근거는 **"다른 수단으로 막고 있다"가 아니다.** 근거는 위 거짓 음성, 즉
|
|
559
|
+
* **이 검사가 안전을 재지 못한다**는 것뿐이다. 통과해도 안전하지 않은 검사를 관문에 놓으면
|
|
560
|
+
* 안전하다는 착각만 판다. 다만 **막지 않는 대신 반드시 보여야 한다** — 업로드·검수 결과에 이
|
|
561
|
+
* 경고를 띄우는 것이 이 결정의 나머지 절반이고, 그것 없이는 방치다.
|
|
555
562
|
*
|
|
556
563
|
* 통과가 안전을 뜻하지 않고 실패가 위험을 뜻하지 않는 검사를 관문에 놓으면, 얻는 것은 심리적 안심뿐이고
|
|
557
564
|
* 잃는 것은 신뢰다 — 게이트의 첫 동작이 **상용 전량 중단**이 된다.
|
|
@@ -820,7 +827,7 @@ const I2_METHODS = [
|
|
|
820
827
|
* 뒤 문장으로 폭주하려면 반드시 다음 import 의 모듈 문자열(따옴표)을 지나야 하기 때문이다.
|
|
821
828
|
*/
|
|
822
829
|
const I2_IMPORT_STATEMENT =
|
|
823
|
-
/^[ \t]*import\s+(?!type\s)([^;"']*?)from\s*["'](?:@(?:zalkera|
|
|
830
|
+
/^[ \t]*import\s+(?!type\s)([^;"']*?)from\s*["'](?:@(?:zalkera|oneq(?:ue?)?)\/client|[^"']*lib\/(?:zalkera|oneq(?:ue?)?)(?:\/[^"']*)?)["']/gm;
|
|
824
831
|
|
|
825
832
|
/**
|
|
826
833
|
* 이 파일이 클라이언트를 **런타임 값으로** 들여오는가.
|
|
@@ -1219,7 +1226,7 @@ function missingClientIp(rawWithBom, jsx = false) {
|
|
|
1219
1226
|
const raw = rawWithBom.replace(/^\uFEFF/, " ");
|
|
1220
1227
|
// 값싼 사전 검사 — 클라이언트를 안 쓰는 파일(대다수)에 문자 배열 2벌을 물리지 않는다. 이 검사기는
|
|
1221
1228
|
// 고객 레포와 서빙 빌드에서도 돈다.
|
|
1222
|
-
if (!/@(?:zalkera|
|
|
1229
|
+
if (!/@(?:zalkera|oneq(?:ue?)?)\/client|lib\/(?:zalkera|oneq(?:ue?)?)/.test(raw)) return null;
|
|
1223
1230
|
const {masked: code, withStrings} = maskCode(raw, jsx);
|
|
1224
1231
|
if (!importsClientAtRuntime(withStrings)) return null;
|
|
1225
1232
|
// `clientIp` 를 담은 **객체 변수의 이름들**을 모은다(`const access = {phone, context: {clientIp}}`).
|
|
@@ -1427,6 +1434,19 @@ const CONTENT_MODE = detectContentMode(root);
|
|
|
1427
1434
|
* 남는 것은 전부 레포 루트 기준 상대 경로여야 한다. **디렉터리는 안 센다** — `public/` 처럼 팩이
|
|
1428
1435
|
* 만들어 주는 자리가 있어 존재 판정이 참이 아니다.
|
|
1429
1436
|
*/
|
|
1437
|
+
/**
|
|
1438
|
+
* 반려문에 테넌트 입력을 되받아 쓸 때의 상한.
|
|
1439
|
+
*
|
|
1440
|
+
* 이 출력은 `/_gate-status` logTail 로 테넌트에게 돌아가고 빌드 로그에 남는다. 상한이 없으면
|
|
1441
|
+
* 6MiB 짜리 값 스무 개가 **125MB 반려문**이 된다. `readSafe` 가 읽기에 8MB 상한을 건 것과 같은
|
|
1442
|
+
* 규율인데 메시지 쪽에는 안 걸려 있었다.
|
|
1443
|
+
* 재현: `content/pages/*.json` 의 asset 값을 6MiB 로 채우고 `--gate` 출력 바이트를 세면 입력과 같다.
|
|
1444
|
+
*/
|
|
1445
|
+
function echoValue(value) {
|
|
1446
|
+
const s = JSON.stringify(value);
|
|
1447
|
+
return s.length <= 200 ? s : `${s.slice(0, 200)}… (${s.length}자)`;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1430
1450
|
const DOC_PATH_TOKEN = /^[\p{L}\p{N}_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)$/u;
|
|
1431
1451
|
const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
|
|
1432
1452
|
|
|
@@ -2045,7 +2065,7 @@ function checkLayoutBlastRadius() {
|
|
|
2045
2065
|
// 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
|
|
2046
2066
|
// 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
|
|
2047
2067
|
const allow = (readSafe(file, "C1b 면제 마커 판독") ?? "").match(
|
|
2048
|
-
/\/\/\s*(?:zalkera|
|
|
2068
|
+
/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:[ \t]*(\S.*)/,
|
|
2049
2069
|
);
|
|
2050
2070
|
const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
|
|
2051
2071
|
const detail =
|
|
@@ -2122,7 +2142,10 @@ function checkThemeWiring() {
|
|
|
2122
2142
|
// 탈출구 — 손으로 계약을 지키는 것도 정당하다(memo108 §1 "kit 은 자격 조건이 아니다"). 다른 이름의
|
|
2123
2143
|
// 자기 헬퍼로 배선한 레포를 error 로 막으면 기계가 정당한 자유를 벌한다. 마커면 warning 으로 강등하고
|
|
2124
2144
|
// 실효 확인은 스모크 개시(실효층)가 맡는다. 마커는 원문에서 찾는다(주석이 곧 마커다).
|
|
2125
|
-
|
|
2145
|
+
// ⚠ **`//` 앵커와 사유를 요구한다.** 앵커가 없으면 문자열 리터럴 안의 같은 글자가 면제로 먹는다 —
|
|
2146
|
+
// `export const NOTE = "zalkera-allow-custom-theme-inject"` 한 줄로 error 가 warning 이 됐다.
|
|
2147
|
+
// 형제 마커(allow-dynamic·allow-inline-style·allow-cross-origin)는 전부 이 형태다.
|
|
2148
|
+
const sink = /\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-custom-theme-inject:[ \t]*\S/.test(raw) ? warnings : errors;
|
|
2126
2149
|
sink.push(
|
|
2127
2150
|
`[S8] ${relative(process.cwd(), rootLayout)}: 테마 주입 배선이 없습니다 — ` +
|
|
2128
2151
|
`parseThemeColors(...) 호출 + <html style={...}> 가 있어야 콘솔의 색 변경이 화면에 반영됩니다. ` +
|
|
@@ -2257,11 +2280,19 @@ function checkContentContract() {
|
|
|
2257
2280
|
// import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
|
|
2258
2281
|
// 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
|
|
2259
2282
|
const declaredSlugs = manifest
|
|
2260
|
-
? new Set(
|
|
2283
|
+
? new Set(
|
|
2284
|
+
[...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([^"'/]+)\.json["']/g)].map((m) =>
|
|
2285
|
+
// ⚠ **정규화를 접어서 비교한다.** macOS 가 만든 zip 은 파일명을 NFD 로 담고 리눅스에서
|
|
2286
|
+
// 풀면 NFD 로 남는데 소스의 import 문은 NFC 다 — 화면에는 같은 글자로 보이면서
|
|
2287
|
+
// "import 안 한다"와 "파일이 없다"가 **동시에** 뜬다. zip 업로드가 정본 입구인
|
|
2288
|
+
// 제품에서 직행 경로이고, 이 축은 면제 마커가 없어 빌드가 선다.
|
|
2289
|
+
m[1].normalize("NFC"),
|
|
2290
|
+
),
|
|
2291
|
+
)
|
|
2261
2292
|
: null;
|
|
2262
2293
|
if (declaredSlugs) {
|
|
2263
2294
|
for (const file of files) {
|
|
2264
|
-
const slug = basename(file, ".json");
|
|
2295
|
+
const slug = basename(file, ".json").normalize("NFC");
|
|
2265
2296
|
if (!declaredSlugs.has(slug)) {
|
|
2266
2297
|
sink.push(
|
|
2267
2298
|
`[N3] ${rel(file)} 를 ${rel(manifestPath)} 가 import 하지 않습니다 — ` +
|
|
@@ -2270,7 +2301,7 @@ function checkContentContract() {
|
|
|
2270
2301
|
}
|
|
2271
2302
|
}
|
|
2272
2303
|
for (const slug of declaredSlugs) {
|
|
2273
|
-
if (!files.some((f) => basename(f, ".json") === slug)) {
|
|
2304
|
+
if (!files.some((f) => basename(f, ".json").normalize("NFC") === slug)) {
|
|
2274
2305
|
sink.push(
|
|
2275
2306
|
`[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`,
|
|
2276
2307
|
);
|
|
@@ -2356,17 +2387,37 @@ function checkContentContract() {
|
|
|
2356
2387
|
value.split("/").includes("..")
|
|
2357
2388
|
) {
|
|
2358
2389
|
sink.push(
|
|
2359
|
-
`[N5] ${at}: "${path}" = ${
|
|
2390
|
+
`[N5] ${at}: "${path}" = ${echoValue(value)} — 레포 public/ 루트 절대 경로만 그려집니다` +
|
|
2360
2391
|
`(원격 URL·상대 경로·경로 탈출은 렌더에서 통째로 떨어집니다).`,
|
|
2361
2392
|
);
|
|
2362
2393
|
continue;
|
|
2363
2394
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2395
|
+
// ⚠ **레포 밖은 판정하지 않는다.** `statSync` 를 맨몸으로 부르면 테넌트가 `public/` 에
|
|
2396
|
+
// 심링크 하나를 심고 config 로 아무 경로나 물어볼 수 있다 — 있는 파일은 침묵, 없는
|
|
2397
|
+
// 파일은 보고이므로 **빌드 박스 파일시스템의 존재 오라클**이 된다. 이 파일이 스스로
|
|
2398
|
+
// 적어 둔 위협 모델(입력·탐침·회신이 갖춰진 자리)이 N5 에서만 봉쇄 없이 성립했다.
|
|
2399
|
+
// ⚠ **경로 부분만 본다.** 런타임 `assetPath` 는 `?query`·`#fragment` 를 그대로 통과시키고
|
|
2400
|
+
// Next 도 정상 서빙한다. 원문 그대로 `statSync` 하면 `logo.png?v=2` 를 파일명으로 찾아
|
|
2401
|
+
// **실재하는 에셋을 거짓 반려**한다(면제 마커가 없는 축이라 빠져나갈 길이 없다).
|
|
2402
|
+
const bare = value.split(/[?#]/, 1)[0];
|
|
2403
|
+
const assetAt = containedPath(join(repoRoot, "public"), `.${bare}`);
|
|
2404
|
+
if (assetAt === null) {
|
|
2405
|
+
markUnmeasured(
|
|
2406
|
+
join(repoRoot, "public", value),
|
|
2407
|
+
"EXDEV",
|
|
2408
|
+
`${at}: "${path}" — 레포 밖을 가리켜 확인하지 않았습니다`,
|
|
2369
2409
|
);
|
|
2410
|
+
} else {
|
|
2411
|
+
try {
|
|
2412
|
+
// ⚠ **파일이어야 한다.** 디렉터리도 `statSync` 는 성공하므로 `/img` 같은 값이
|
|
2413
|
+
// "실재"로 통과했다 — 이 축의 존재 이유인 "개시하면 깨진 이미지"를 놓친다.
|
|
2414
|
+
// 형제 호출 네 곳은 이미 `.isFile()` 을 쓴다.
|
|
2415
|
+
if (!statSync(assetAt).isFile()) throw new Error("not a file");
|
|
2416
|
+
} catch {
|
|
2417
|
+
sink.push(
|
|
2418
|
+
`[N5] ${at}: "${path}" 가 가리키는 public${bare} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2370
2421
|
}
|
|
2371
2422
|
}
|
|
2372
2423
|
// N5 — 계약 필수 참조.
|
|
@@ -2422,7 +2473,10 @@ function check(file) {
|
|
|
2422
2473
|
const isClient = isClientBoundary(src);
|
|
2423
2474
|
|
|
2424
2475
|
// 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
|
|
2425
|
-
|
|
2476
|
+
// ⚠ **주석 제거 사본에서 잰다.** 원문에 대고 재면 주석 한 줄이 두 가지를 동시에 한다:
|
|
2477
|
+
// ⑴ 정상 클라이언트 컴포넌트에 E1 오류를 지어내고 ⑵ `singletonFound` 를 세워 W1(싱글턴 부재)
|
|
2478
|
+
// 경고를 **침묵시킨다.** 하나의 결함이 오탐과 미탐을 같이 낸다.
|
|
2479
|
+
if (/create(?:Zalkera|Oneque)Client\s*\(/.test(stripComments(src))) {
|
|
2426
2480
|
singletonFound = true;
|
|
2427
2481
|
if (isClient)
|
|
2428
2482
|
servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
|
|
@@ -2430,23 +2484,28 @@ function check(file) {
|
|
|
2430
2484
|
|
|
2431
2485
|
if (isClient) {
|
|
2432
2486
|
// E1: 값 import(= import type 아님)로 @zalkera/client 를 들여옴(구 @oneque/client 도 잡는다).
|
|
2433
|
-
const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|
|
|
2487
|
+
const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneq(?:ue?)?)\/client["']/m.test(src);
|
|
2434
2488
|
if (valueImport) {
|
|
2435
2489
|
servingSink().push(
|
|
2436
2490
|
`[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 import 한다 (타입은 \`import type\` 으로).`,
|
|
2437
2491
|
);
|
|
2438
2492
|
}
|
|
2439
2493
|
// E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
|
|
2440
|
-
if (/from\s+["'][^"']*lib\/(?:zalkera|
|
|
2494
|
+
if (/from\s+["'][^"']*lib\/(?:zalkera|oneq(?:ue?)?)["']/.test(src)) {
|
|
2441
2495
|
servingSink().push(`[E2] ${rel}: "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera)을 import 한다.`);
|
|
2442
2496
|
}
|
|
2443
2497
|
}
|
|
2444
2498
|
|
|
2445
2499
|
// C1: ISR-우선 게이트 — SEO 라우트 page 는 per-page SSR 을 강제할 수 없다.
|
|
2446
2500
|
if (isSeoPageFile(file) && !isClient) {
|
|
2447
|
-
|
|
2501
|
+
// ⚠ **주석을 지운 사본에서 잰다.** 원문에 대고 재면 "예전엔 여기서 cookies() 를 읽었다" 같은
|
|
2502
|
+
// 설명 주석이 그대로 오류가 된다 — 이 파일이 그 함정을 스스로 적어 뒀고(C1b 는 그렇게 고쳤다)
|
|
2503
|
+
// C1 만 원문에 남아 있었다. 게다가 유일한 탈출구인 `zalkera-allow-dynamic` 마커는 동적 SSR 을
|
|
2504
|
+
// 안 쓰는 페이지에 "동적 SSR 이 필요하다"는 **거짓 사유**를 적게 만든다.
|
|
2505
|
+
// 마커 탐색은 아래에서 원문에 대고 한다 — 마커는 주석이라 지운 사본엔 없다.
|
|
2506
|
+
const hits = SSR_FORBIDDEN.filter(({re}) => re.test(stripComments(src))).map(({why}) => why);
|
|
2448
2507
|
if (hits.length > 0) {
|
|
2449
|
-
const allow = src.match(/\/\/\s*(?:zalkera|
|
|
2508
|
+
const allow = src.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
|
|
2450
2509
|
const detail = `${rel}: SEO 라우트가 동적SSR 을 유발한다 → ${hits.join("; ")}`;
|
|
2451
2510
|
if (allow) {
|
|
2452
2511
|
warnings.push(`[C1] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
|
|
@@ -2495,7 +2554,11 @@ function check(file) {
|
|
|
2495
2554
|
// 원리적으로 매치될 수 없었다 — 이 규칙이 안내하는 탈출구가 **한 번도 작동한 적이 없다**
|
|
2496
2555
|
// (2026-08-01 첫 사용자가 발견). 형제 규칙 `allow-dynamic`(§S1)은 `readFileSync` 원문을 보므로
|
|
2497
2556
|
// 처음부터 옳았다 — 같은 관례를 두 곳이 다르게 구현하고 있었다.
|
|
2498
|
-
if (
|
|
2557
|
+
if (
|
|
2558
|
+
s2 &&
|
|
2559
|
+
/style=\{\{(?!\s*["'`]--)/.test(text) &&
|
|
2560
|
+
!/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-inline-style:/.test(src)
|
|
2561
|
+
) {
|
|
2499
2562
|
s2.push(
|
|
2500
2563
|
`[S2] ${rel}: JSX 인라인 style={{…}} 사용 — 스타일은 Tailwind 유틸리티 클래스로 표현하라. ` +
|
|
2501
2564
|
`정당한 동적 스타일이면 \`// zalkera-allow-inline-style: <이유>\` 마커로 억제하라.`,
|
|
@@ -3147,7 +3210,7 @@ function checkPagesRouter() {
|
|
|
3147
3210
|
// ── C1pa — `_app`/`_document` 의 getInitialProps: 사이트 전체 정적 최적화 해제.
|
|
3148
3211
|
if (stem === "_app" || stem === "_document") {
|
|
3149
3212
|
if (/\bgetInitialProps\b/.test(text)) {
|
|
3150
|
-
const allow = raw.match(/\/\/\s*(?:zalkera|
|
|
3213
|
+
const allow = raw.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
|
|
3151
3214
|
const detail =
|
|
3152
3215
|
`${rel}: ${stem} 이 getInitialProps 를 씁니다 — **사이트 전 라우트**의 자동 정적 최적화가 ` +
|
|
3153
3216
|
`꺼져 모든 페이지가 매 요청 SSR 이 됩니다(폭발반경이 layout 과 같습니다)`;
|
|
@@ -3169,7 +3232,7 @@ function checkPagesRouter() {
|
|
|
3169
3232
|
/export\s+(?:const|let|var)\s+getServerSideProps\b/.test(text) ||
|
|
3170
3233
|
/export\s*\{[^}]*\bgetServerSideProps\b/.test(text);
|
|
3171
3234
|
if (!gssp) continue;
|
|
3172
|
-
const allow = raw.match(/\/\/\s*(?:zalkera|
|
|
3235
|
+
const allow = raw.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
|
|
3173
3236
|
const detail = `${rel}: SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제합니다`;
|
|
3174
3237
|
if (allow) warnings.push(`[C1p] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
|
|
3175
3238
|
else
|
|
@@ -3306,6 +3369,15 @@ checkThemeWiring(); // S8 — L1 배선(declared 전용).
|
|
|
3306
3369
|
checkSectionCoverage();
|
|
3307
3370
|
checkContentContract(); // N1~N5 — 콘텐츠 파일 계약(선언 조건화).
|
|
3308
3371
|
checkDocCoordinates(); // D1·D2 — 문서 좌표가 실물을 가리키는가.
|
|
3372
|
+
// 모르는 `zalkera.styling` 값은 조용히 넘어가지 않는다 — 콘텐츠축의 `[N0]` 과 같은 규율이다.
|
|
3373
|
+
if (styleDeclarationNote !== null) {
|
|
3374
|
+
warnings.push(
|
|
3375
|
+
`[S0] package.json 의 \`zalkera.styling\` 값 ${JSON.stringify(styleDeclarationNote)} 을 모릅니다 — ` +
|
|
3376
|
+
`아는 값은 \`"tailwind-tokens"\` 하나이고, 모르는 값이면 **S 규칙군이 통째로 스킵됩니다**. ` +
|
|
3377
|
+
`오타면 고치고, 잘커라 스타일 규약을 안 쓰는 것이면 키를 지우십시오.`,
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3309
3381
|
checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조 가드(memo118).
|
|
3310
3382
|
checkPagesRouter(); // C1p·C1pa·X1p — Pages Router 좌표(App Router 전용이던 사각).
|
|
3311
3383
|
checkEnvFiles(); // E3 — `.env*` 의 NEXT_PUBLIC_ 시크릿.
|
|
@@ -3336,7 +3408,13 @@ function checkManualCarrier() {
|
|
|
3336
3408
|
return; // client 미설치 — 여기서 판정할 것이 없다.
|
|
3337
3409
|
}
|
|
3338
3410
|
if (!existsSync(carried)) return;
|
|
3339
|
-
|
|
3411
|
+
// ⚠ **`readSafe` 를 쓴다.** 양쪽 다 테넌트가 통제하는 경로다(`llms.txt` 와 설치본). 맨몸
|
|
3412
|
+
// `readFileSync` 는 ⑴ 레포 밖 심링크를 따라가 **내용 동일성 오라클**이 되고(맞히면 침묵,
|
|
3413
|
+
// 틀리면 경고 — 길이 오라클도 겸한다) ⑵ 크기 상한을 통째로 우회한다(실측 RSS 354MB).
|
|
3414
|
+
const rootDoc = readSafe(at, "W-LLMS 루트 llms.txt");
|
|
3415
|
+
const carriedDoc = readSafe(carried, "W-LLMS 운반본 llms.txt");
|
|
3416
|
+
if (rootDoc === null || carriedDoc === null) return;
|
|
3417
|
+
if (rootDoc !== carriedDoc) {
|
|
3340
3418
|
warnings.push(
|
|
3341
3419
|
"[W-LLMS] 루트 llms.txt 가 설치된 @zalkera/client 의 것과 다릅니다 — client 를 올린 뒤 사본을 " +
|
|
3342
3420
|
"안 고쳤다면 낡은 명세가 배송됩니다. 일부러 고친 것이면 이 경고는 무시하십시오.",
|
package/dist/index.cjs
CHANGED
|
@@ -36,7 +36,7 @@ var ZalkeraError = class _ZalkeraError extends Error {
|
|
|
36
36
|
return this.status === 429;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* 스토어프론트 시크릿 키 문제
|
|
39
|
+
* 스토어프론트 시크릿 키 문제 — 개발자 오배선 신호. `true` 면 `secretKey` 옵션을
|
|
40
40
|
* 점검하라(미설정·불일치·폐기). [code] 로 정밀 분기: `STOREFRONT_KEY_REQUIRED`(401·키 필요)·
|
|
41
41
|
* `TENANT_MISMATCH`(403·키↔tenant 불일치).
|
|
42
42
|
*/
|
|
@@ -48,7 +48,7 @@ var ZalkeraError = class _ZalkeraError extends Error {
|
|
|
48
48
|
if (isApiErrorBody(body)) {
|
|
49
49
|
const code = body.errorCode ?? body.error;
|
|
50
50
|
return new _ZalkeraError(
|
|
51
|
-
// 스토어프론트 키
|
|
51
|
+
// 스토어프론트 키 오배선은 개발자용 안내로 메시지를 덮는다 — 백엔드는 정보 누출
|
|
52
52
|
// 최소화로 두루뭉술한 401 을 주므로, SDK 가 "무엇을 고쳐야 하는지"를 또렷이 알려준다.
|
|
53
53
|
STOREFRONT_KEY_MESSAGES[code] ?? body.message,
|
|
54
54
|
{
|
|
@@ -308,12 +308,12 @@ function safeJsonParse(text) {
|
|
|
308
308
|
// src/sections.ts
|
|
309
309
|
var SECTION_CONTRACT_REV = 7;
|
|
310
310
|
var SECTION_CONTRACT = [
|
|
311
|
-
// 뷰티
|
|
311
|
+
// 뷰티 — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
|
|
312
312
|
// 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
|
|
313
313
|
// 진열이 낸다(보장표 `service-menu-itemlist` 의 route 는 원래 `any` — 판정 지점은 산출물이다).
|
|
314
314
|
{ type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
315
315
|
{ type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
316
|
-
// 기업 마케팅
|
|
316
|
+
// 기업 마케팅
|
|
317
317
|
{ type: "HERO", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
318
318
|
{ type: "FEATURE_GRID", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
319
319
|
{ type: "TEXT_MEDIA", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
@@ -348,13 +348,13 @@ function internalPath(raw) {
|
|
|
348
348
|
return out;
|
|
349
349
|
}
|
|
350
350
|
function safeLinkUrl(raw) {
|
|
351
|
-
if (!raw) return "#";
|
|
351
|
+
if (typeof raw !== "string" || !raw) return "#";
|
|
352
352
|
const url = raw.trim();
|
|
353
353
|
if (url.startsWith("#") || url.startsWith("?")) return url;
|
|
354
354
|
if (url.startsWith("/")) return internalPath(url) ?? "#";
|
|
355
355
|
try {
|
|
356
356
|
const parsed = new URL(url);
|
|
357
|
-
return ALLOWED_SCHEMES.has(parsed.protocol) ?
|
|
357
|
+
return ALLOWED_SCHEMES.has(parsed.protocol) ? parsed.href : "#";
|
|
358
358
|
} catch {
|
|
359
359
|
try {
|
|
360
360
|
return internalPath(url) ?? "#";
|
|
@@ -421,7 +421,9 @@ function asId(value) {
|
|
|
421
421
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
|
|
422
422
|
}
|
|
423
423
|
function mediaSrc(assetId) {
|
|
424
|
-
|
|
424
|
+
const raw = String(assetId);
|
|
425
|
+
if (raw === "." || raw === "..") return void 0;
|
|
426
|
+
return `/media/${seg(raw)}`;
|
|
425
427
|
}
|
|
426
428
|
function asHandle(value) {
|
|
427
429
|
if (typeof value !== "string") return void 0;
|
|
@@ -540,5 +542,3 @@ exports.readConfig = readConfig;
|
|
|
540
542
|
exports.safeLinkUrl = safeLinkUrl;
|
|
541
543
|
exports.sectionsOfVertical = sectionsOfVertical;
|
|
542
544
|
exports.visitorIp = visitorIp;
|
|
543
|
-
//# sourceMappingURL=index.cjs.map
|
|
544
|
-
//# sourceMappingURL=index.cjs.map
|
package/dist/index.d.cts
CHANGED
|
@@ -541,7 +541,7 @@ interface ShipmentInfo {
|
|
|
541
541
|
}
|
|
542
542
|
|
|
543
543
|
/**
|
|
544
|
-
* ISR 읽기 옵션 — Next.js 캐시
|
|
544
|
+
* ISR 읽기 옵션 — Next.js 캐시 태그. RSC/ISR 페이지에서 읽기 메서드에 넘기면
|
|
545
545
|
* 그 fetch 에 `next.tags` 가 실려, 백엔드가 `revalidateTag(tag)` 로 온디맨드 무효화할 수 있다.
|
|
546
546
|
* 태그 컨벤션: `site-config`(사이트 설정·테마·레이아웃), `products`(카탈로그), `product:{slug}`(특정 상품).
|
|
547
547
|
* 넘기지 않으면 세그먼트 기본 캐시(페이지의 `revalidate` 주기)만 적용된다 — 하위호환 유지.
|
|
@@ -569,7 +569,7 @@ interface ZalkeraClientOptions {
|
|
|
569
569
|
tenant: string;
|
|
570
570
|
/**
|
|
571
571
|
* 스토어프론트 서버 시크릿 키(선택·`oqsk_…`). 주면 모든 요청에 `X-Storefront-Key` 헤더로 실려,
|
|
572
|
-
* 백엔드가 이 키로 테넌트 신원을 증명한다
|
|
572
|
+
* 백엔드가 이 키로 테넌트 신원을 증명한다 — `tenant` 만으로 신뢰하던 것의 보안 승격이다.
|
|
573
573
|
*
|
|
574
574
|
* ⚠️ **진짜 비밀이다.** 오직 서버 `.env`(예: `ZALKERA_STOREFRONT_KEY`)에만 두고, 브라우저 번들에
|
|
575
575
|
* 절대 넣지 마라 — `NEXT_PUBLIC_*` 접두사·클라이언트 컴포넌트 import 금지. 이 클라이언트가 서버
|
|
@@ -584,7 +584,7 @@ interface ZalkeraClientOptions {
|
|
|
584
584
|
* Next.js 의 `fetch` 캐시 옵션을 감싸는 래퍼를 넣을 때 쓴다.
|
|
585
585
|
*
|
|
586
586
|
* (내부 `request` 전송부는 이 주입점을 데이터 소스 seam 으로 유지한다 — 미래의 로컬 픽스처(mock)
|
|
587
|
-
* 모드를 갈아엎지 않고 얹기 위한 여지다. mock 구현은 현재
|
|
587
|
+
* 모드를 갈아엎지 않고 얹기 위한 여지다. mock 구현은 현재 없다.
|
|
588
588
|
*/
|
|
589
589
|
fetch?: typeof fetch;
|
|
590
590
|
/** 모든 요청에 추가할 헤더(선택). */
|
|
@@ -859,7 +859,7 @@ declare class ZalkeraError extends Error {
|
|
|
859
859
|
*/
|
|
860
860
|
get isRateLimited(): boolean;
|
|
861
861
|
/**
|
|
862
|
-
* 스토어프론트 시크릿 키 문제
|
|
862
|
+
* 스토어프론트 시크릿 키 문제 — 개발자 오배선 신호. `true` 면 `secretKey` 옵션을
|
|
863
863
|
* 점검하라(미설정·불일치·폐기). [code] 로 정밀 분기: `STOREFRONT_KEY_REQUIRED`(401·키 필요)·
|
|
864
864
|
* `TENANT_MISMATCH`(403·키↔tenant 불일치).
|
|
865
865
|
*/
|
|
@@ -869,13 +869,13 @@ declare class ZalkeraError extends Error {
|
|
|
869
869
|
}
|
|
870
870
|
|
|
871
871
|
/**
|
|
872
|
-
* 섹션 어휘 계약 — **정본의 코드
|
|
872
|
+
* 섹션 어휘 계약 — **정본의 코드 표현**.
|
|
873
873
|
*
|
|
874
|
-
* 정본은 백엔드 레포의
|
|
874
|
+
* 정본은 백엔드 레포의 섹션 어휘 계약이고, 이 파일은 그것을 npm 으로
|
|
875
875
|
* 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —
|
|
876
876
|
* 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래
|
|
877
877
|
* 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —
|
|
878
|
-
* 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께
|
|
878
|
+
* 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다.
|
|
879
879
|
*
|
|
880
880
|
* 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —
|
|
881
881
|
* 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,
|
|
@@ -884,31 +884,31 @@ declare class ZalkeraError extends Error {
|
|
|
884
884
|
/**
|
|
885
885
|
* 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다.
|
|
886
886
|
*
|
|
887
|
-
* rev 4 = **참조 방언·콘텐츠 파일의 1급
|
|
887
|
+
* rev 4 = **참조 방언·콘텐츠 파일의 1급 승격**. 섹션 타입 12종도 config 키 선언도
|
|
888
888
|
* 안 바뀌었다 — 정본에 `dialects`(id ↔ 참조)와 `contentFile`(`content/pages/*.json`) 절이 생겼고,
|
|
889
889
|
* 이 패키지는 그 방언을 읽는 헬퍼([asHandle]·[asHandleArray]·[assetPath]·[readConfig])를 실어 나른다.
|
|
890
890
|
* 그래서 아래 `SECTION_CONTRACT` 리터럴은 rev 3 과 **바이트 동일**하다(동기 스크립트가 확인한다).
|
|
891
891
|
*
|
|
892
|
-
* rev 5 = **`categorySlug` 를 대등
|
|
892
|
+
* rev 5 = **`categorySlug` 를 대등 참조로**. `SERVICE_MENU`·`BOOKING_CTA` 의 필수성 단위가
|
|
893
893
|
* "productIds 가 있는가"에서 **"참조가 하나라도 있는가"**(`requiredRefsAnyOf`)로 옮겨갔다. 그 시대의
|
|
894
894
|
* 판단이었고 rev 6 이 뒤집었다(아래).
|
|
895
895
|
*
|
|
896
|
-
* rev 6 = **`SERVICE_MENU`·`BOOKING_CTA` 완전 삭제 — 12종 → 10
|
|
896
|
+
* rev 6 = **`SERVICE_MENU`·`BOOKING_CTA` 완전 삭제 — 12종 → 10종**. rev 3·5 가
|
|
897
897
|
* "조회형 섹션은 참조를 반드시 실어라"로 조이던 잣대가 **"조회형 섹션을 싣지 마라"로 반전**됐다.
|
|
898
|
-
* 경계
|
|
898
|
+
* 경계 규칙: 값이 콘텐츠 파일에 사는 저작물은 **선언 섹션**의 소관이고, 값이 업무 DB 에
|
|
899
899
|
* 살고 화면이 비추기만 하는 조회는 **소스가 이 패키지를 직접 호출**해 그린다(`listProducts()`·
|
|
900
900
|
* `listProductCategories()`). 절반 선언(`SERVICE_MENU`)은 "어디에"만 선언에 두고 "어떻게"(카드 그리드·
|
|
901
901
|
* 필드·개수)를 공유 렌더러에 얼려 두는 형태였고, 그것이 자연어로 다양한 디자인을 만든다는 방향과 반대다.
|
|
902
902
|
*
|
|
903
903
|
* **`retired` 표기가 아니라 삭제**인 이유: 실측상 정당한 잔존 소비자가 0이었고(상용 `page_section`·
|
|
904
|
-
* `product`·`product_category` 전부 0행),
|
|
904
|
+
* `product`·`product_category` 전부 0행), 백엔드가 이미 `page_section` 계열을 퇴역 방향으로 잡아 뒀다.
|
|
905
905
|
* 제3 상태는 계약·검사기·팩 게이트·콘솔이 각자 해석해야 하는 축을 새로 만든다.
|
|
906
906
|
*
|
|
907
907
|
* ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.
|
|
908
|
-
* 어휘를 강제하지도 않는다(
|
|
908
|
+
* 어휘를 강제하지도 않는다(레인 A 의 첫 요건): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인
|
|
909
909
|
* 팩**에만 선다.
|
|
910
910
|
*
|
|
911
|
-
* rev 7 = **DB 방언 소거 — 거처가 하나
|
|
911
|
+
* rev 7 = **DB 방언 소거 — 거처가 하나 남았다**. `page`·`page_section`·`menu` 계열이 퇴역하면서
|
|
912
912
|
* 정본의 `dialects.id`(숫자 id 표기)가 가리킬 자리가 없어졌다. rev 4 가 방언을 1급으로 승격하며 rev 를
|
|
913
913
|
* 올렸던 것의 **역연산**이라 서술 정리가 아니라 잣대 변경이다. **아래 리터럴은 rev 6 과 바이트 동일**이다
|
|
914
914
|
* — 섹션 10종·`requiredRefs(AnyOf)` 는 한 글자도 안 바뀐다(동기 스크립트가 확인한다). 이 패키지에서
|
|
@@ -925,7 +925,7 @@ interface SectionSpec {
|
|
|
925
925
|
/** 이 섹션이 산출하는 schema.org 타입. null 이면 구조화 데이터 없음. */
|
|
926
926
|
readonly jsonLd: string | null;
|
|
927
927
|
/**
|
|
928
|
-
* **필수 참조 config 키**(정본 `config` 선언에서 `?` 가 없는 `*Id`/`*Ids`
|
|
928
|
+
* **필수 참조 config 키**(정본 `config` 선언에서 `?` 가 없는 `*Id`/`*Ids` 키·rev 3).
|
|
929
929
|
*
|
|
930
930
|
* 계약 전체를 실어 나르지 않고 이 축만 뽑아 오는 이유: 이 값을 읽는 소비자가 **팩 게이트 하나**이고,
|
|
931
931
|
* 그가 답해야 하는 질문이 정확히 "이 섹션이 아무것도 안 가리킨 채 시드에 들어와 있는가"이기 때문이다.
|
|
@@ -935,7 +935,7 @@ interface SectionSpec {
|
|
|
935
935
|
* ⚠ **rev 6 기준 이 축을 쓰는 타입은 0 이다.** 그 요구를 갖던 둘이 어휘에서 삭제됐기 때문이다.
|
|
936
936
|
* 필드를 남겨 두는 것은 계약 기계를 유지하기 위해서다 — 참조가 필수인 **저작물** 타입이 앞으로
|
|
937
937
|
* 생길 수 있고(에셋 축), 팩 게이트가 이 선언을 읽는 코드도 그대로 선다. 다만 **조회형 타입의 증설로**
|
|
938
|
-
* 이 축이 되살아나는 일은 없다
|
|
938
|
+
* 이 축이 되살아나는 일은 없다 — 조회형 섹션을 없앤 결정이 그 문을 닫았다.
|
|
939
939
|
*
|
|
940
940
|
* 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는
|
|
941
941
|
* 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.
|
|
@@ -951,7 +951,7 @@ interface SectionSpec {
|
|
|
951
951
|
* 안 가리킨 채 시드에 들어와 개시 직후 조용히 사라지는 섹션.
|
|
952
952
|
*
|
|
953
953
|
* ⚠ `requiredRefs` 와 같이 **rev 6 기준 이 축을 쓰는 타입도 0 이다**(그 둘이 삭제됐다). 남기는
|
|
954
|
-
* 이유도 같다 — 계약 기계는
|
|
954
|
+
* 이유도 같다 — 계약 기계는 유지하되, 되살릴 문은 이미 닫혔다.
|
|
955
955
|
*
|
|
956
956
|
* `requiredRefs`(무조건 필수)와 **함께** 쓴다: 그룹으로 표현되는 섹션은 `requiredRefs` 가 빈 배열이고,
|
|
957
957
|
* 종전처럼 단일 키가 무조건 필수인 섹션은 이 필드가 빈 배열이다. 둘 다 빈 배열이면 참조 요구가 없다.
|
|
@@ -1032,7 +1032,7 @@ type KnownSectionType = (typeof SECTION_CONTRACT)[number]["type"];
|
|
|
1032
1032
|
/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */
|
|
1033
1033
|
declare function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[];
|
|
1034
1034
|
|
|
1035
|
-
declare function safeLinkUrl(raw:
|
|
1035
|
+
declare function safeLinkUrl(raw: unknown): string;
|
|
1036
1036
|
|
|
1037
1037
|
/**
|
|
1038
1038
|
* `X-Forwarded-For` 에서 **원 방문자 IP** 를 뽑는다. 백엔드 `ClientUtils.resolveClientIp` 의 **계약 거울**이다
|
|
@@ -1084,7 +1084,7 @@ declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
|
1084
1084
|
* @param headers `Headers` 또는 `NextRequest.headers` — `get(name)` 하나만 쓴다.
|
|
1085
1085
|
* @param options 홉 수 명시. 우선순위: 명시 > env `ZALKERA_TRUSTED_PROXY_HOPS` > 기본 1.
|
|
1086
1086
|
* @returns 원 방문자 IP. 정할 수 없으면 `undefined`(문자열 `"unknown"` 을 지어내지 않는다) —
|
|
1087
|
-
* 그대로 `RequestContext.clientIp` 에
|
|
1087
|
+
* 그대로 `RequestContext.clientIp` 에 넣는다 — 백엔드가 `undefined` 를 받으면 그 필드를 생략한다..
|
|
1088
1088
|
*/
|
|
1089
1089
|
declare function visitorIp(headers: HeaderReader, options?: VisitorIpOptions): string | undefined;
|
|
1090
1090
|
/** `get(name)` 만 요구한다 — `Headers`·`NextRequest.headers`·직접 만든 객체가 전부 들어맞는다. */
|
|
@@ -1111,7 +1111,7 @@ interface VisitorIpOptions {
|
|
|
1111
1111
|
* **페이지 전체가 500** 이 난다 — 계약은 "그 섹션만 사라진다" 였다. 아래 가드들이 그 계약을
|
|
1112
1112
|
* 지키는 장치다. [parseThemeColors] 와 같은 사상.
|
|
1113
1113
|
*
|
|
1114
|
-
* **이 패키지에 사는
|
|
1114
|
+
* **이 패키지에 사는 이유**: 이건 표현이 아니라 **계약을 안전하게 읽는 법**이다. 마크업은
|
|
1115
1115
|
* 만드는 쪽 자유지만, '섹션 하나가 사이트를 죽이지 않는다'는 계약은 한 벌이어야 한다 — 사본이 갈라지면
|
|
1116
1116
|
* 그 계약이 조용히 깨진다(471946c 교훈).
|
|
1117
1117
|
*/
|
|
@@ -1121,7 +1121,7 @@ declare function parseConfig<T>(config: string | null): T | null;
|
|
|
1121
1121
|
*
|
|
1122
1122
|
* 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는
|
|
1123
1123
|
* 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른
|
|
1124
|
-
* 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만
|
|
1124
|
+
* 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만 관용은 append-only 로 남긴다.
|
|
1125
1125
|
* 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유
|
|
1126
1126
|
* 그대로다. 그래서 입구를 하나로 좁힌다.
|
|
1127
1127
|
*
|
|
@@ -1142,9 +1142,13 @@ declare function asId(value: unknown): number | undefined;
|
|
|
1142
1142
|
*
|
|
1143
1143
|
* 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config
|
|
1144
1144
|
* 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.
|
|
1145
|
-
* **throw 하지 않는다**는 이 파일의 계약은 그대로다 —
|
|
1145
|
+
* **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 판정에 걸리면 던지지 않고 `undefined` 를 준다.
|
|
1146
|
+
*
|
|
1147
|
+
* ⚠ **점은 인코딩이 안 된다.** `encodeURIComponent(".")` 는 `"."` 그대로라 `mediaSrc("..")` 가
|
|
1148
|
+
* `/media/..` 를 만든다 — 한 단이지만 프록시 라우트 밖이다. 형제인 전송 단일점(`client.ts` 의
|
|
1149
|
+
* `INVALID_PATH`)은 같은 불변식을 이미 세워 두었는데 여기만 없었다.
|
|
1146
1150
|
*/
|
|
1147
|
-
declare function mediaSrc(assetId: number): string;
|
|
1151
|
+
declare function mediaSrc(assetId: number): string | undefined;
|
|
1148
1152
|
/** 상품 handle 하나. 문자열이 아니거나 공백뿐이면 `undefined` — 형식은 검사하지 않는다(런타임은 관용). */
|
|
1149
1153
|
declare function asHandle(value: unknown): string | undefined;
|
|
1150
1154
|
/**
|
|
@@ -1170,8 +1174,8 @@ declare function assetPath(value: unknown): string | undefined;
|
|
|
1170
1174
|
* 읽어들인 색은 globals.css 의 `@theme` 변수를 `<html>` inline style 로 덮어, `bg-primary` 등
|
|
1171
1175
|
* 전 유틸리티가 테넌트 색으로 바뀐다(layout.tsx). CSS 주입 방어를 위해 값은 hex 형식 검증 후에만 싣는다.
|
|
1172
1176
|
*
|
|
1173
|
-
* **이 패키지에 사는
|
|
1174
|
-
* 쓸지는 코드가 정하고 값은 config 가 정한다는
|
|
1177
|
+
* **이 패키지에 사는 이유**: L1(말로 색 바꾸기)의 기계는 표현이 아니라 계약이다 — 어떤 토큰을
|
|
1178
|
+
* 쓸지는 코드가 정하고 값은 config 가 정한다는 규약의 집행부이고, 순수 함수라 React 에 안 매인다.
|
|
1175
1179
|
* 이 함수의 반환을 `<html style={...}>` 에 싣는 **배선은 각 사이트의 몫**이다(validator S8 이 그걸 센다).
|
|
1176
1180
|
*/
|
|
1177
1181
|
interface ParsedTheme {
|