@zalkera/client 0.23.0 → 0.24.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.
@@ -594,6 +594,72 @@ let singletonFound = false;
594
594
  * package.json 을 못 찾거나 파싱 실패하면 none(안전 — S 안 들이댄다).
595
595
  * 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서 읽는다.
596
596
  */
597
+ /**
598
+ * **면제 마커 판정을 한 곳에서 만든다.**
599
+ *
600
+ * 이 파일이 판정하는 마커는 **네 계열**이다 — `allow-dynamic`(C1·C1b·C1p·C1pa) ·
601
+ * `allow-inline-style`(S2) · `allow-cross-origin`(X1·X1p) · `allow-custom-theme-inject`(S8).
602
+ * (`allow-preview-write` 라는 다섯째 마커가 있지만 **여기서는 판정하지 않는다** — 판정은
603
+ * `zalkera-storefront-examples` 의 `src/lib/previewGuard.test.ts` 에 살고, 줄머리 앵커에 구 접두를
604
+ * 안 받는 **다른 형태**다. 여기 모았다고 그것까지 모인 것이 아니다.)
605
+ *
606
+ * 그 정규식이 **여덟 자리에 흩어져 있었고 다섯 자리가 사유 없는 맨몸 마커를 면제로 받았다**
607
+ * (S2·X1·X1p·C1p·C1pa). 그중 **셋**(`\s*(.+)` — X1·C1p·C1pa)은 `\s` 가 개행을 넘어 **다음 소스
608
+ * 줄을 사유로 지어내** 사람에게 보여 줬다:
609
+ *
610
+ * // zalkera-allow-cross-origin:
611
+ * import {visitorIp} from "@zalkera/client";
612
+ * → 면제 1건: app/api/checkout/route.ts — import {visitorIp} from "@zalkera/client";
613
+ *
614
+ * 재현: 발행 전 판본을 꺼내(`git show <ref>:bin/validate-storefront.mjs`) **자기 `node_modules` 에
615
+ * 닿는 자리**에 두고, 규칙별 배치에 맞춘 픽스처(X1=변이 라우트 · S2=인라인 style · C1p=`pages/`
616
+ * `getServerSideProps`)에 맨몸 마커를 먹여 `--gate` 출력을 두 판본에서 대조한다.
617
+ *
618
+ * 면제는 곧 그 규칙을 끄는 일이고, 경고문 자체가 「마커를 다세요」라고 가르친다 — 경고를 없애려는
619
+ * 고객·AI 가 마커만 달면 그 라우트의 가드가 사라진다. 그래서 **사유를 요구한다.**
620
+ *
621
+ * ■ 「공백」의 정의를 양쪽에서 같게 쓴다
622
+ * 구분자는 **가로 공백류**다 — NBSP·전각공백을 포함한다. 붙여넣기 경로에서 온 한글 사유가
623
+ * 그 문자로 시작하는 일이 있고, 그것을 거부하면 정당한 마커가 막힌다(과소독도 결함이다).
624
+ * 반대로 사유의 첫 글자는 **보이지 않는 글자여도 안 된다** — `\S` 는 그것들을 통과시켜 ZWSP 한
625
+ * 글자가 「사유」가 됐고, 면제 목록에 **빈 사유**가 찍혔다(실측 9종: ZWSP·ZWNJ·ZWJ·WJ·SHY·
626
+ * CGJ·MVS·ALM·LRM). 그래서 `\p{Cf}` 만이 아니라 `\p{Mn}`·`\p{Me}` 도 배제한다 — **CGJ(U+034F)는
627
+ * `Cf` 가 아니라 `Mn` 이다.** `Cf` 만 막으면 아홉 중 여덟만 막힌다(그렇게 한 판을 냈다가 잡혔다).
628
+ *
629
+ * ⚠ **범주로 완전히 막을 수는 없다.** U+3164 한글 채움 문자(`Lo`)나 U+2800 점자 빈칸(`So`)은
630
+ * 보이지 않으면서 어느 배제 범주에도 안 들어간다. 여기가 막는 것은 「눈에 안 보이는 사유」
631
+ * 전부가 아니라 **결합·서식 문자 계열**이다.
632
+ *
633
+ * ■ `//` 뒤에도 개행을 안 넘는다
634
+ * 종전에는 `//\s*` 라 `//` 와 마커 사이에 개행이 있어도 매치했다 — 템플릿 리터럴이나 블록
635
+ * 주석 안의 `//` 가 다음 줄의 마커를 살렸다. 같은 가로 공백류로 좁힌다.
636
+ */
637
+ const ALLOW_RULES = new Set(["dynamic", "inline-style", "cross-origin", "custom-theme-inject"]);
638
+ /** 가로 공백류 — 개행을 뺀 공백. NBSP·전각공백을 **받는다**(붙여넣기에서 실제로 나온다). */
639
+ const H_SPACE = "[ \\t\\u00a0\\u1680\\u2000-\\u200a\\u202f\\u205f\\u3000]";
640
+ /** 사유의 첫 글자로 인정하지 않는 보이지 않는 글자 — 서식(`Cf`)·결합(`Mn`·`Me`). */
641
+ const INVISIBLE = "[\\p{Cf}\\p{Mn}\\p{Me}]";
642
+ const ALLOW_MARKERS = new Map();
643
+ function allowMarker(rule) {
644
+ // ⚠ **이름을 검증한다.** 오타는 유효한 정규식을 만들고 **매치만 안 한다** — 면제가 조용히
645
+ // 사라진다. 이 검증이 없던 판에서는 여덟 자리 중 다섯에서 한 글자를 바꿔도 시험이 전건
646
+ // 초록이었다(재현: 자리 하나의 규칙 이름에 글자를 더하고 `npm test` — 그 자리를 덮는 시험이
647
+ // 생기기 전에는 아무것도 안 죽었다). 모르면 던져서 그 자리에서 죽인다.
648
+ if (!ALLOW_RULES.has(rule)) throw new Error(`알 수 없는 면제 규칙: ${rule}`);
649
+ // 한 번만 만든다. 호출 자리 **여덟 중 일곱**이 파일 루프 안이다(S8 만 실행당 1회).
650
+ // V8 은 같은 소스 문자열이면 컴파일을 캐시하므로 이것이 막는 것은 컴파일이 아니라 **파싱·객체
651
+ // 할당**이다. 그 캐시는 GC 로 비워질 수 있어 보험값이 있다.
652
+ let re = ALLOW_MARKERS.get(rule);
653
+ if (re === undefined) {
654
+ re = new RegExp(
655
+ `//${H_SPACE}*(?:zalkera|oneq(?:ue?)?)-allow-${rule}:(?:${H_SPACE}|${INVISIBLE})*(?!\\s|${INVISIBLE})(\\S.*)`,
656
+ "u",
657
+ );
658
+ ALLOW_MARKERS.set(rule, re);
659
+ }
660
+ return re;
661
+ }
662
+
597
663
  /**
598
664
  * 모르는 `zalkera.styling` 값. 모드는 `none` 이지만 조용히 넘어가지 않는다.
599
665
  *
@@ -2075,19 +2141,6 @@ function isSeoPageFile(file) {
2075
2141
  return !dirSegments.some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
2076
2142
  }
2077
2143
 
2078
- // SEO page 에서 금지되는 per-page SSR 유발 패턴들.
2079
- const SSR_FORBIDDEN = [
2080
- {
2081
- re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/,
2082
- why: `export const dynamic = "force-dynamic" (전 요청 SSR 강제)`,
2083
- },
2084
- {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: `export const revalidate = 0 (ISR 무력화·매 요청 재생성)`},
2085
- {re: /\bcookies\s*\(/, why: `page 레벨 cookies() 호출 (동적 렌더 opt-in — 세션값은 클라이언트 컴포넌트로)`},
2086
- {re: /\bheaders\s*\(/, why: `page 레벨 headers() 호출 (동적 렌더 opt-in)`},
2087
- {re: /cache\s*:\s*["']no-store["']/, why: `fetch(..., { cache: "no-store" }) (캐시 불가·매 요청 fetch)`},
2088
- {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: `fetch(..., { next: { revalidate: 0 } }) (ISR 무력화)`},
2089
- ];
2090
-
2091
2144
  const WALK_MAX_DEPTH = 64;
2092
2145
 
2093
2146
  /**
@@ -2501,7 +2554,22 @@ function nextDynamicCalls(raw, text, code, where) {
2501
2554
  /** 이름 축을 안 태우는 나머지 — 형태가 곧 Next 계약이라 출처를 물을 것이 없다. */
2502
2555
  const DYNAMIC_API = [
2503
2556
  {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: "export const revalidate = 0", corpus: "code"},
2504
- {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: "fetch(..., {next: {revalidate: 0}})", corpus: "code"},
2557
+ // ⚠ **상한을 건다.** `[^}]*` 닫는 `}` 찾으면 시작 오프셋마다 끝까지 훑고 되돌아온다 —
2558
+ // `page.tsx` 에 `next:{` 를 반복한 입력에서 관문 관통 400KB **13,863ms → 250ms**
2559
+ // (빈 트리 기준선 190ms · 3회 · `repo()`/`timeGate()` 하네스).
2560
+ // ⚠ **이 형상은 어느 크기에서도 검사를 마치지 않는다**(이 자리와 무관한 선재 결함이고 네
2561
+ // 판본 전부에서 같다). `next:{` 1,222회(≈7.2KB)부터 TypeScript 파서가 스택을 넘겨 rc=2 이고,
2562
+ // 그 아래는 rc=0 이 아니라 **rc=7**(파스 실패 — 「읽지 못한 자리」)이다. 위 수치는 「검사를
2563
+ // 마친 시간」이 아니라 거기까지 간 시간이다. 상한이 지운 것은 그중 정규식 몫이고, 파스
2564
+ // 예산은 아직 없다.
2565
+ // 상한은 **문법이 낼 수 있는 현실적 길이**에서 골랐다 — 검사 대상(스토어프론트 트리)에는 이
2566
+ // 블록이 **0건**이다(재현: `grep -rE "next[[:space:]]*:[[:space:]]*\{" <스토어프론트 src>`).
2567
+ // `stripCommentsAndStrings` 가 문자열을 지우므로 `tags: ["a","b",…]` 는 길어지지 않는다 —
2568
+ // 붕괴 후 길이라 문자열 태그는 수십 개가 들어가고, 예산을 먹는 것은 **식별자 태그**다.
2569
+ // 몇 개부터 놓치는지는 이름 길이와 **구분자 표기**에 달렸다(`, ` 기준 9자면 23개·10자면
2570
+ // 21개부터, `,` 만 쓰면 25개·23개부터) — 숫자 하나로
2571
+ // 적을 수 없어 조건을 적는다.
2572
+ {re: /next\s*:\s*\{[^}]{0,256}\brevalidate\s*:\s*0\b/, why: "fetch(..., {next: {revalidate: 0}})", corpus: "code"},
2505
2573
  {re: /cache\s*:\s*["']no-store["']/, why: `fetch(..., {cache: "no-store"})`, corpus: "text"},
2506
2574
  {
2507
2575
  re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/,
@@ -2711,9 +2779,7 @@ function checkPageBlastRadius() {
2711
2779
  reported.add(key);
2712
2780
 
2713
2781
  // 면제는 **범인 파일**에 붙인다(C1b 와 같은 규칙) — page 에 붙이면 그 아래 import 가 한 번에 뚫린다.
2714
- const allow = (readSafe(file, "C1 면제 마커 판독") ?? "").match(
2715
- /\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:[ \t]*(\S.*)/,
2716
- );
2782
+ const allow = (readSafe(file, "C1 면제 마커 판독") ?? "").match(allowMarker("dynamic"));
2717
2783
  const own = file === page;
2718
2784
  const where = own
2719
2785
  ? `${relative(process.cwd(), page)}: SEO 라우트가 동적SSR 을 유발한다 → ${why}`
@@ -2746,9 +2812,7 @@ function checkLayoutBlastRadius() {
2746
2812
 
2747
2813
  // 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
2748
2814
  // 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
2749
- const allow = (readSafe(file, "C1b 면제 마커 판독") ?? "").match(
2750
- /\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:[ \t]*(\S.*)/,
2751
- );
2815
+ const allow = (readSafe(file, "C1b 면제 마커 판독") ?? "").match(allowMarker("dynamic"));
2752
2816
  const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
2753
2817
  const detail =
2754
2818
  `${relative(process.cwd(), layout)} 이 ${why} 에 도달한다 → ${path}. ` +
@@ -2827,8 +2891,8 @@ function checkThemeWiring() {
2827
2891
  // 실효 확인은 스모크 개시(실효층)가 맡는다. 마커는 원문에서 찾는다(주석이 곧 마커다).
2828
2892
  // ⚠ **`//` 앵커와 사유를 요구한다.** 앵커가 없으면 문자열 리터럴 안의 같은 글자가 면제로 먹는다 —
2829
2893
  // `export const NOTE = "zalkera-allow-custom-theme-inject"` 한 줄로 error 가 warning 이 됐다.
2830
- // 형제 마커(allow-dynamic·allow-inline-style·allow-cross-origin) 전부 형태다.
2831
- const sink = /\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-custom-theme-inject:[ \t]*\S/.test(raw) ? warnings : errors;
2894
+ // 판정은 `allowMarker()` 곳에서 만든다 — 자리마다 쓰면 갈린다(맨 위 `allowMarker` 참조).
2895
+ const sink = allowMarker("custom-theme-inject").test(raw) ? warnings : errors;
2832
2896
  sink.push(
2833
2897
  `[S8] ${relative(process.cwd(), rootLayout)}: 테마 주입 배선이 없습니다 — ` +
2834
2898
  `parseThemeColors(...) 호출 + <html style={...}> 가 있어야 콘솔의 색 변경이 화면에 반영됩니다. ` +
@@ -3248,13 +3312,11 @@ function check(file) {
3248
3312
  const s2 = styleSink("S2");
3249
3313
  // ⚠ 마커는 **원문(`src`)에서 찾는다.** `text` 는 `stripComments(src)` 라 `//` 주석 마커가
3250
3314
  // 원리적으로 매치될 수 없었다 — 이 규칙이 안내하는 탈출구가 **한 번도 작동한 적이 없다**
3251
- // (2026-08-01 첫 사용자가 발견). 형제 규칙 `allow-dynamic`(§S1)은 `readFileSync` 원문을 보므로
3252
- // 처음부터 옳았다 같은 관례를 곳이 다르게 구현하고 있었다.
3253
- if (
3254
- s2 &&
3255
- /style=\{\{(?!\s*["'`]--)/.test(text) &&
3256
- !/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-inline-style:/.test(src)
3257
- ) {
3315
+ // (2026-08-01 첫 사용자가 발견). 형제 규칙 `allow-dynamic`(§S1)은 `readFileSync` 원문을 본다 —
3316
+ // **읽는 대상**은 처음부터 옳았다. 다만 규칙도 판정 자리가 넷이고 그중 둘(C1p·C1pa)은
3317
+ // **정규식이 느슨했다**(맨몸 마커를 면제로 받고 다음 줄을 사유로 지어냈다). 같은 관례를
3318
+ // 여러 곳이 다르게 구현하고 있었다.
3319
+ if (s2 && /style=\{\{(?!\s*["'`]--)/.test(text) && !allowMarker("inline-style").test(src)) {
3258
3320
  s2.push(
3259
3321
  `[S2] ${rel}: JSX 인라인 style={{…}} 사용 — 스타일은 Tailwind 유틸리티 클래스로 표현하라. ` +
3260
3322
  `정당한 동적 스타일이면 \`// zalkera-allow-inline-style: <이유>\` 마커로 억제하라.`,
@@ -3640,7 +3702,14 @@ function findMutationHandlers(code) {
3640
3702
  }
3641
3703
 
3642
3704
  // ⒞ `export {POST}` · `export {handler as POST}` — 본문을 못 따라간다.
3643
- for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
3705
+ // **상한을 건다.** 닫는 `}` 가 없으면 2차다 — `route.ts` 에 `export{` 를 반복한 입력에서
3706
+ // 관문 관통 400KB **14,044ms → 375ms**(빈 트리 기준선 190ms · 3회 · `repo()`/`timeGate()`).
3707
+ //
3708
+ // 상한은 **이 규칙이 보는 자리**에서 골랐다 — 라우트 핸들러의 export 목록이고, 검사 대상
3709
+ // 트리에서 최대 **2자**다(`export {cn};` 꼴. 재현:
3710
+ // `grep -rE "export[[:space:]]*\{" <스토어프론트 src>`). 512 는 배럴 파일(수십 개 재export)
3711
+ // 까지 덮는 값이고, 라우트 핸들러가 그만큼 길어질 일은 없다.
3712
+ for (const m of code.matchAll(/export\s*\{([^}]{0,512})\}/g)) {
3644
3713
  for (const part of m[1].split(",")) {
3645
3714
  const name = part
3646
3715
  .trim()
@@ -3706,7 +3775,15 @@ function judgeGuardPlacement(rawBody) {
3706
3775
  }
3707
3776
 
3708
3777
  // 가드가 어디에도 없다 — 다른 갈래들과 사유가 다르다(고치는 방법이 다르다).
3709
- if (!new RegExp(CALL).test(body)) {
3778
+ // **여기서는 `CALL` 을 쓰지 않는다.** 이 줄은 「가드가 본문 어딘가에 있나」를 보는 존재
3779
+ // 확인이고, `(?:X)?Y` 의 부울값은 `Y` 의 부울값과 같다 — 그런데 `CALL` 앞의 `\w+` 가 긴 낱말
3780
+ // 런을 먹고 `.` 를 못 찾아 되돌아온다. 관문 관통 실측(`POST` 본문이 낱말 런 200KB):
3781
+ // **32,295ms → 247ms**(빈 트리 기준선 190ms · 3회 · `repo()`/`timeGate()`).
3782
+ // 오탐 통제군 — 같은 크기 평범한 본문은 양쪽 다 237ms 다(방아쇠가 크기가 아니라 낱말 런이다).
3783
+ //
3784
+ // `CALL` 자체는 아래 `^` 앵커 자리들에서 **네임스페이스 접두를 실제로 소비해야** 하므로
3785
+ // 상수를 바꾸지 않는다. 존재만 보는 이 한 줄만 접두 없는 형태로 본다.
3786
+ if (!/assertSameOrigin\s*\(/.test(body)) {
3710
3787
  return (
3711
3788
  `가 변이 메서드인데 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 열립니다.` +
3712
3789
  ` 정당한 예외면 파일 상단에 \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`
@@ -3798,7 +3875,7 @@ function checkCrossOriginGuards() {
3798
3875
  // 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
3799
3876
  // 조용히 면제되고, 마커를 읽는 사람이 그 사실을 모른다. 첫 `export` 앞까지만 본다.
3800
3877
  const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
3801
- const marker = head.match(/\/\/\s*zalkera-allow-cross-origin:\s*(.+)/);
3878
+ const marker = head.match(allowMarker("cross-origin"));
3802
3879
  if (marker) {
3803
3880
  exempted.push(`${rel} — ${marker[1].trim()}`);
3804
3881
  continue;
@@ -3983,11 +4060,18 @@ function checkPagesRouter() {
3983
4060
  const code = stripLiterals(raw, false); // X1p: 같은 이유
3984
4061
  // 파일 상단 마커로 면제. X1 과 같은 마커·같은 "첫 export 앞까지" 규칙.
3985
4062
  const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
3986
- if (/\/\/\s*zalkera-allow-cross-origin:/.test(raw.slice(0, head.length || raw.length))) continue;
4063
+ if (allowMarker("cross-origin").test(raw.slice(0, head.length || raw.length))) continue;
3987
4064
  // 비-GET 을 405 로 되돌리는 읽기 전용 라우트는 뺀다. 값 검사라 **원문**에 건다
3988
4065
  // (stripLiterals 를 거치면 `"GET"` 이 공백이 된다 — X3 가 그렇게 죽어 있었다).
3989
4066
  if (/\breq\.method\s*!==\s*["']GET["']/.test(raw)) continue;
3990
- if (!/(?:\w+\s*\.\s*)?assertSameOrigin\s*\(/.test(code)) {
4067
+ // ⚠ **선택 그룹을 두지 않는다.** 여기는 존재만 보는 `.test()` 이고 `(?:X)?Y` 의 부울값은
4068
+ // `Y` 의 부울값과 같다 — 그런데 `\w+` 가 긴 낱말 런을 먹고 `.` 를 못 찾아 되돌아온다.
4069
+ // 관문 관통 실측(`pages/api` 200KB): `assertSameOrigin` 반복 **26,254ms → 251ms**
4070
+ // (빈 트리 기준선 190ms · 3회 · `repo()`/`timeGate()`).
4071
+ // ⚠ **방아쇠는 그 이름이 아니라 긴 낱말 런이다** — 가드 이름이 0건인 무해한 식별자
4072
+ // 반복도 26,383ms → 254ms 다(번들·미니파이 산출물이 `pages/api` 에 들어가기만 해도 터진다).
4073
+ // 오탐 통제군(평범한 줄이 반복되는 같은 크기 파일)은 양쪽 다 236ms.
4074
+ if (!/assertSameOrigin\s*\(/.test(code)) {
3991
4075
  crossOriginSink().push(
3992
4076
  `[X1p] ${rel}: pages/api 핸들러에 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 ` +
3993
4077
  `열립니다. 이 형태(default export + req.method 분기)에서는 검사기가 **가드의 ` +
@@ -4007,7 +4091,7 @@ function checkPagesRouter() {
4007
4091
  // ── C1pa — `_app`/`_document` 의 getInitialProps: 사이트 전체 정적 최적화 해제.
4008
4092
  if (stem === "_app" || stem === "_document") {
4009
4093
  if (/\bgetInitialProps\b/.test(text)) {
4010
- const allow = raw.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
4094
+ const allow = raw.match(allowMarker("dynamic"));
4011
4095
  const detail =
4012
4096
  `${rel}: ${stem} 이 getInitialProps 를 씁니다 — **사이트 전 라우트**의 자동 정적 최적화가 ` +
4013
4097
  `꺼져 모든 페이지가 매 요청 SSR 이 됩니다(폭발반경이 layout 과 같습니다)`;
@@ -4027,9 +4111,9 @@ function checkPagesRouter() {
4027
4111
  const gssp =
4028
4112
  /export\s+(?:async\s+)?function\s+getServerSideProps\b/.test(text) ||
4029
4113
  /export\s+(?:const|let|var)\s+getServerSideProps\b/.test(text) ||
4030
- /export\s*\{[^}]*\bgetServerSideProps\b/.test(text);
4114
+ /export\s*\{[^}]{0,512}\bgetServerSideProps\b/.test(text);
4031
4115
  if (!gssp) continue;
4032
- const allow = raw.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
4116
+ const allow = raw.match(allowMarker("dynamic"));
4033
4117
  const detail = `${rel}: SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제합니다`;
4034
4118
  if (allow) warnings.push(`[C1p] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
4035
4119
  else
@@ -4106,7 +4190,18 @@ function isStaticallyReadableConfig(text) {
4106
4190
  if (/export\s+default\s+[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
4107
4191
  if (/module\.exports\s*=\s*[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
4108
4192
  // `import type {NextConfig} from "next"` 은 값을 안 나른다 — 그것만 허용한다.
4109
- for (const m of text.matchAll(/^\s*import\s+([^;\n]*)from\s/gm)) {
4193
+ // **줄머리 공백에서 줄바꿈류만 뺀다.** `^\s*` 의 `\s` 는 개행을 포함하고 `m` 플래그가 줄마다
4194
+ // `^` 를 세우므로, 개행만 있는 입력에서 줄머리마다 파일 끝까지 훑고 되돌아온다. 관문 관통 실측
4195
+ // (`next.config.mjs` 를 개행으로 채움 200KB): **26,184ms → 190ms**
4196
+ // (빈 트리 기준선 190ms · 3회 · `repo()`/`timeGate()` — 즉 기준선으로 되돌아왔다).
4197
+ // 오탐 통제군: 같은 크기 공백만 192→190ms · 정상 config 190→188ms(둘 다 이 형상에 안 걸린다).
4198
+ //
4199
+ // ⚠ **`[ \t]` 로 좁히면 안 된다.** JS `\s` 는 25자이고 그중 19자(`\v`·`\f`·NBSP·전각공백·
4200
+ // **BOM** 등)가 함께 빠진다. 그 하나가 첫 import 줄머리에 있으면 값 import 를 못 보고
4201
+ // `true` 를 돌려주어 **O1 거짓 경고**가 난다(실측: BOM·NBSP·전각공백·수직탭 각 1건, 그 문자만
4202
+ // 빼면 0건). UTF-8 BOM 은 Windows 도구가 그대로 내고 zip 업로드가 이 제품의 정본 입구다.
4203
+ // 그래서 빼는 것은 **줄바꿈류 넷**뿐이다 — 그것이 이 자리에서 말한 「가로 공백」이다.
4204
+ for (const m of text.matchAll(/^[^\S\n\r\u2028\u2029]*import\s+([^;\n]*)from\s/gm)) {
4110
4205
  if (!/^\s*type\b/.test(m[1])) return false;
4111
4206
  }
4112
4207
  return true;
package/llms.txt CHANGED
@@ -855,8 +855,7 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
855
855
  들여왔는지**를 본다 — 같은 이름의 자기 헬퍼는 걸리지 않는다.
856
856
  실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
857
857
  (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
858
- `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
859
- <이유>`)이 필요하다 — 서버 동적 렌더는 **상시 런타임 원가**라 SEO 라우트에서는 기본이 아니다.
858
+ `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석** `// zalkera-allow-dynamic: <이유>` 가 필요하다(마커와 사유는 **같은 줄**이어야 한다) — 서버 동적 렌더는 **상시 런타임 원가**라 SEO 라우트에서는 기본이 아니다.
860
859
 
861
860
  ## 5.1 산출물 규범 — 발견되는 사이트 (필수)
862
861
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalkera/client",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
5
  "license": "MIT",
6
6
  "author": "Credium Co., Ltd.",