@roottale/analytics-runtime 0.6.0 → 0.6.1

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.
@@ -64,7 +64,18 @@ loaded[tag.provider]=true;
64
64
  if(tag.provider==='ga4'){
65
65
  var s=document.createElement('script');s.async=true;
66
66
  s.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);
67
- document.head.appendChild(s);gtag('js',new Date());gtag('config',tag.id);
67
+ document.head.appendChild(s);
68
+ /* GA4 \uAE30\uBCF8 \uC218\uC9D1\uC740 %\uC778\uCF54\uB529\uB41C location.href \uB97C page_location \uC73C\uB85C \uC2E4\uC5B4 \uBCF4\uACE0\uC11C\uC5D0\uC11C
69
+ \uD55C\uAE00 \uACBD\uB85C\uAC00 %EC%9A%B8... \uB85C \uAE68\uC9C4\uB2E4. decodeURI \uB85C \uD480\uC5B4(\uAD6C\uC870 \uBB38\uC790 /?#& \uC720\uC9C0,
70
+ \uC798\uBABB\uB41C \uC778\uCF54\uB529\uC774\uBA74 \uC6D0\uBB38 \uC720\uC9C0) 'set' \uC73C\uB85C \uBA85\uC2DC\uD558\uACE0 SPA \uC774\uB3D9(pushState/replaceState/
71
+ popstate)\uB9C8\uB2E4 \uAC31\uC2E0\uD55C\uB2E4. gtag.js \uBCF8\uCCB4\uB294 \uC774 \uB85C\uB354\uBCF4\uB2E4 \uB2A6\uAC8C history \uB97C \uBC14\uAE65\uC5D0\uC11C
72
+ \uAC10\uC2F8\uBBC0\uB85C, \uC5EC\uAE30 \uAC31\uC2E0\uC774 \uD5A5\uC0C1\uB41C \uCE21\uC815\uC758 \uC790\uB3D9 page_view \uBC1C\uD654\uBCF4\uB2E4 \uD56D\uC0C1 \uBA3C\uC800 \uC801\uC6A9\uB41C\uB2E4. */
73
+ var dec=function(u){try{return decodeURI(u)}catch(e){return u}};
74
+ var syncLoc=function(){gtag('set',{page_location:dec(location.href)});};
75
+ syncLoc();
76
+ ['pushState','replaceState'].forEach(function(m){var o=history[m];history[m]=function(){var r=o.apply(this,arguments);syncLoc();return r;};});
77
+ window.addEventListener('popstate',syncLoc);
78
+ gtag('js',new Date());gtag('config',tag.id);
68
79
  }else if(tag.provider==='clarity'){
69
80
  (function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
70
81
  var t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;
@@ -165,4 +176,4 @@ export {
165
176
  renderJourneyBridgeScript,
166
177
  readJourney
167
178
  };
168
- //# sourceMappingURL=chunk-EHXYDR7C.js.map
179
+ //# sourceMappingURL=chunk-KS5MJEOM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/consent.ts","../src/loader.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2 + Amend 4 — 동의 정책 + provider↔consent 매핑.\n//\n// Amend 4 (2026-08-19): 통계는 기본 허용, 광고는 기본 거부.\n// 한국법엔 유럽식 쿠키 배너(사전 옵트인) 의무가 없다 — 통계 목적 분석 도구는\n// 처리방침 고지로 충분하고, 동의가 실질 요구되는 건 행태정보를 **맞춤형\n// 광고**에 쓸 때부터다(개보위 2022 구글·메타 처분 영역). 따라서 분석\n// 카테고리를 fail-closed 로 잠그면 법이 요구하지 않는 배너 손실(무시층\n// 30~50%)만 남는다. 광고 카테고리는 그대로 fail-closed — 광고 도구는 동의\n// UI 없이는 영원히 발화하지 않는다(현재 광고 연동 계획 없음이 사업 결정).\n\nimport type { ConsentCategory, ConsentState, TagProvider } from \"./types\";\n\nexport const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[] = [\n \"analytics_storage\",\n \"ad_storage\",\n \"ad_user_data\",\n \"ad_personalization\",\n] as const;\n\n/**\n * 방문자의 명시적 동의 선택 저장 키(localStorage) — 과거 배너가 저장한 값을\n * 로더가 계속 읽는다. **과거에 \"거부\"를 누른 방문자의 선택은 기본값 변경\n * 후에도 존중된다**(기본값은 선택 기록이 없는 방문자에게만 적용).\n */\nexport const CONSENT_STORAGE_KEY = \"rt_consent_v1\";\n\n/** 전부 denied — 광고 도구 게이트·테스트 기준값으로 유지. */\nexport const DENIED_CONSENT: ConsentState = {\n analytics_storage: \"denied\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/** 기본 동의(Amend 4) — 통계 granted · 광고 denied. */\nexport const DEFAULT_CONSENT: ConsentState = {\n analytics_storage: \"granted\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/**\n * provider 가 발화하기 전 granted 여야 하는 동의 카테고리.\n * - analytics 도구(GA4·Clarity·Naver) → `analytics_storage`\n * - 광고 도구(Meta Pixel) → `ad_storage` + `ad_user_data` + `ad_personalization`\n * (국외이전·광고목적이라 동의 요건 가장 강함)\n */\nexport const PROVIDER_CONSENT: Record<TagProvider, ConsentCategory[]> = {\n ga4: [\"analytics_storage\"],\n clarity: [\"analytics_storage\"],\n naver: [\"analytics_storage\"],\n meta_pixel: [\"ad_storage\", \"ad_user_data\", \"ad_personalization\"],\n};\n\n/** required 카테고리가 *모두* granted 일 때만 true. */\nexport function isTagAllowed(\n required: readonly ConsentCategory[],\n consent: ConsentState,\n): boolean {\n return required.every((c) => consent[c] === \"granted\");\n}\n","// ADR-0038 §2·§4 + Amend 4 — 공유 주입 산출물. 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default =\n// 통계 granted · 광고 denied (Amend 4). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 초기\n// applyAll() 이 동의 충족 태그(분석 3종)를 즉시 주입하고, 광고 도구는\n// 동의 update(`RootTaleAnalytics.updateConsent`) 전까지 발화하지 않는다.\n// 과거 배너가 localStorage 에 남긴 명시적 선택(CONSENT_STORAGE_KEY)이\n// 있으면 기본값보다 우선한다 — 예전에 \"거부\"한 방문자는 계속 거부 상태.\n//\n// 설계(measurement-design-draft.md) §0 게이트 계약 — `RootTaleAnalytics.\n// isTagLoaded(provider)` 로 태그 로드 완료 상태를 노출한다(내부 `loaded` 맵\n// 그대로 read-only 조회). `ga4-bridge.ts` 가 GA4 미러 발화 전 \"동의 granted\n// 그리고 GA4 로드 완료\" 를 함께 확인하는 데 쓴다.\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\nimport type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩 — 통계 granted · 광고 denied (Amend 4).\n * 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다. `wait_for_update` 는\n * 로더가 저장된 과거 선택을 적용할 짧은 창.\n */\nexport function renderConsentBootstrap(): string {\n return [\n \"window.dataLayer=window.dataLayer||[];\",\n \"function gtag(){dataLayer.push(arguments);}\",\n \"gtag('consent','default',{\",\n \"'ad_storage':'denied','ad_user_data':'denied',\",\n \"'ad_personalization':'denied','analytics_storage':'granted',\",\n \"'wait_for_update':500});\",\n ].join(\"\");\n}\n\n// 브라우저 런타임 — manifest 를 읽고 동의가 충족된 태그만 주입한다.\n// `__RT_MANIFEST__` 는 renderLoaderScript 가 JSON 으로 치환.\nconst RUNTIME = `(function(){\nvar M=__RT_MANIFEST__;\nvar consent=Object.assign({},M.consentDefault);\n/* 과거 배너가 저장한 명시적 선택이 있으면 기본값을 덮는다 — \"거부\"를 눌렀던\n 방문자는 기본값이 granted 로 바뀌어도 거부 상태를 유지한다(Amend 4). */\ntry{\nvar stored=JSON.parse(localStorage.getItem(__RT_CONSENT_KEY__)||'null');\nif(stored&&typeof stored==='object'){\nfor(var sk in stored){if(Object.prototype.hasOwnProperty.call(consent,sk))consent[sk]=stored[sk];}\nif(typeof gtag==='function'){gtag('consent','update',stored);}\n}\n}catch(e){}\nvar loaded={};\nfunction allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}\nfunction inject(tag){\nif(loaded[tag.provider]||!allowed(tag.requiredConsent))return;\nloaded[tag.provider]=true;\nif(tag.provider==='ga4'){\nvar s=document.createElement('script');s.async=true;\ns.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);\ndocument.head.appendChild(s);\n/* GA4 기본 수집은 %인코딩된 location.href 를 page_location 으로 실어 보고서에서\n 한글 경로가 %EC%9A%B8... 로 깨진다. decodeURI 로 풀어(구조 문자 /?#& 유지,\n 잘못된 인코딩이면 원문 유지) 'set' 으로 명시하고 SPA 이동(pushState/replaceState/\n popstate)마다 갱신한다. gtag.js 본체는 이 로더보다 늦게 history 를 바깥에서\n 감싸므로, 여기 갱신이 향상된 측정의 자동 page_view 발화보다 항상 먼저 적용된다. */\nvar dec=function(u){try{return decodeURI(u)}catch(e){return u}};\nvar syncLoc=function(){gtag('set',{page_location:dec(location.href)});};\nsyncLoc();\n['pushState','replaceState'].forEach(function(m){var o=history[m];history[m]=function(){var r=o.apply(this,arguments);syncLoc();return r;};});\nwindow.addEventListener('popstate',syncLoc);\ngtag('js',new Date());gtag('config',tag.id);\n}else if(tag.provider==='clarity'){\n(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\nvar t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;\nvar y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,'clarity','script',tag.id);\n}else if(tag.provider==='meta_pixel'){\n(function(f,b,e,v){if(f.fbq)return;var n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};\nif(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];var t=b.createElement(e);t.async=!0;\nt.src=v;var s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)})(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init',tag.id);fbq('track','PageView');\n}else if(tag.provider==='naver'){\nvar w=document.createElement('script');w.async=true;w.src='//wcs.naver.net/wcslog.js';\nw.onload=function(){if(window.wcs){window.wcs_add={wa:tag.id};if(window.wcs.inflow)window.wcs.inflow();if(window.wcs_do)window.wcs_do();}};\ndocument.head.appendChild(w);\n}}\nfunction applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}\nwindow.RootTaleAnalytics={\nmanifest:M,\ngetConsent:function(){return Object.assign({},consent);},\nisTagLoaded:function(provider){return !!loaded[provider];},\nupdateConsent:function(partial){\nfor(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}\nif(typeof gtag==='function'){gtag('consent','update',partial);}\napplyAll();\n}};\napplyAll();\n})();`;\n\n/**\n * manifest 를 embed 한 로더 스크립트. 초기 `applyAll()` 이 동의 충족 태그\n * (기본값에선 분석 도구)를 즉시 주입한다. 광고 도구는 `requiredConsent` 의\n * ad_* 카테고리가 기본 denied 라 `updateConsent` 없이는 절대 붙지 않는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return (\n \"var __RT_MANIFEST__=\" +\n JSON.stringify(manifest) +\n \";var __RT_CONSENT_KEY__=\" +\n JSON.stringify(CONSENT_STORAGE_KEY) +\n \";\" +\n RUNTIME\n );\n}\n","// 설계(measurement-design-draft.md) §0(ADR-0038 amend) — GA4 미러 브리지.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtGa4Mirror` 전역 훅이\n// 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그 훅을\n// 설치할 뿐 beacon 을 직접 건드리지 않는다 — 순서 무관하게 안전(훅이 아직\n// 없을 때 이벤트가 나면 그냥 스킵, 다음 이벤트부터 미러링).\n//\n// 게이트(설계 §0): events.ts `GA4_MIRROR` 에 등록된 이벤트 + 동의\n// `analytics_storage==='granted'` + GA4 태그 로드 완료(`isTagLoaded('ga4')`) +\n// `typeof gtag==='function'` **전부** 참일 때만 `gtag('event', ga4Name, params)`.\n// 하나라도 불충족이면 그냥 버린다 — dataLayer 에 선큐잉해 동의 이후 소급\n// 전송하지 않는다(큐·재생 금지). 확정 전환 수는 어디까지나 inquiries DB —\n// 본 미러는 광고 생태계용 근사 신호일 뿐이다(설계 §0 SSoT 계약).\n\nimport { GA4_MIRROR } from \"./events\";\n\nconst RUNTIME = `(function(){\nvar MIRROR=__RT_GA4_MIRROR__;\nwindow.__rtGa4Mirror=function(ev,extra){\nvar name=MIRROR[ev];\nif(!name)return;\nvar RA=window.RootTaleAnalytics;\nif(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;\nif(RA.getConsent().analytics_storage!=='granted')return;\nif(!RA.isTagLoaded('ga4'))return;\nif(typeof gtag!=='function')return;\ngtag('event',name,extra||{});\n};\n})();`;\n\n/**\n * GA4 미러 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와 함께\n * (순서 무관) 주입한다. `enableGa4Bridge={false}` 로 아예 생략 가능(react.tsx).\n */\nexport function renderGa4BridgeScript(): string {\n return \"var __RT_GA4_MIRROR__=\" + JSON.stringify(GA4_MIRROR) + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 GA4 미러 훅(설치돼 있을 때만). */\n __rtGa4Mirror?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n","// 설계(measurement-design-draft.md) §3 — 방문 여정(journey) 링버퍼.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtJourneyRecord` 전역\n// 훅이 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그\n// 훅을 설치할 뿐이다 — `events.ts` `TRACK_EVENTS[ev].journey` 가 true 인\n// 이벤트(pageview + 전환 사다리)만 sessionStorage `_rt_journey` 링버퍼(최대\n// 30항목)에 적재한다. scroll_depth·section_view 등 고빈도 감지 이벤트는\n// journey:false 라 기록되지 않는다.\n//\n// 서버측 계약(설계 §3, 본 모듈 범위 밖): vertical(medical/legal) 게이트는\n// 서버가 재강제하고, 저장은 `inquiries.journey` jsonb 컬럼 + zod 재구성 +\n// 180일 보존이다 — 클라이언트 캡처는 신뢰의 원천이 아니다.\n\nimport { TRACK_EVENTS, type TrackEventName } from \"./events\";\n\n/** sessionStorage 키 — cms-client/renderer 와의 공유 계약(변경 시 양쪽 동시 갱신). */\nexport const JOURNEY_STORAGE_KEY = \"_rt_journey\";\n\n/** 링버퍼 최대 항목 수(변경 시 RUNTIME 내 하드코딩 리터럴도 동시 갱신). */\nexport const JOURNEY_MAX_ITEMS = 30;\n\n/** `_rt_journey` 항목 형태. */\nexport interface JourneyEntry {\n /** 캡처 시각 (epoch ms). */\n t: number;\n /** 이벤트 이름 (events.ts 사전). */\n ev: string;\n /** 발생 경로 (pathname). vertical 게이트 대상은 서버에서 제거될 수 있음. */\n path?: string;\n /** 소형 차원 데이터 (data-track-* 로 캡처된 extra). */\n d?: Record<string, unknown>;\n}\n\n/** journey:true 이벤트 이름 목록 — events.ts SSoT 에서 파생. */\nconst JOURNEY_EVENT_NAMES: TrackEventName[] = (\n Object.keys(TRACK_EVENTS) as TrackEventName[]\n).filter((name) => TRACK_EVENTS[name].journey);\n\nconst RUNTIME = `(function(){\nvar JE=__RT_JOURNEY_EVENTS__;\nwindow.__rtJourneyRecord=function(ev,extra){\nif(!JE||JE.indexOf(ev)===-1)return;\nvar KEY='_rt_journey';\nvar arr=null;\ntry{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}\nif(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];\nvar item={t:Date.now(),ev:ev};\ntry{if(location&&location.pathname)item.path=location.pathname;}catch(e){}\nif(extra)item.d=extra;\narr.push(item);\nif(arr.length>30)arr=arr.slice(arr.length-30);\nsessionStorage.setItem(KEY,JSON.stringify(arr));\n};\n})();`;\n\n/**\n * journey 기록 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와\n * 함께(순서 무관) 항상 주입한다 — 1차 캡처 인프라라 opt-out 프롭 없음(ga4\n * 미러와 달리).\n */\nexport function renderJourneyBridgeScript(): string {\n return (\n \"var __RT_JOURNEY_EVENTS__=\" +\n JSON.stringify(JOURNEY_EVENT_NAMES) +\n \";\" +\n RUNTIME\n );\n}\n\n/**\n * sessionStorage 의 journey 링버퍼를 읽는다(브라우저 전용). 파싱 실패·미존재\n * 시 빈 배열. cms-client/renderer 가 문의 제출 시 이 값을 재직렬화해 동봉한다\n * (submit capture/`formdata` 시점 — 설계 §3 P1-3, hidden 스냅샷 아님).\n */\nexport function readJourney(): JourneyEntry[] {\n if (typeof window === \"undefined\" || typeof sessionStorage === \"undefined\") {\n return [];\n }\n try {\n const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);\n if (!raw) return [];\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? (parsed as JourneyEntry[]) : [];\n } catch {\n return [];\n }\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 journey 기록 훅(설치돼 있을 때만). */\n __rtJourneyRecord?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n"],"mappings":";;;;;;AAYO,IAAM,yBAAqD;AAAA,EAChE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,sBAAsB;AAG5B,IAAM,iBAA+B;AAAA,EAC1C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAGO,IAAM,kBAAgC;AAAA,EAC3C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAQO,IAAM,mBAA2D;AAAA,EACtE,KAAK,CAAC,mBAAmB;AAAA,EACzB,SAAS,CAAC,mBAAmB;AAAA,EAC7B,OAAO,CAAC,mBAAmB;AAAA,EAC3B,YAAY,CAAC,cAAc,gBAAgB,oBAAoB;AACjE;AAGO,SAAS,aACd,UACA,SACS;AACT,SAAO,SAAS,MAAM,CAAC,MAAM,QAAQ,CAAC,MAAM,SAAS;AACvD;;;ACrCO,SAAS,yBAAiC;AAC/C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAIA,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgET,SAAS,mBAAmB,UAAqC;AACtE,SACE,yBACA,KAAK,UAAU,QAAQ,IACvB,6BACA,KAAK,UAAU,mBAAmB,IAClC,MACA;AAEJ;;;AC9FA,IAAMA,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBT,SAAS,wBAAgC;AAC9C,SAAO,2BAA2B,KAAK,UAAU,UAAU,IAAI,MAAMA;AACvE;;;ACpBO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAejC,IAAM,sBACJ,OAAO,KAAK,YAAY,EACxB,OAAO,CAAC,SAAS,aAAa,IAAI,EAAE,OAAO;AAE7C,IAAMC,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBT,SAAS,4BAAoC;AAClD,SACE,+BACA,KAAK,UAAU,mBAAmB,IAClC,MACAA;AAEJ;AAOO,SAAS,cAA8B;AAC5C,MAAI,OAAO,WAAW,eAAe,OAAO,mBAAmB,aAAa;AAC1E,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,mBAAmB;AACtD,QAAI,CAAC,IAAK,QAAO,CAAC;AAClB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,MAAM,QAAQ,MAAM,IAAK,SAA4B,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":["RUNTIME","RUNTIME"]}
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  renderGa4BridgeScript,
13
13
  renderJourneyBridgeScript,
14
14
  renderLoaderScript
15
- } from "./chunk-EHXYDR7C.js";
15
+ } from "./chunk-KS5MJEOM.js";
16
16
  import {
17
17
  initObservers,
18
18
  teardownObservers
package/dist/react.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  renderGa4BridgeScript,
4
4
  renderJourneyBridgeScript,
5
5
  renderLoaderScript
6
- } from "./chunk-EHXYDR7C.js";
6
+ } from "./chunk-KS5MJEOM.js";
7
7
  import {
8
8
  renderBeaconScript
9
9
  } from "./chunk-K3NVHH7K.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/analytics-runtime",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "ROOT-ANALYTICS runtime — privacy-first page, click, content-view and attribution collection with Consent Mode v2 external-tag loading.",
6
6
  "main": "./dist/index.js",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/consent.ts","../src/loader.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2 + Amend 4 — 동의 정책 + provider↔consent 매핑.\n//\n// Amend 4 (2026-08-19): 통계는 기본 허용, 광고는 기본 거부.\n// 한국법엔 유럽식 쿠키 배너(사전 옵트인) 의무가 없다 — 통계 목적 분석 도구는\n// 처리방침 고지로 충분하고, 동의가 실질 요구되는 건 행태정보를 **맞춤형\n// 광고**에 쓸 때부터다(개보위 2022 구글·메타 처분 영역). 따라서 분석\n// 카테고리를 fail-closed 로 잠그면 법이 요구하지 않는 배너 손실(무시층\n// 30~50%)만 남는다. 광고 카테고리는 그대로 fail-closed — 광고 도구는 동의\n// UI 없이는 영원히 발화하지 않는다(현재 광고 연동 계획 없음이 사업 결정).\n\nimport type { ConsentCategory, ConsentState, TagProvider } from \"./types\";\n\nexport const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[] = [\n \"analytics_storage\",\n \"ad_storage\",\n \"ad_user_data\",\n \"ad_personalization\",\n] as const;\n\n/**\n * 방문자의 명시적 동의 선택 저장 키(localStorage) — 과거 배너가 저장한 값을\n * 로더가 계속 읽는다. **과거에 \"거부\"를 누른 방문자의 선택은 기본값 변경\n * 후에도 존중된다**(기본값은 선택 기록이 없는 방문자에게만 적용).\n */\nexport const CONSENT_STORAGE_KEY = \"rt_consent_v1\";\n\n/** 전부 denied — 광고 도구 게이트·테스트 기준값으로 유지. */\nexport const DENIED_CONSENT: ConsentState = {\n analytics_storage: \"denied\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/** 기본 동의(Amend 4) — 통계 granted · 광고 denied. */\nexport const DEFAULT_CONSENT: ConsentState = {\n analytics_storage: \"granted\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/**\n * provider 가 발화하기 전 granted 여야 하는 동의 카테고리.\n * - analytics 도구(GA4·Clarity·Naver) → `analytics_storage`\n * - 광고 도구(Meta Pixel) → `ad_storage` + `ad_user_data` + `ad_personalization`\n * (국외이전·광고목적이라 동의 요건 가장 강함)\n */\nexport const PROVIDER_CONSENT: Record<TagProvider, ConsentCategory[]> = {\n ga4: [\"analytics_storage\"],\n clarity: [\"analytics_storage\"],\n naver: [\"analytics_storage\"],\n meta_pixel: [\"ad_storage\", \"ad_user_data\", \"ad_personalization\"],\n};\n\n/** required 카테고리가 *모두* granted 일 때만 true. */\nexport function isTagAllowed(\n required: readonly ConsentCategory[],\n consent: ConsentState,\n): boolean {\n return required.every((c) => consent[c] === \"granted\");\n}\n","// ADR-0038 §2·§4 + Amend 4 — 공유 주입 산출물. 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default =\n// 통계 granted · 광고 denied (Amend 4). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 초기\n// applyAll() 이 동의 충족 태그(분석 3종)를 즉시 주입하고, 광고 도구는\n// 동의 update(`RootTaleAnalytics.updateConsent`) 전까지 발화하지 않는다.\n// 과거 배너가 localStorage 에 남긴 명시적 선택(CONSENT_STORAGE_KEY)이\n// 있으면 기본값보다 우선한다 — 예전에 \"거부\"한 방문자는 계속 거부 상태.\n//\n// 설계(measurement-design-draft.md) §0 게이트 계약 — `RootTaleAnalytics.\n// isTagLoaded(provider)` 로 태그 로드 완료 상태를 노출한다(내부 `loaded` 맵\n// 그대로 read-only 조회). `ga4-bridge.ts` 가 GA4 미러 발화 전 \"동의 granted\n// 그리고 GA4 로드 완료\" 를 함께 확인하는 데 쓴다.\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\nimport type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩 — 통계 granted · 광고 denied (Amend 4).\n * 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다. `wait_for_update` 는\n * 로더가 저장된 과거 선택을 적용할 짧은 창.\n */\nexport function renderConsentBootstrap(): string {\n return [\n \"window.dataLayer=window.dataLayer||[];\",\n \"function gtag(){dataLayer.push(arguments);}\",\n \"gtag('consent','default',{\",\n \"'ad_storage':'denied','ad_user_data':'denied',\",\n \"'ad_personalization':'denied','analytics_storage':'granted',\",\n \"'wait_for_update':500});\",\n ].join(\"\");\n}\n\n// 브라우저 런타임 — manifest 를 읽고 동의가 충족된 태그만 주입한다.\n// `__RT_MANIFEST__` 는 renderLoaderScript 가 JSON 으로 치환.\nconst RUNTIME = `(function(){\nvar M=__RT_MANIFEST__;\nvar consent=Object.assign({},M.consentDefault);\n/* 과거 배너가 저장한 명시적 선택이 있으면 기본값을 덮는다 — \"거부\"를 눌렀던\n 방문자는 기본값이 granted 로 바뀌어도 거부 상태를 유지한다(Amend 4). */\ntry{\nvar stored=JSON.parse(localStorage.getItem(__RT_CONSENT_KEY__)||'null');\nif(stored&&typeof stored==='object'){\nfor(var sk in stored){if(Object.prototype.hasOwnProperty.call(consent,sk))consent[sk]=stored[sk];}\nif(typeof gtag==='function'){gtag('consent','update',stored);}\n}\n}catch(e){}\nvar loaded={};\nfunction allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}\nfunction inject(tag){\nif(loaded[tag.provider]||!allowed(tag.requiredConsent))return;\nloaded[tag.provider]=true;\nif(tag.provider==='ga4'){\nvar s=document.createElement('script');s.async=true;\ns.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);\ndocument.head.appendChild(s);gtag('js',new Date());gtag('config',tag.id);\n}else if(tag.provider==='clarity'){\n(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\nvar t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;\nvar y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,'clarity','script',tag.id);\n}else if(tag.provider==='meta_pixel'){\n(function(f,b,e,v){if(f.fbq)return;var n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};\nif(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];var t=b.createElement(e);t.async=!0;\nt.src=v;var s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)})(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init',tag.id);fbq('track','PageView');\n}else if(tag.provider==='naver'){\nvar w=document.createElement('script');w.async=true;w.src='//wcs.naver.net/wcslog.js';\nw.onload=function(){if(window.wcs){window.wcs_add={wa:tag.id};if(window.wcs.inflow)window.wcs.inflow();if(window.wcs_do)window.wcs_do();}};\ndocument.head.appendChild(w);\n}}\nfunction applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}\nwindow.RootTaleAnalytics={\nmanifest:M,\ngetConsent:function(){return Object.assign({},consent);},\nisTagLoaded:function(provider){return !!loaded[provider];},\nupdateConsent:function(partial){\nfor(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}\nif(typeof gtag==='function'){gtag('consent','update',partial);}\napplyAll();\n}};\napplyAll();\n})();`;\n\n/**\n * manifest 를 embed 한 로더 스크립트. 초기 `applyAll()` 이 동의 충족 태그\n * (기본값에선 분석 도구)를 즉시 주입한다. 광고 도구는 `requiredConsent` 의\n * ad_* 카테고리가 기본 denied 라 `updateConsent` 없이는 절대 붙지 않는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return (\n \"var __RT_MANIFEST__=\" +\n JSON.stringify(manifest) +\n \";var __RT_CONSENT_KEY__=\" +\n JSON.stringify(CONSENT_STORAGE_KEY) +\n \";\" +\n RUNTIME\n );\n}\n","// 설계(measurement-design-draft.md) §0(ADR-0038 amend) — GA4 미러 브리지.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtGa4Mirror` 전역 훅이\n// 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그 훅을\n// 설치할 뿐 beacon 을 직접 건드리지 않는다 — 순서 무관하게 안전(훅이 아직\n// 없을 때 이벤트가 나면 그냥 스킵, 다음 이벤트부터 미러링).\n//\n// 게이트(설계 §0): events.ts `GA4_MIRROR` 에 등록된 이벤트 + 동의\n// `analytics_storage==='granted'` + GA4 태그 로드 완료(`isTagLoaded('ga4')`) +\n// `typeof gtag==='function'` **전부** 참일 때만 `gtag('event', ga4Name, params)`.\n// 하나라도 불충족이면 그냥 버린다 — dataLayer 에 선큐잉해 동의 이후 소급\n// 전송하지 않는다(큐·재생 금지). 확정 전환 수는 어디까지나 inquiries DB —\n// 본 미러는 광고 생태계용 근사 신호일 뿐이다(설계 §0 SSoT 계약).\n\nimport { GA4_MIRROR } from \"./events\";\n\nconst RUNTIME = `(function(){\nvar MIRROR=__RT_GA4_MIRROR__;\nwindow.__rtGa4Mirror=function(ev,extra){\nvar name=MIRROR[ev];\nif(!name)return;\nvar RA=window.RootTaleAnalytics;\nif(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;\nif(RA.getConsent().analytics_storage!=='granted')return;\nif(!RA.isTagLoaded('ga4'))return;\nif(typeof gtag!=='function')return;\ngtag('event',name,extra||{});\n};\n})();`;\n\n/**\n * GA4 미러 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와 함께\n * (순서 무관) 주입한다. `enableGa4Bridge={false}` 로 아예 생략 가능(react.tsx).\n */\nexport function renderGa4BridgeScript(): string {\n return \"var __RT_GA4_MIRROR__=\" + JSON.stringify(GA4_MIRROR) + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 GA4 미러 훅(설치돼 있을 때만). */\n __rtGa4Mirror?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n","// 설계(measurement-design-draft.md) §3 — 방문 여정(journey) 링버퍼.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtJourneyRecord` 전역\n// 훅이 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그\n// 훅을 설치할 뿐이다 — `events.ts` `TRACK_EVENTS[ev].journey` 가 true 인\n// 이벤트(pageview + 전환 사다리)만 sessionStorage `_rt_journey` 링버퍼(최대\n// 30항목)에 적재한다. scroll_depth·section_view 등 고빈도 감지 이벤트는\n// journey:false 라 기록되지 않는다.\n//\n// 서버측 계약(설계 §3, 본 모듈 범위 밖): vertical(medical/legal) 게이트는\n// 서버가 재강제하고, 저장은 `inquiries.journey` jsonb 컬럼 + zod 재구성 +\n// 180일 보존이다 — 클라이언트 캡처는 신뢰의 원천이 아니다.\n\nimport { TRACK_EVENTS, type TrackEventName } from \"./events\";\n\n/** sessionStorage 키 — cms-client/renderer 와의 공유 계약(변경 시 양쪽 동시 갱신). */\nexport const JOURNEY_STORAGE_KEY = \"_rt_journey\";\n\n/** 링버퍼 최대 항목 수(변경 시 RUNTIME 내 하드코딩 리터럴도 동시 갱신). */\nexport const JOURNEY_MAX_ITEMS = 30;\n\n/** `_rt_journey` 항목 형태. */\nexport interface JourneyEntry {\n /** 캡처 시각 (epoch ms). */\n t: number;\n /** 이벤트 이름 (events.ts 사전). */\n ev: string;\n /** 발생 경로 (pathname). vertical 게이트 대상은 서버에서 제거될 수 있음. */\n path?: string;\n /** 소형 차원 데이터 (data-track-* 로 캡처된 extra). */\n d?: Record<string, unknown>;\n}\n\n/** journey:true 이벤트 이름 목록 — events.ts SSoT 에서 파생. */\nconst JOURNEY_EVENT_NAMES: TrackEventName[] = (\n Object.keys(TRACK_EVENTS) as TrackEventName[]\n).filter((name) => TRACK_EVENTS[name].journey);\n\nconst RUNTIME = `(function(){\nvar JE=__RT_JOURNEY_EVENTS__;\nwindow.__rtJourneyRecord=function(ev,extra){\nif(!JE||JE.indexOf(ev)===-1)return;\nvar KEY='_rt_journey';\nvar arr=null;\ntry{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}\nif(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];\nvar item={t:Date.now(),ev:ev};\ntry{if(location&&location.pathname)item.path=location.pathname;}catch(e){}\nif(extra)item.d=extra;\narr.push(item);\nif(arr.length>30)arr=arr.slice(arr.length-30);\nsessionStorage.setItem(KEY,JSON.stringify(arr));\n};\n})();`;\n\n/**\n * journey 기록 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와\n * 함께(순서 무관) 항상 주입한다 — 1차 캡처 인프라라 opt-out 프롭 없음(ga4\n * 미러와 달리).\n */\nexport function renderJourneyBridgeScript(): string {\n return (\n \"var __RT_JOURNEY_EVENTS__=\" +\n JSON.stringify(JOURNEY_EVENT_NAMES) +\n \";\" +\n RUNTIME\n );\n}\n\n/**\n * sessionStorage 의 journey 링버퍼를 읽는다(브라우저 전용). 파싱 실패·미존재\n * 시 빈 배열. cms-client/renderer 가 문의 제출 시 이 값을 재직렬화해 동봉한다\n * (submit capture/`formdata` 시점 — 설계 §3 P1-3, hidden 스냅샷 아님).\n */\nexport function readJourney(): JourneyEntry[] {\n if (typeof window === \"undefined\" || typeof sessionStorage === \"undefined\") {\n return [];\n }\n try {\n const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);\n if (!raw) return [];\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? (parsed as JourneyEntry[]) : [];\n } catch {\n return [];\n }\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 journey 기록 훅(설치돼 있을 때만). */\n __rtJourneyRecord?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n"],"mappings":";;;;;;AAYO,IAAM,yBAAqD;AAAA,EAChE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,sBAAsB;AAG5B,IAAM,iBAA+B;AAAA,EAC1C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAGO,IAAM,kBAAgC;AAAA,EAC3C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAQO,IAAM,mBAA2D;AAAA,EACtE,KAAK,CAAC,mBAAmB;AAAA,EACzB,SAAS,CAAC,mBAAmB;AAAA,EAC7B,OAAO,CAAC,mBAAmB;AAAA,EAC3B,YAAY,CAAC,cAAc,gBAAgB,oBAAoB;AACjE;AAGO,SAAS,aACd,UACA,SACS;AACT,SAAO,SAAS,MAAM,CAAC,MAAM,QAAQ,CAAC,MAAM,SAAS;AACvD;;;ACrCO,SAAS,yBAAiC;AAC/C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAIA,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqDT,SAAS,mBAAmB,UAAqC;AACtE,SACE,yBACA,KAAK,UAAU,QAAQ,IACvB,6BACA,KAAK,UAAU,mBAAmB,IAClC,MACA;AAEJ;;;ACnFA,IAAMA,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBT,SAAS,wBAAgC;AAC9C,SAAO,2BAA2B,KAAK,UAAU,UAAU,IAAI,MAAMA;AACvE;;;ACpBO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAejC,IAAM,sBACJ,OAAO,KAAK,YAAY,EACxB,OAAO,CAAC,SAAS,aAAa,IAAI,EAAE,OAAO;AAE7C,IAAMC,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBT,SAAS,4BAAoC;AAClD,SACE,+BACA,KAAK,UAAU,mBAAmB,IAClC,MACAA;AAEJ;AAOO,SAAS,cAA8B;AAC5C,MAAI,OAAO,WAAW,eAAe,OAAO,mBAAmB,aAAa;AAC1E,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,mBAAmB;AACtD,QAAI,CAAC,IAAK,QAAO,CAAC;AAClB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,MAAM,QAAQ,MAAM,IAAK,SAA4B,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":["RUNTIME","RUNTIME"]}