@roottale/analytics-runtime 0.5.1 → 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.
- package/dist/{chunk-DL4PER2S.js → chunk-GV4CWEWY.js} +2 -2
- package/dist/{chunk-7LLWILEY.js → chunk-K3NVHH7K.js} +55 -31
- package/dist/{chunk-7LLWILEY.js.map → chunk-K3NVHH7K.js.map} +1 -1
- package/dist/chunk-KIOFZXJX.js +99 -0
- package/dist/chunk-KIOFZXJX.js.map +1 -0
- package/dist/{chunk-GZ6RRTAD.js → chunk-KS5MJEOM.js} +60 -59
- package/dist/chunk-KS5MJEOM.js.map +1 -0
- package/dist/entry-channels.d.ts +52 -0
- package/dist/entry-channels.js +13 -0
- package/dist/entry-channels.js.map +1 -0
- package/dist/index.d.ts +41 -10
- package/dist/index.js +75 -29
- package/dist/index.js.map +1 -1
- package/dist/next.js +2 -2
- package/dist/react.d.ts +2 -6
- package/dist/react.js +2 -25
- package/dist/react.js.map +1 -1
- package/dist/{consent-banner-WVGsxcY2.d.ts → types-VA3xp0bt.d.ts} +1 -12
- package/package.json +6 -1
- package/dist/chunk-GZ6RRTAD.js.map +0 -1
- /package/dist/{chunk-DL4PER2S.js.map → chunk-GV4CWEWY.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
trackCustomEvent
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-K3NVHH7K.js";
|
|
4
4
|
|
|
5
5
|
// src/observers.ts
|
|
6
6
|
var SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
@@ -197,4 +197,4 @@ export {
|
|
|
197
197
|
initObservers,
|
|
198
198
|
teardownObservers
|
|
199
199
|
};
|
|
200
|
-
//# sourceMappingURL=chunk-
|
|
200
|
+
//# sourceMappingURL=chunk-GV4CWEWY.js.map
|
|
@@ -39,54 +39,78 @@ var ds=el.dataset||{};
|
|
|
39
39
|
for(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}
|
|
40
40
|
send(el.getAttribute('data-track'),extra);
|
|
41
41
|
},{capture:true});
|
|
42
|
+
/* \uC720\uC785 \uC2E0\uD638 1\uD68C \uACC4\uC0B0 \u2014 \uC5B4\uD2B8\uB9AC\uBDF0\uC158 \uC800\uC7A5(\uC544\uB798 C.attr \uBE14\uB85D)\uACFC pageview \uB3D9\uBD09\uC774
|
|
43
|
+
\uAC19\uC740 \uAC12\uC744 \uC4F4\uB2E4. referrer \uB294 \uD638\uC2A4\uD2B8\uBA85\uB9CC(\uACBD\uB85C\xB7\uCFFC\uB9AC \uBBF8\uD3EC\uD568, www \uC81C\uAC70),
|
|
44
|
+
gclid/fbclid \uB294 \uC874\uC7AC \uC5EC\uBD80(gc:1)\uB9CC \u2014 \uC6D0\uAC12\uC740 \uC5B4\uB514\uC5D0\uB3C4 \uC5C6\uB2E4. */
|
|
45
|
+
var SIG=(function(){var o={};
|
|
46
|
+
try{
|
|
47
|
+
var sp=new URLSearchParams(location.search);
|
|
48
|
+
var rs=sp.get('rt_src');if(rs)o.rs=rs.slice(0,32);
|
|
49
|
+
var us=sp.get('utm_source');if(us)o.us=us.slice(0,100);
|
|
50
|
+
var um=sp.get('utm_medium');if(um)o.um=um.slice(0,100);
|
|
51
|
+
var uc=sp.get('utm_campaign');if(uc)o.uc=uc.slice(0,100);
|
|
52
|
+
var ut=sp.get('utm_term');if(ut)o.ut=ut.slice(0,100);
|
|
53
|
+
var un=sp.get('utm_content');if(un)o.un=un.slice(0,100);
|
|
54
|
+
if(sp.get('gclid')||sp.get('fbclid'))o.gc=1;
|
|
55
|
+
}catch(e){}
|
|
56
|
+
try{
|
|
57
|
+
if(document.referrer){
|
|
58
|
+
var rh=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\./,'');
|
|
59
|
+
var oh=location.hostname.toLowerCase().replace(/^www\\./,'');
|
|
60
|
+
if(rh&&rh!==oh)o.rh=rh.slice(0,253);
|
|
61
|
+
}
|
|
62
|
+
}catch(e){}
|
|
63
|
+
return o;})();
|
|
42
64
|
if(C.attr){
|
|
43
65
|
try{
|
|
44
66
|
var AK='_rt_attr';
|
|
45
67
|
var cur=null;
|
|
46
68
|
try{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}
|
|
47
69
|
var expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;
|
|
48
|
-
var sp=new URLSearchParams(location.search);
|
|
49
|
-
var rs=sp.get('rt_src');
|
|
50
|
-
var us=sp.get('utm_source');
|
|
51
|
-
var um=sp.get('utm_medium');
|
|
52
|
-
var uc=sp.get('utm_campaign');
|
|
53
|
-
var ut=sp.get('utm_term');
|
|
54
|
-
var un=sp.get('utm_content');
|
|
55
|
-
var hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));
|
|
56
|
-
var extRef=null;
|
|
57
|
-
try{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}
|
|
58
70
|
var env=cur;
|
|
59
71
|
if(expired){
|
|
60
72
|
env={v:2,lp:location.pathname,ts:Date.now()};
|
|
61
|
-
if(rs)env.rs=rs
|
|
62
|
-
if(us)env.us=us
|
|
63
|
-
if(um)env.um=um
|
|
64
|
-
if(uc)env.uc=uc
|
|
65
|
-
if(ut)env.ut=ut
|
|
66
|
-
if(un)env.un=un
|
|
67
|
-
if(
|
|
68
|
-
if(
|
|
73
|
+
if(SIG.rs)env.rs=SIG.rs;
|
|
74
|
+
if(SIG.us)env.us=SIG.us;
|
|
75
|
+
if(SIG.um)env.um=SIG.um;
|
|
76
|
+
if(SIG.uc)env.uc=SIG.uc;
|
|
77
|
+
if(SIG.ut)env.ut=SIG.ut;
|
|
78
|
+
if(SIG.un)env.un=SIG.un;
|
|
79
|
+
if(SIG.gc)env.gcl=1;
|
|
80
|
+
if(SIG.rh)env.rf=SIG.rh;
|
|
69
81
|
}
|
|
70
82
|
var ch=null;
|
|
71
|
-
if(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(
|
|
83
|
+
if(SIG.rs){ch='rs';}else if(SIG.us||SIG.um||SIG.uc||SIG.ut||SIG.un){ch='utm';}else if(SIG.gc){ch='gcl';}else if(SIG.rh){ch='rf';}
|
|
72
84
|
if(ch){
|
|
73
85
|
var lt={ts:Date.now(),ch:ch};
|
|
74
|
-
if(rs)lt.rs=rs
|
|
75
|
-
if(us)lt.us=us
|
|
76
|
-
if(um)lt.um=um
|
|
77
|
-
if(uc)lt.uc=uc
|
|
78
|
-
if(ut)lt.ut=ut
|
|
79
|
-
if(un)lt.un=un
|
|
80
|
-
if(
|
|
81
|
-
if(
|
|
86
|
+
if(SIG.rs)lt.rs=SIG.rs;
|
|
87
|
+
if(SIG.us)lt.us=SIG.us;
|
|
88
|
+
if(SIG.um)lt.um=SIG.um;
|
|
89
|
+
if(SIG.uc)lt.uc=SIG.uc;
|
|
90
|
+
if(SIG.ut)lt.ut=SIG.ut;
|
|
91
|
+
if(SIG.un)lt.un=SIG.un;
|
|
92
|
+
if(SIG.gc)lt.gcl=1;
|
|
93
|
+
if(SIG.rh)lt.rf=SIG.rh;
|
|
82
94
|
env.lt=lt;
|
|
83
95
|
}
|
|
84
96
|
if(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}
|
|
85
97
|
}catch(e){}
|
|
86
98
|
}
|
|
87
|
-
if(C.pv){var _pid=metaPid();var _air=aiRef();var _x=
|
|
88
|
-
if(_pid
|
|
89
|
-
|
|
99
|
+
if(C.pv){var _pid=metaPid();var _air=aiRef();var _x={};
|
|
100
|
+
if(_pid)_x.pid=_pid;
|
|
101
|
+
if(_air)_x.air=_air;
|
|
102
|
+
/* AI \uCC57\uBD07 \uC720\uC785\uC740 \uB77C\uBCA8(air)\uB9CC \u2014 \uD638\uC2A4\uD2B8\uBA85\uB3C4 \uBCF4\uB0B4\uC9C0 \uC54A\uB294\uB2E4(\uAE30\uC874 \uBD88\uBCC0\uC2DD \uC720\uC9C0). */
|
|
103
|
+
if(SIG.rh&&!_air)_x.rh=SIG.rh;
|
|
104
|
+
if(SIG.us)_x.us=SIG.us;
|
|
105
|
+
if(SIG.um)_x.um=SIG.um;
|
|
106
|
+
if(SIG.uc)_x.uc=SIG.uc;
|
|
107
|
+
if(SIG.rs)_x.rs=SIG.rs;
|
|
108
|
+
if(SIG.gc)_x.gc=1;
|
|
109
|
+
/* \uBE0C\uB77C\uC6B0\uC800 \uC5B8\uC5B4 1\uCC28 \uC11C\uBE0C\uD0DC\uADF8\uB9CC(ko\xB7en\xB7ja\xB7\xB7\xB7) \u2014 \uC678\uAD6D\uC778 \uBC29\uBB38 \uBE44\uC728\uC6A9. \uC804\uCCB4
|
|
110
|
+
locale \uBB38\uC790\uC5F4(ko-KR \uB4F1)\uC740 \uC9C0\uBB38\uC131\uBD84\uC774 \uB298\uC5B4 \uBCF4\uB0B4\uC9C0 \uC54A\uB294\uB2E4. */
|
|
111
|
+
try{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}
|
|
112
|
+
var _has=false;for(var _q in _x){_has=true;break;}
|
|
113
|
+
send('pageview',_has?_x:null);}
|
|
90
114
|
})();`;
|
|
91
115
|
function renderBeaconScript(options) {
|
|
92
116
|
const cfg = JSON.stringify({
|
|
@@ -108,4 +132,4 @@ export {
|
|
|
108
132
|
renderBeaconScript,
|
|
109
133
|
trackCustomEvent
|
|
110
134
|
};
|
|
111
|
-
//# sourceMappingURL=chunk-
|
|
135
|
+
//# sourceMappingURL=chunk-K3NVHH7K.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nimport { aiReferralRulesLiteral } from \"./ai-referrals\";\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\nvar AIR=${aiReferralRulesLiteral()};\n/* AI 챗봇 유입 판정 — 원문 referrer 는 보내지 않고 고정 목록의 라벨만 남긴다.\n 목록 밖(일반 사이트·검색엔진)은 null 이라 아무것도 전송되지 않는다. */\nfunction aiRef(){try{\nif(!document.referrer)return null;\nvar h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nif(!h||h===location.hostname.toLowerCase().replace(/^www\\\\./,''))return null;\nfor(var i=0;i<AIR.length;i++){var r=AIR[i][0];if(h===r||h.slice(-(r.length+1))==='.'+r)return AIR[i][1];}\nreturn null;\n}catch(e){return null;}}\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};\nif(extra){for(var k in extra){p[k]=extra[k];}}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');\nvar us=sp.get('utm_source');\nvar um=sp.get('utm_medium');\nvar uc=sp.get('utm_campaign');\nvar ut=sp.get('utm_term');\nvar un=sp.get('utm_content');\nvar hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));\nvar extRef=null;\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(rs)env.rs=rs.slice(0,32);\nif(us)env.us=us.slice(0,100);\nif(um)env.um=um.slice(0,100);\nif(uc)env.uc=uc.slice(0,100);\nif(ut)env.ut=ut.slice(0,100);\nif(un)env.un=un.slice(0,100);\nif(hasGcl)env.gcl=1;\nif(extRef)env.rf=extRef;\n}\nvar ch=null;\nif(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(hasGcl){ch='gcl';}else if(extRef){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(rs)lt.rs=rs.slice(0,32);\nif(us)lt.us=us.slice(0,100);\nif(um)lt.um=um.slice(0,100);\nif(uc)lt.uc=uc.slice(0,100);\nif(ut)lt.ut=ut.slice(0,100);\nif(un)lt.un=un.slice(0,100);\nif(hasGcl)lt.gcl=1;\nif(extRef)lt.rf=extRef;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();var _air=aiRef();var _x=null;\nif(_pid||_air){_x={};if(_pid)_x.pid=_pid;if(_air)_x.air=_air;}\nsend('pageview',_x);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";;;;;AA2DO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA,UAEN,uBAAuB,CAAC;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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4F3B,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nimport { aiReferralRulesLiteral } from \"./ai-referrals\";\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\nvar AIR=${aiReferralRulesLiteral()};\n/* AI 챗봇 유입 판정 — 원문 referrer 는 보내지 않고 고정 목록의 라벨만 남긴다.\n 목록 밖(일반 사이트·검색엔진)은 null 이라 아무것도 전송되지 않는다. */\nfunction aiRef(){try{\nif(!document.referrer)return null;\nvar h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nif(!h||h===location.hostname.toLowerCase().replace(/^www\\\\./,''))return null;\nfor(var i=0;i<AIR.length;i++){var r=AIR[i][0];if(h===r||h.slice(-(r.length+1))==='.'+r)return AIR[i][1];}\nreturn null;\n}catch(e){return null;}}\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};\nif(extra){for(var k in extra){p[k]=extra[k];}}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\n/* 유입 신호 1회 계산 — 어트리뷰션 저장(아래 C.attr 블록)과 pageview 동봉이\n 같은 값을 쓴다. referrer 는 호스트명만(경로·쿼리 미포함, www 제거),\n gclid/fbclid 는 존재 여부(gc:1)만 — 원값은 어디에도 없다. */\nvar SIG=(function(){var o={};\ntry{\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');if(rs)o.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)o.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)o.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)o.uc=uc.slice(0,100);\nvar ut=sp.get('utm_term');if(ut)o.ut=ut.slice(0,100);\nvar un=sp.get('utm_content');if(un)o.un=un.slice(0,100);\nif(sp.get('gclid')||sp.get('fbclid'))o.gc=1;\n}catch(e){}\ntry{\nif(document.referrer){\nvar rh=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nvar oh=location.hostname.toLowerCase().replace(/^www\\\\./,'');\nif(rh&&rh!==oh)o.rh=rh.slice(0,253);\n}\n}catch(e){}\nreturn o;})();\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(SIG.rs)env.rs=SIG.rs;\nif(SIG.us)env.us=SIG.us;\nif(SIG.um)env.um=SIG.um;\nif(SIG.uc)env.uc=SIG.uc;\nif(SIG.ut)env.ut=SIG.ut;\nif(SIG.un)env.un=SIG.un;\nif(SIG.gc)env.gcl=1;\nif(SIG.rh)env.rf=SIG.rh;\n}\nvar ch=null;\nif(SIG.rs){ch='rs';}else if(SIG.us||SIG.um||SIG.uc||SIG.ut||SIG.un){ch='utm';}else if(SIG.gc){ch='gcl';}else if(SIG.rh){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(SIG.rs)lt.rs=SIG.rs;\nif(SIG.us)lt.us=SIG.us;\nif(SIG.um)lt.um=SIG.um;\nif(SIG.uc)lt.uc=SIG.uc;\nif(SIG.ut)lt.ut=SIG.ut;\nif(SIG.un)lt.un=SIG.un;\nif(SIG.gc)lt.gcl=1;\nif(SIG.rh)lt.rf=SIG.rh;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();var _air=aiRef();var _x={};\nif(_pid)_x.pid=_pid;\nif(_air)_x.air=_air;\n/* AI 챗봇 유입은 라벨(air)만 — 호스트명도 보내지 않는다(기존 불변식 유지). */\nif(SIG.rh&&!_air)_x.rh=SIG.rh;\nif(SIG.us)_x.us=SIG.us;\nif(SIG.um)_x.um=SIG.um;\nif(SIG.uc)_x.uc=SIG.uc;\nif(SIG.rs)_x.rs=SIG.rs;\nif(SIG.gc)_x.gc=1;\n/* 브라우저 언어 1차 서브태그만(ko·en·ja···) — 외국인 방문 비율용. 전체\n locale 문자열(ko-KR 등)은 지문성분이 늘어 보내지 않는다. */\ntry{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}\nvar _has=false;for(var _q in _x){_has=true;break;}\nsend('pageview',_has?_x:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * 유입 경로 신호(2026-08-19) — pageview 에만 동봉, 전부 있을 때만 실린다:\n * `rh` 외부 referrer **호스트명**(경로·쿼리 없음, www 제거) · `us`/`um`/`uc`\n * utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재\n * 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full\n * locale 미전송). 판정은 서버가 한다 —\n * `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널\n * 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).\n * raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";;;;;AA2DO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA,UAEN,uBAAuB,CAAC;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;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;AA6H3B,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/entry-channels.ts
|
|
2
|
+
var ENTRY_CHANNEL_IDS = [
|
|
3
|
+
"search",
|
|
4
|
+
"direct",
|
|
5
|
+
"referral",
|
|
6
|
+
"social",
|
|
7
|
+
"paid",
|
|
8
|
+
"ai",
|
|
9
|
+
"shortlink",
|
|
10
|
+
"other"
|
|
11
|
+
];
|
|
12
|
+
function isEntryChannelId(value) {
|
|
13
|
+
return ENTRY_CHANNEL_IDS.includes(value);
|
|
14
|
+
}
|
|
15
|
+
var ENTRY_CHANNEL_LABEL = {
|
|
16
|
+
search: "\uAC80\uC0C9",
|
|
17
|
+
direct: "\uC8FC\uC18C \uC785\uB825\xB7\uC990\uACA8\uCC3E\uAE30",
|
|
18
|
+
referral: "\uB2E4\uB978 \uC0AC\uC774\uD2B8 \uB9C1\uD06C",
|
|
19
|
+
social: "\uC18C\uC15C \uBBF8\uB514\uC5B4",
|
|
20
|
+
paid: "\uAD11\uACE0",
|
|
21
|
+
ai: "AI \uCC57\uBD07",
|
|
22
|
+
shortlink: "\uB2E8\uCD95\uB9C1\uD06C\xB7QR",
|
|
23
|
+
other: "\uAE30\uD0C0 \uCEA0\uD398\uC778"
|
|
24
|
+
};
|
|
25
|
+
var SEARCH_HOSTS = [
|
|
26
|
+
{ host: "google.com" },
|
|
27
|
+
{ host: "google.co.kr" },
|
|
28
|
+
{ host: "search.naver.com" },
|
|
29
|
+
{ host: "naver.com", exact: true },
|
|
30
|
+
{ host: "m.naver.com", exact: true },
|
|
31
|
+
{ host: "search.daum.net" },
|
|
32
|
+
{ host: "daum.net", exact: true },
|
|
33
|
+
{ host: "m.daum.net", exact: true },
|
|
34
|
+
{ host: "bing.com" },
|
|
35
|
+
{ host: "duckduckgo.com" },
|
|
36
|
+
{ host: "search.yahoo.com" },
|
|
37
|
+
{ host: "yandex.com" },
|
|
38
|
+
{ host: "baidu.com" },
|
|
39
|
+
{ host: "search.zum.com" },
|
|
40
|
+
{ host: "zum.com", exact: true },
|
|
41
|
+
{ host: "search.nate.com" },
|
|
42
|
+
{ host: "nate.com", exact: true }
|
|
43
|
+
];
|
|
44
|
+
var SOCIAL_HOSTS = [
|
|
45
|
+
{ host: "facebook.com" },
|
|
46
|
+
{ host: "instagram.com" },
|
|
47
|
+
{ host: "threads.net" },
|
|
48
|
+
{ host: "twitter.com" },
|
|
49
|
+
{ host: "x.com" },
|
|
50
|
+
{ host: "t.co", exact: true },
|
|
51
|
+
{ host: "linkedin.com" },
|
|
52
|
+
{ host: "youtube.com" },
|
|
53
|
+
{ host: "youtu.be", exact: true },
|
|
54
|
+
{ host: "tiktok.com" },
|
|
55
|
+
{ host: "pinterest.com" },
|
|
56
|
+
{ host: "band.us" },
|
|
57
|
+
{ host: "story.kakao.com" },
|
|
58
|
+
{ host: "pf.kakao.com" },
|
|
59
|
+
{ host: "cafe.naver.com" },
|
|
60
|
+
{ host: "cafe.daum.net" }
|
|
61
|
+
];
|
|
62
|
+
function matchesRules(host, rules) {
|
|
63
|
+
for (const rule of rules) {
|
|
64
|
+
if (host === rule.host) return true;
|
|
65
|
+
if (!rule.exact && host.endsWith(`.${rule.host}`)) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
var PAID_MEDIUM_RE = /^(cpc|ppc|cpm|cpv|cpa|display|banner|paid[\w-]*|retargeting|remarketing)$/i;
|
|
70
|
+
var SOCIAL_MEDIUM_RE = /^(social|sns|social[-_]media)$/i;
|
|
71
|
+
function classifyEntryChannel(signals) {
|
|
72
|
+
const host = signals.referrerHost?.trim().toLowerCase() || null;
|
|
73
|
+
const medium = signals.utmMedium?.trim() || null;
|
|
74
|
+
const hasUtm = Boolean(
|
|
75
|
+
signals.utmSource?.trim() || medium || signals.utmCampaign?.trim()
|
|
76
|
+
);
|
|
77
|
+
if (signals.aiReferralId) return "ai";
|
|
78
|
+
if (signals.rtSrc?.trim()) return "shortlink";
|
|
79
|
+
if (signals.hasAdClick || medium && PAID_MEDIUM_RE.test(medium)) {
|
|
80
|
+
return "paid";
|
|
81
|
+
}
|
|
82
|
+
if (medium && SOCIAL_MEDIUM_RE.test(medium)) return "social";
|
|
83
|
+
if (host) {
|
|
84
|
+
if (matchesRules(host, SEARCH_HOSTS)) return "search";
|
|
85
|
+
if (matchesRules(host, SOCIAL_HOSTS)) return "social";
|
|
86
|
+
}
|
|
87
|
+
if (hasUtm) return "other";
|
|
88
|
+
if (host) return "referral";
|
|
89
|
+
if (signals.hasReferrer) return null;
|
|
90
|
+
return "direct";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export {
|
|
94
|
+
ENTRY_CHANNEL_IDS,
|
|
95
|
+
isEntryChannelId,
|
|
96
|
+
ENTRY_CHANNEL_LABEL,
|
|
97
|
+
classifyEntryChannel
|
|
98
|
+
};
|
|
99
|
+
//# sourceMappingURL=chunk-KIOFZXJX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/entry-channels.ts"],"sourcesContent":["/**\n * 유입 경로(entry channel) 분류 — ROOT-ANALYTICS 자체 수집의 \"어디서 왔나\".\n *\n * 비콘은 신호(외부 referrer **호스트명**·utm·광고클릭 유무·rt_src·AI 라벨)만\n * 보내고, 판정은 서버(`api-core /v1/collect`)가 이 모듈로 한다 — 판정 목록을\n * 고치면 워커 배포만으로 전 사이트에 반영된다(고객 사이트 재배포 불필요).\n *\n * Privacy(ADR-0038 §2 동의 면제 유지): 입력이 이미 비식별이다 — referrer 는\n * 경로·쿼리 없는 호스트명뿐, gclid/fbclid 는 존재 여부 불리언뿐(원값 미수신),\n * utm 은 고객이 자기 캠페인 링크에 직접 붙인 라벨이다. 출력은 고정 사전의\n * id 하나라 임의 문자열이 차원에 들어갈 수 없다.\n *\n * GA4 화면의 6묶음(search/direct/referral/social/paid/other)과 말을 맞추되,\n * 자체 수집만 아는 두 가지를 더 가른다 — `ai`(챗봇 유입, ai-referrals.ts 와\n * 동일 사전) · `shortlink`(roottale.link 단축링크/QR 의 rt_src).\n *\n * 이 파일은 브라우저 비콘 생성기·서버 양쪽에서 참조하므로 런타임 의존이\n * 없어야 한다(ai-referrals.ts 와 동일 제약).\n */\n\nexport const ENTRY_CHANNEL_IDS = [\n \"search\",\n \"direct\",\n \"referral\",\n \"social\",\n \"paid\",\n \"ai\",\n \"shortlink\",\n \"other\",\n] as const;\n\nexport type EntryChannelId = (typeof ENTRY_CHANNEL_IDS)[number];\n\n/** 수집 라우트·어드민 조회의 입력 검증 — 사전 밖 문자열 차단. */\nexport function isEntryChannelId(value: string): value is EntryChannelId {\n return (ENTRY_CHANNEL_IDS as readonly string[]).includes(value);\n}\n\n/** 고객 화면 라벨 — GA4 6묶음과 같은 말을 쓴다(두 화면이 다르게 부르지 않게). */\nexport const ENTRY_CHANNEL_LABEL: Record<EntryChannelId, string> = {\n search: \"검색\",\n direct: \"주소 입력·즐겨찾기\",\n referral: \"다른 사이트 링크\",\n social: \"소셜 미디어\",\n paid: \"광고\",\n ai: \"AI 챗봇\",\n shortlink: \"단축링크·QR\",\n other: \"기타 캠페인\",\n};\n\n/**\n * 호스트 규칙 — 기본은 정확 일치 또는 서브도메인(`.` 접미) 일치.\n * `exact` 는 정확 일치만: `naver.com` 을 접미 매칭하면 `blog.naver.com`(블로그\n * = 다른 사이트 링크)까지 검색으로 잘못 묶인다.\n */\ntype HostRule = { readonly host: string; readonly exact?: true };\n\nconst SEARCH_HOSTS: readonly HostRule[] = [\n { host: \"google.com\" },\n { host: \"google.co.kr\" },\n { host: \"search.naver.com\" },\n { host: \"naver.com\", exact: true },\n { host: \"m.naver.com\", exact: true },\n { host: \"search.daum.net\" },\n { host: \"daum.net\", exact: true },\n { host: \"m.daum.net\", exact: true },\n { host: \"bing.com\" },\n { host: \"duckduckgo.com\" },\n { host: \"search.yahoo.com\" },\n { host: \"yandex.com\" },\n { host: \"baidu.com\" },\n { host: \"search.zum.com\" },\n { host: \"zum.com\", exact: true },\n { host: \"search.nate.com\" },\n { host: \"nate.com\", exact: true },\n];\n\nconst SOCIAL_HOSTS: readonly HostRule[] = [\n { host: \"facebook.com\" },\n { host: \"instagram.com\" },\n { host: \"threads.net\" },\n { host: \"twitter.com\" },\n { host: \"x.com\" },\n { host: \"t.co\", exact: true },\n { host: \"linkedin.com\" },\n { host: \"youtube.com\" },\n { host: \"youtu.be\", exact: true },\n { host: \"tiktok.com\" },\n { host: \"pinterest.com\" },\n { host: \"band.us\" },\n { host: \"story.kakao.com\" },\n { host: \"pf.kakao.com\" },\n { host: \"cafe.naver.com\" },\n { host: \"cafe.daum.net\" },\n];\n\n/** 부분 문자열 매칭 금지 — `naver.com.evil.com` 같은 위장 도메인 차단. */\nfunction matchesRules(host: string, rules: readonly HostRule[]): boolean {\n for (const rule of rules) {\n if (host === rule.host) return true;\n if (!rule.exact && host.endsWith(`.${rule.host}`)) return true;\n }\n return false;\n}\n\n/** utm_medium 유료 표기 — GA4 의 paid 판정 표기와 겹치는 관용 값들. */\nconst PAID_MEDIUM_RE =\n /^(cpc|ppc|cpm|cpv|cpa|display|banner|paid[\\w-]*|retargeting|remarketing)$/i;\n\nconst SOCIAL_MEDIUM_RE = /^(social|sns|social[-_]media)$/i;\n\nexport interface EntryChannelSignals {\n /** 외부 referrer 호스트명(소문자, www 제거) — 같은 호스트면 비콘이 생략. */\n readonly referrerHost?: string | null;\n readonly utmSource?: string | null;\n readonly utmMedium?: string | null;\n readonly utmCampaign?: string | null;\n /** gclid/fbclid 존재 여부 — 원값은 어디에도 없다. */\n readonly hasAdClick?: boolean;\n /** roottale.link 단축링크/QR 토큰(rt_src). */\n readonly rtSrc?: string | null;\n /** 비콘이 판정한 AI 챗봇 라벨(ai-referrals 사전 통과분). */\n readonly aiReferralId?: string | null;\n /** referrer 존재 여부(내부 이동 포함) — 비콘 `ref` 플래그. */\n readonly hasReferrer?: boolean;\n}\n\n/**\n * 한 pageview 의 유입 경로 판정. **`null` = 유입이 아님**(내부 이동 — 같은\n * 사이트 안에서 다음 페이지로 넘어온 조회). 집계에서 내부 이동을 빼야\n * \"채널별 유입\"이 실제 들어온 횟수를 뜻한다.\n *\n * 우선순위: ai > shortlink > paid > social(utm) > search(host) >\n * social(host) > other(utm만) > referral(그 외 외부 host) > 내부/직접.\n * utm_medium 명시가 host 추정보다 먼저다 — 고객이 직접 붙인 라벨이 더 정확.\n */\nexport function classifyEntryChannel(\n signals: EntryChannelSignals,\n): EntryChannelId | null {\n const host = signals.referrerHost?.trim().toLowerCase() || null;\n const medium = signals.utmMedium?.trim() || null;\n const hasUtm = Boolean(\n signals.utmSource?.trim() || medium || signals.utmCampaign?.trim(),\n );\n\n if (signals.aiReferralId) return \"ai\";\n if (signals.rtSrc?.trim()) return \"shortlink\";\n if (signals.hasAdClick || (medium && PAID_MEDIUM_RE.test(medium))) {\n return \"paid\";\n }\n if (medium && SOCIAL_MEDIUM_RE.test(medium)) return \"social\";\n if (host) {\n if (matchesRules(host, SEARCH_HOSTS)) return \"search\";\n if (matchesRules(host, SOCIAL_HOSTS)) return \"social\";\n }\n if (hasUtm) return \"other\"; // email·뉴스레터 등 — 라벨은 utm 원문으로 확인\n if (host) return \"referral\";\n // referrer 는 있는데 외부 호스트가 아니다 = 내부 이동 → 유입 아님.\n if (signals.hasReferrer) return null;\n return \"direct\";\n}\n"],"mappings":";AAoBO,IAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAKO,SAAS,iBAAiB,OAAwC;AACvE,SAAQ,kBAAwC,SAAS,KAAK;AAChE;AAGO,IAAM,sBAAsD;AAAA,EACjE,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,OAAO;AACT;AASA,IAAM,eAAoC;AAAA,EACxC,EAAE,MAAM,aAAa;AAAA,EACrB,EAAE,MAAM,eAAe;AAAA,EACvB,EAAE,MAAM,mBAAmB;AAAA,EAC3B,EAAE,MAAM,aAAa,OAAO,KAAK;AAAA,EACjC,EAAE,MAAM,eAAe,OAAO,KAAK;AAAA,EACnC,EAAE,MAAM,kBAAkB;AAAA,EAC1B,EAAE,MAAM,YAAY,OAAO,KAAK;AAAA,EAChC,EAAE,MAAM,cAAc,OAAO,KAAK;AAAA,EAClC,EAAE,MAAM,WAAW;AAAA,EACnB,EAAE,MAAM,iBAAiB;AAAA,EACzB,EAAE,MAAM,mBAAmB;AAAA,EAC3B,EAAE,MAAM,aAAa;AAAA,EACrB,EAAE,MAAM,YAAY;AAAA,EACpB,EAAE,MAAM,iBAAiB;AAAA,EACzB,EAAE,MAAM,WAAW,OAAO,KAAK;AAAA,EAC/B,EAAE,MAAM,kBAAkB;AAAA,EAC1B,EAAE,MAAM,YAAY,OAAO,KAAK;AAClC;AAEA,IAAM,eAAoC;AAAA,EACxC,EAAE,MAAM,eAAe;AAAA,EACvB,EAAE,MAAM,gBAAgB;AAAA,EACxB,EAAE,MAAM,cAAc;AAAA,EACtB,EAAE,MAAM,cAAc;AAAA,EACtB,EAAE,MAAM,QAAQ;AAAA,EAChB,EAAE,MAAM,QAAQ,OAAO,KAAK;AAAA,EAC5B,EAAE,MAAM,eAAe;AAAA,EACvB,EAAE,MAAM,cAAc;AAAA,EACtB,EAAE,MAAM,YAAY,OAAO,KAAK;AAAA,EAChC,EAAE,MAAM,aAAa;AAAA,EACrB,EAAE,MAAM,gBAAgB;AAAA,EACxB,EAAE,MAAM,UAAU;AAAA,EAClB,EAAE,MAAM,kBAAkB;AAAA,EAC1B,EAAE,MAAM,eAAe;AAAA,EACvB,EAAE,MAAM,iBAAiB;AAAA,EACzB,EAAE,MAAM,gBAAgB;AAC1B;AAGA,SAAS,aAAa,MAAc,OAAqC;AACvE,aAAW,QAAQ,OAAO;AACxB,QAAI,SAAS,KAAK,KAAM,QAAO;AAC/B,QAAI,CAAC,KAAK,SAAS,KAAK,SAAS,IAAI,KAAK,IAAI,EAAE,EAAG,QAAO;AAAA,EAC5D;AACA,SAAO;AACT;AAGA,IAAM,iBACJ;AAEF,IAAM,mBAAmB;AA2BlB,SAAS,qBACd,SACuB;AACvB,QAAM,OAAO,QAAQ,cAAc,KAAK,EAAE,YAAY,KAAK;AAC3D,QAAM,SAAS,QAAQ,WAAW,KAAK,KAAK;AAC5C,QAAM,SAAS;AAAA,IACb,QAAQ,WAAW,KAAK,KAAK,UAAU,QAAQ,aAAa,KAAK;AAAA,EACnE;AAEA,MAAI,QAAQ,aAAc,QAAO;AACjC,MAAI,QAAQ,OAAO,KAAK,EAAG,QAAO;AAClC,MAAI,QAAQ,cAAe,UAAU,eAAe,KAAK,MAAM,GAAI;AACjE,WAAO;AAAA,EACT;AACA,MAAI,UAAU,iBAAiB,KAAK,MAAM,EAAG,QAAO;AACpD,MAAI,MAAM;AACR,QAAI,aAAa,MAAM,YAAY,EAAG,QAAO;AAC7C,QAAI,aAAa,MAAM,YAAY,EAAG,QAAO;AAAA,EAC/C;AACA,MAAI,OAAQ,QAAO;AACnB,MAAI,KAAM,QAAO;AAEjB,MAAI,QAAQ,YAAa,QAAO;AAChC,SAAO;AACT;","names":[]}
|
|
@@ -3,6 +3,36 @@ import {
|
|
|
3
3
|
TRACK_EVENTS
|
|
4
4
|
} from "./chunk-GBIGQHVN.js";
|
|
5
5
|
|
|
6
|
+
// src/consent.ts
|
|
7
|
+
var ALL_CONSENT_CATEGORIES = [
|
|
8
|
+
"analytics_storage",
|
|
9
|
+
"ad_storage",
|
|
10
|
+
"ad_user_data",
|
|
11
|
+
"ad_personalization"
|
|
12
|
+
];
|
|
13
|
+
var CONSENT_STORAGE_KEY = "rt_consent_v1";
|
|
14
|
+
var DENIED_CONSENT = {
|
|
15
|
+
analytics_storage: "denied",
|
|
16
|
+
ad_storage: "denied",
|
|
17
|
+
ad_user_data: "denied",
|
|
18
|
+
ad_personalization: "denied"
|
|
19
|
+
};
|
|
20
|
+
var DEFAULT_CONSENT = {
|
|
21
|
+
analytics_storage: "granted",
|
|
22
|
+
ad_storage: "denied",
|
|
23
|
+
ad_user_data: "denied",
|
|
24
|
+
ad_personalization: "denied"
|
|
25
|
+
};
|
|
26
|
+
var PROVIDER_CONSENT = {
|
|
27
|
+
ga4: ["analytics_storage"],
|
|
28
|
+
clarity: ["analytics_storage"],
|
|
29
|
+
naver: ["analytics_storage"],
|
|
30
|
+
meta_pixel: ["ad_storage", "ad_user_data", "ad_personalization"]
|
|
31
|
+
};
|
|
32
|
+
function isTagAllowed(required, consent) {
|
|
33
|
+
return required.every((c) => consent[c] === "granted");
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
// src/loader.ts
|
|
7
37
|
function renderConsentBootstrap() {
|
|
8
38
|
return [
|
|
@@ -10,13 +40,22 @@ function renderConsentBootstrap() {
|
|
|
10
40
|
"function gtag(){dataLayer.push(arguments);}",
|
|
11
41
|
"gtag('consent','default',{",
|
|
12
42
|
"'ad_storage':'denied','ad_user_data':'denied',",
|
|
13
|
-
"'ad_personalization':'denied','analytics_storage':'
|
|
43
|
+
"'ad_personalization':'denied','analytics_storage':'granted',",
|
|
14
44
|
"'wait_for_update':500});"
|
|
15
45
|
].join("");
|
|
16
46
|
}
|
|
17
47
|
var RUNTIME = `(function(){
|
|
18
48
|
var M=__RT_MANIFEST__;
|
|
19
49
|
var consent=Object.assign({},M.consentDefault);
|
|
50
|
+
/* \uACFC\uAC70 \uBC30\uB108\uAC00 \uC800\uC7A5\uD55C \uBA85\uC2DC\uC801 \uC120\uD0DD\uC774 \uC788\uC73C\uBA74 \uAE30\uBCF8\uAC12\uC744 \uB36E\uB294\uB2E4 \u2014 "\uAC70\uBD80"\uB97C \uB20C\uB800\uB358
|
|
51
|
+
\uBC29\uBB38\uC790\uB294 \uAE30\uBCF8\uAC12\uC774 granted \uB85C \uBC14\uB00C\uC5B4\uB3C4 \uAC70\uBD80 \uC0C1\uD0DC\uB97C \uC720\uC9C0\uD55C\uB2E4(Amend 4). */
|
|
52
|
+
try{
|
|
53
|
+
var stored=JSON.parse(localStorage.getItem(__RT_CONSENT_KEY__)||'null');
|
|
54
|
+
if(stored&&typeof stored==='object'){
|
|
55
|
+
for(var sk in stored){if(Object.prototype.hasOwnProperty.call(consent,sk))consent[sk]=stored[sk];}
|
|
56
|
+
if(typeof gtag==='function'){gtag('consent','update',stored);}
|
|
57
|
+
}
|
|
58
|
+
}catch(e){}
|
|
20
59
|
var loaded={};
|
|
21
60
|
function allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}
|
|
22
61
|
function inject(tag){
|
|
@@ -25,7 +64,18 @@ loaded[tag.provider]=true;
|
|
|
25
64
|
if(tag.provider==='ga4'){
|
|
26
65
|
var s=document.createElement('script');s.async=true;
|
|
27
66
|
s.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);
|
|
28
|
-
document.head.appendChild(s);
|
|
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);
|
|
29
79
|
}else if(tag.provider==='clarity'){
|
|
30
80
|
(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
|
31
81
|
var t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;
|
|
@@ -53,60 +103,7 @@ applyAll();
|
|
|
53
103
|
applyAll();
|
|
54
104
|
})();`;
|
|
55
105
|
function renderLoaderScript(manifest) {
|
|
56
|
-
return "var __RT_MANIFEST__=" + JSON.stringify(manifest) + ";" + RUNTIME;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// src/consent-banner.ts
|
|
60
|
-
var BANNER_ID = "rt-consent";
|
|
61
|
-
var STORAGE_KEY = "rt_consent_v1";
|
|
62
|
-
function renderConsentBannerMarkup(options = {}) {
|
|
63
|
-
const message = options.message ?? "\uC774 \uC0AC\uC774\uD2B8\uB294 ROOT-ANALYTICS\uC640 \uC678\uBD80 \uBD84\uC11D\xB7\uAD11\uACE0 \uB3C4\uAD6C\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uB3D9\uC758 \uC5EC\uBD80\uB97C \uC120\uD0DD\uD574 \uC8FC\uC138\uC694.";
|
|
64
|
-
const privacy = options.privacyUrl ? ` <a href="${escapeAttr(options.privacyUrl)}" target="_blank" rel="noopener">\uAC1C\uC778\uC815\uBCF4\uCC98\uB9AC\uBC29\uCE68</a>` : "";
|
|
65
|
-
return [
|
|
66
|
-
`<div id="${BANNER_ID}" role="dialog" aria-live="polite" aria-label="\uB3D9\uC758 \uC548\uB0B4" hidden`,
|
|
67
|
-
` style="position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,
|
|
68
|
-
`background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,
|
|
69
|
-
`display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,
|
|
70
|
-
`font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)">`,
|
|
71
|
-
`<span style="flex:1 1 280px;min-width:240px">${escapeHtml(message)}${privacy}</span>`,
|
|
72
|
-
`<button id="rt-consent-all" type="button" style="${BTN_PRIMARY}">\uBAA8\uB450 \uD5C8\uC6A9</button>`,
|
|
73
|
-
`<button id="rt-consent-analytics" type="button" style="${BTN_SECONDARY}">\uBD84\uC11D\uB9CC \uD5C8\uC6A9</button>`,
|
|
74
|
-
`<button id="rt-consent-deny" type="button" style="${BTN_GHOST}">\uAC70\uBD80</button>`,
|
|
75
|
-
`</div>`
|
|
76
|
-
].join("");
|
|
77
|
-
}
|
|
78
|
-
function renderConsentBannerScript() {
|
|
79
|
-
return `(function(){
|
|
80
|
-
var KEY=${JSON.stringify(STORAGE_KEY)};
|
|
81
|
-
var el=document.getElementById(${JSON.stringify(BANNER_ID)});
|
|
82
|
-
function apply(state){
|
|
83
|
-
if(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){
|
|
84
|
-
window.RootTaleAnalytics.updateConsent(state);
|
|
85
|
-
}}
|
|
86
|
-
function persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
|
|
87
|
-
function read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}
|
|
88
|
-
var ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};
|
|
89
|
-
var ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};
|
|
90
|
-
var DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};
|
|
91
|
-
function choose(state){apply(state);persist(state);if(el){el.hidden=true;}}
|
|
92
|
-
var prior=read();
|
|
93
|
-
if(prior){apply(prior);if(el){el.hidden=true;}}
|
|
94
|
-
else if(el){el.hidden=false;}
|
|
95
|
-
function bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}
|
|
96
|
-
bind('rt-consent-all',ALL);
|
|
97
|
-
bind('rt-consent-analytics',ANALYTICS);
|
|
98
|
-
bind('rt-consent-deny',DENY);
|
|
99
|
-
})();`;
|
|
100
|
-
}
|
|
101
|
-
var BTN_BASE = "padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;";
|
|
102
|
-
var BTN_PRIMARY = BTN_BASE + "background:#111;color:#fff;border-color:#111;";
|
|
103
|
-
var BTN_SECONDARY = BTN_BASE + "background:#f3f4f6;color:#111;";
|
|
104
|
-
var BTN_GHOST = BTN_BASE + "background:transparent;color:#6b7280;";
|
|
105
|
-
function escapeHtml(s) {
|
|
106
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
107
|
-
}
|
|
108
|
-
function escapeAttr(s) {
|
|
109
|
-
return escapeHtml(s).replace(/"/g, """);
|
|
106
|
+
return "var __RT_MANIFEST__=" + JSON.stringify(manifest) + ";var __RT_CONSENT_KEY__=" + JSON.stringify(CONSENT_STORAGE_KEY) + ";" + RUNTIME;
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
// src/ga4-bridge.ts
|
|
@@ -165,14 +162,18 @@ function readJourney() {
|
|
|
165
162
|
}
|
|
166
163
|
|
|
167
164
|
export {
|
|
165
|
+
ALL_CONSENT_CATEGORIES,
|
|
166
|
+
CONSENT_STORAGE_KEY,
|
|
167
|
+
DENIED_CONSENT,
|
|
168
|
+
DEFAULT_CONSENT,
|
|
169
|
+
PROVIDER_CONSENT,
|
|
170
|
+
isTagAllowed,
|
|
168
171
|
renderConsentBootstrap,
|
|
169
172
|
renderLoaderScript,
|
|
170
|
-
renderConsentBannerMarkup,
|
|
171
|
-
renderConsentBannerScript,
|
|
172
173
|
renderGa4BridgeScript,
|
|
173
174
|
JOURNEY_STORAGE_KEY,
|
|
174
175
|
JOURNEY_MAX_ITEMS,
|
|
175
176
|
renderJourneyBridgeScript,
|
|
176
177
|
readJourney
|
|
177
178
|
};
|
|
178
|
-
//# sourceMappingURL=chunk-
|
|
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"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 유입 경로(entry channel) 분류 — ROOT-ANALYTICS 자체 수집의 "어디서 왔나".
|
|
3
|
+
*
|
|
4
|
+
* 비콘은 신호(외부 referrer **호스트명**·utm·광고클릭 유무·rt_src·AI 라벨)만
|
|
5
|
+
* 보내고, 판정은 서버(`api-core /v1/collect`)가 이 모듈로 한다 — 판정 목록을
|
|
6
|
+
* 고치면 워커 배포만으로 전 사이트에 반영된다(고객 사이트 재배포 불필요).
|
|
7
|
+
*
|
|
8
|
+
* Privacy(ADR-0038 §2 동의 면제 유지): 입력이 이미 비식별이다 — referrer 는
|
|
9
|
+
* 경로·쿼리 없는 호스트명뿐, gclid/fbclid 는 존재 여부 불리언뿐(원값 미수신),
|
|
10
|
+
* utm 은 고객이 자기 캠페인 링크에 직접 붙인 라벨이다. 출력은 고정 사전의
|
|
11
|
+
* id 하나라 임의 문자열이 차원에 들어갈 수 없다.
|
|
12
|
+
*
|
|
13
|
+
* GA4 화면의 6묶음(search/direct/referral/social/paid/other)과 말을 맞추되,
|
|
14
|
+
* 자체 수집만 아는 두 가지를 더 가른다 — `ai`(챗봇 유입, ai-referrals.ts 와
|
|
15
|
+
* 동일 사전) · `shortlink`(roottale.link 단축링크/QR 의 rt_src).
|
|
16
|
+
*
|
|
17
|
+
* 이 파일은 브라우저 비콘 생성기·서버 양쪽에서 참조하므로 런타임 의존이
|
|
18
|
+
* 없어야 한다(ai-referrals.ts 와 동일 제약).
|
|
19
|
+
*/
|
|
20
|
+
declare const ENTRY_CHANNEL_IDS: readonly ["search", "direct", "referral", "social", "paid", "ai", "shortlink", "other"];
|
|
21
|
+
type EntryChannelId = (typeof ENTRY_CHANNEL_IDS)[number];
|
|
22
|
+
/** 수집 라우트·어드민 조회의 입력 검증 — 사전 밖 문자열 차단. */
|
|
23
|
+
declare function isEntryChannelId(value: string): value is EntryChannelId;
|
|
24
|
+
/** 고객 화면 라벨 — GA4 6묶음과 같은 말을 쓴다(두 화면이 다르게 부르지 않게). */
|
|
25
|
+
declare const ENTRY_CHANNEL_LABEL: Record<EntryChannelId, string>;
|
|
26
|
+
interface EntryChannelSignals {
|
|
27
|
+
/** 외부 referrer 호스트명(소문자, www 제거) — 같은 호스트면 비콘이 생략. */
|
|
28
|
+
readonly referrerHost?: string | null;
|
|
29
|
+
readonly utmSource?: string | null;
|
|
30
|
+
readonly utmMedium?: string | null;
|
|
31
|
+
readonly utmCampaign?: string | null;
|
|
32
|
+
/** gclid/fbclid 존재 여부 — 원값은 어디에도 없다. */
|
|
33
|
+
readonly hasAdClick?: boolean;
|
|
34
|
+
/** roottale.link 단축링크/QR 토큰(rt_src). */
|
|
35
|
+
readonly rtSrc?: string | null;
|
|
36
|
+
/** 비콘이 판정한 AI 챗봇 라벨(ai-referrals 사전 통과분). */
|
|
37
|
+
readonly aiReferralId?: string | null;
|
|
38
|
+
/** referrer 존재 여부(내부 이동 포함) — 비콘 `ref` 플래그. */
|
|
39
|
+
readonly hasReferrer?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 한 pageview 의 유입 경로 판정. **`null` = 유입이 아님**(내부 이동 — 같은
|
|
43
|
+
* 사이트 안에서 다음 페이지로 넘어온 조회). 집계에서 내부 이동을 빼야
|
|
44
|
+
* "채널별 유입"이 실제 들어온 횟수를 뜻한다.
|
|
45
|
+
*
|
|
46
|
+
* 우선순위: ai > shortlink > paid > social(utm) > search(host) >
|
|
47
|
+
* social(host) > other(utm만) > referral(그 외 외부 host) > 내부/직접.
|
|
48
|
+
* utm_medium 명시가 host 추정보다 먼저다 — 고객이 직접 붙인 라벨이 더 정확.
|
|
49
|
+
*/
|
|
50
|
+
declare function classifyEntryChannel(signals: EntryChannelSignals): EntryChannelId | null;
|
|
51
|
+
|
|
52
|
+
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, type EntryChannelId, type EntryChannelSignals, classifyEntryChannel, isEntryChannelId };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENTRY_CHANNEL_IDS,
|
|
3
|
+
ENTRY_CHANNEL_LABEL,
|
|
4
|
+
classifyEntryChannel,
|
|
5
|
+
isEntryChannelId
|
|
6
|
+
} from "./chunk-KIOFZXJX.js";
|
|
7
|
+
export {
|
|
8
|
+
ENTRY_CHANNEL_IDS,
|
|
9
|
+
ENTRY_CHANNEL_LABEL,
|
|
10
|
+
classifyEntryChannel,
|
|
11
|
+
isEntryChannelId
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=entry-channels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import { C as ConsentCategory, a as ConsentState, T as TagProvider, A as AnalyticsSiteConfig, b as AnalyticsManifest } from './
|
|
2
|
-
export { c as
|
|
1
|
+
import { C as ConsentCategory, a as ConsentState, T as TagProvider, A as AnalyticsSiteConfig, b as AnalyticsManifest } from './types-VA3xp0bt.js';
|
|
2
|
+
export { c as ConsentValue, R as ResolvedTag, d as TagConfig } from './types-VA3xp0bt.js';
|
|
3
3
|
export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, AiReferralId, AiReferralMetric, aiReferralRulesLiteral, classifyAiReferral, isAiReferralId, summarizeAiReferrals } from './ai-referrals.js';
|
|
4
|
+
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, EntryChannelId, EntryChannelSignals, classifyEntryChannel, isEntryChannelId } from './entry-channels.js';
|
|
4
5
|
export { GA4_MIRROR, TRACK_EVENTS, TrackEventDef, TrackEventName, isTrackEvent } from './events.js';
|
|
5
6
|
|
|
6
7
|
declare const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[];
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* 방문자의 명시적 동의 선택 저장 키(localStorage) — 과거 배너가 저장한 값을
|
|
10
|
+
* 로더가 계속 읽는다. **과거에 "거부"를 누른 방문자의 선택은 기본값 변경
|
|
11
|
+
* 후에도 존중된다**(기본값은 선택 기록이 없는 방문자에게만 적용).
|
|
12
|
+
*/
|
|
13
|
+
declare const CONSENT_STORAGE_KEY = "rt_consent_v1";
|
|
14
|
+
/** 전부 denied — 광고 도구 게이트·테스트 기준값으로 유지. */
|
|
8
15
|
declare const DENIED_CONSENT: ConsentState;
|
|
16
|
+
/** 기본 동의(Amend 4) — 통계 granted · 광고 denied. */
|
|
17
|
+
declare const DEFAULT_CONSENT: ConsentState;
|
|
9
18
|
/**
|
|
10
19
|
* provider 가 발화하기 전 granted 여야 하는 동의 카테고리.
|
|
11
20
|
* - analytics 도구(GA4·Clarity·Naver) → `analytics_storage`
|
|
@@ -21,22 +30,35 @@ declare function isTagAllowed(required: readonly ConsentCategory[], consent: Con
|
|
|
21
30
|
* - `enabled=false` 또는 빈 id 는 제외.
|
|
22
31
|
* - provider 당 1개 (중복은 첫 항목만).
|
|
23
32
|
* - 각 태그에 필요한 동의 카테고리(`PROVIDER_CONSENT`)를 부착.
|
|
24
|
-
* - 동의
|
|
33
|
+
* - 동의 기본값 = 통계 granted · 광고 denied (ADR-0038 Amend 4 — 광고 도구는
|
|
34
|
+
* 동의 UI 없이는 발화하지 않는다).
|
|
25
35
|
*/
|
|
26
36
|
declare function buildManifest(config: AnalyticsSiteConfig): AnalyticsManifest;
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
|
-
* Consent Mode v2 default
|
|
30
|
-
* 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다.
|
|
39
|
+
* Consent Mode v2 default 부트스트랩 — 통계 granted · 광고 denied (Amend 4).
|
|
40
|
+
* 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다. `wait_for_update` 는
|
|
41
|
+
* 로더가 저장된 과거 선택을 적용할 짧은 창.
|
|
31
42
|
*/
|
|
32
43
|
declare function renderConsentBootstrap(): string;
|
|
33
44
|
/**
|
|
34
|
-
* manifest 를 embed 한 로더 스크립트.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
45
|
+
* manifest 를 embed 한 로더 스크립트. 초기 `applyAll()` 이 동의 충족 태그
|
|
46
|
+
* (기본값에선 분석 도구)를 즉시 주입한다. 광고 도구는 `requiredConsent` 의
|
|
47
|
+
* ad_* 카테고리가 기본 denied 라 `updateConsent` 없이는 절대 붙지 않는다.
|
|
37
48
|
*/
|
|
38
49
|
declare function renderLoaderScript(manifest: AnalyticsManifest): string;
|
|
39
50
|
|
|
51
|
+
interface ConsentBannerOptions {
|
|
52
|
+
/** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */
|
|
53
|
+
privacyUrl?: string;
|
|
54
|
+
/** 본문 카피. 기본 한국어. */
|
|
55
|
+
message?: string;
|
|
56
|
+
}
|
|
57
|
+
/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */
|
|
58
|
+
declare function renderConsentBannerMarkup(options?: ConsentBannerOptions): string;
|
|
59
|
+
/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */
|
|
60
|
+
declare function renderConsentBannerScript(): string;
|
|
61
|
+
|
|
40
62
|
interface BeaconOptions {
|
|
41
63
|
/** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */
|
|
42
64
|
collectUrl: string;
|
|
@@ -113,6 +135,15 @@ interface LastTouchAttribution {
|
|
|
113
135
|
* 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.
|
|
114
136
|
* `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).
|
|
115
137
|
*
|
|
138
|
+
* 유입 경로 신호(2026-08-19) — pageview 에만 동봉, 전부 있을 때만 실린다:
|
|
139
|
+
* `rh` 외부 referrer **호스트명**(경로·쿼리 없음, www 제거) · `us`/`um`/`uc`
|
|
140
|
+
* utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재
|
|
141
|
+
* 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full
|
|
142
|
+
* locale 미전송). 판정은 서버가 한다 —
|
|
143
|
+
* `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널
|
|
144
|
+
* 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).
|
|
145
|
+
* raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).
|
|
146
|
+
*
|
|
116
147
|
* ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name="rt:content-id">` 의
|
|
117
148
|
* content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별
|
|
118
149
|
* 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.
|
|
@@ -201,4 +232,4 @@ declare global {
|
|
|
201
232
|
}
|
|
202
233
|
}
|
|
203
234
|
|
|
204
|
-
export { ALL_CONSENT_CATEGORIES, ATTRIBUTION_STORAGE_KEY, ATTRIBUTION_TTL_MS, AnalyticsManifest, AnalyticsSiteConfig, type BeaconOptions, ConsentCategory, ConsentState, DENIED_CONSENT, type FirstTouchAttribution, JOURNEY_MAX_ITEMS, JOURNEY_STORAGE_KEY, type JourneyEntry, type LastTouchAttribution, type LastTouchChannel, PROVIDER_CONSENT, TagProvider, buildManifest, initObservers, isTagAllowed, readJourney, renderBeaconScript, renderConsentBootstrap, renderGa4BridgeScript, renderJourneyBridgeScript, renderLoaderScript, teardownObservers, trackCustomEvent };
|
|
235
|
+
export { ALL_CONSENT_CATEGORIES, ATTRIBUTION_STORAGE_KEY, ATTRIBUTION_TTL_MS, AnalyticsManifest, AnalyticsSiteConfig, type BeaconOptions, CONSENT_STORAGE_KEY, type ConsentBannerOptions, ConsentCategory, ConsentState, DEFAULT_CONSENT, DENIED_CONSENT, type FirstTouchAttribution, JOURNEY_MAX_ITEMS, JOURNEY_STORAGE_KEY, type JourneyEntry, type LastTouchAttribution, type LastTouchChannel, PROVIDER_CONSENT, TagProvider, buildManifest, initObservers, isTagAllowed, readJourney, renderBeaconScript, renderConsentBannerMarkup, renderConsentBannerScript, renderConsentBootstrap, renderGa4BridgeScript, renderJourneyBridgeScript, renderLoaderScript, teardownObservers, trackCustomEvent };
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ALL_CONSENT_CATEGORIES,
|
|
3
|
+
CONSENT_STORAGE_KEY,
|
|
4
|
+
DEFAULT_CONSENT,
|
|
5
|
+
DENIED_CONSENT,
|
|
2
6
|
JOURNEY_MAX_ITEMS,
|
|
3
7
|
JOURNEY_STORAGE_KEY,
|
|
8
|
+
PROVIDER_CONSENT,
|
|
9
|
+
isTagAllowed,
|
|
4
10
|
readJourney,
|
|
5
|
-
renderConsentBannerMarkup,
|
|
6
|
-
renderConsentBannerScript,
|
|
7
11
|
renderConsentBootstrap,
|
|
8
12
|
renderGa4BridgeScript,
|
|
9
13
|
renderJourneyBridgeScript,
|
|
10
14
|
renderLoaderScript
|
|
11
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-KS5MJEOM.js";
|
|
12
16
|
import {
|
|
13
17
|
initObservers,
|
|
14
18
|
teardownObservers
|
|
15
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-GV4CWEWY.js";
|
|
16
20
|
import {
|
|
17
21
|
ATTRIBUTION_STORAGE_KEY,
|
|
18
22
|
ATTRIBUTION_TTL_MS,
|
|
19
23
|
renderBeaconScript,
|
|
20
24
|
trackCustomEvent
|
|
21
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-K3NVHH7K.js";
|
|
22
26
|
import {
|
|
23
27
|
GA4_MIRROR,
|
|
24
28
|
TRACK_EVENTS,
|
|
@@ -32,29 +36,12 @@ import {
|
|
|
32
36
|
isAiReferralId,
|
|
33
37
|
summarizeAiReferrals
|
|
34
38
|
} from "./chunk-6NWJK7V7.js";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"ad_personalization"
|
|
42
|
-
];
|
|
43
|
-
var DENIED_CONSENT = {
|
|
44
|
-
analytics_storage: "denied",
|
|
45
|
-
ad_storage: "denied",
|
|
46
|
-
ad_user_data: "denied",
|
|
47
|
-
ad_personalization: "denied"
|
|
48
|
-
};
|
|
49
|
-
var PROVIDER_CONSENT = {
|
|
50
|
-
ga4: ["analytics_storage"],
|
|
51
|
-
clarity: ["analytics_storage"],
|
|
52
|
-
naver: ["analytics_storage"],
|
|
53
|
-
meta_pixel: ["ad_storage", "ad_user_data", "ad_personalization"]
|
|
54
|
-
};
|
|
55
|
-
function isTagAllowed(required, consent) {
|
|
56
|
-
return required.every((c) => consent[c] === "granted");
|
|
57
|
-
}
|
|
39
|
+
import {
|
|
40
|
+
ENTRY_CHANNEL_IDS,
|
|
41
|
+
ENTRY_CHANNEL_LABEL,
|
|
42
|
+
classifyEntryChannel,
|
|
43
|
+
isEntryChannelId
|
|
44
|
+
} from "./chunk-KIOFZXJX.js";
|
|
58
45
|
|
|
59
46
|
// src/manifest.ts
|
|
60
47
|
function buildManifest(config) {
|
|
@@ -73,16 +60,73 @@ function buildManifest(config) {
|
|
|
73
60
|
return {
|
|
74
61
|
siteId: config.siteId,
|
|
75
62
|
tags,
|
|
76
|
-
consentDefault: { ...
|
|
63
|
+
consentDefault: { ...DEFAULT_CONSENT }
|
|
77
64
|
};
|
|
78
65
|
}
|
|
66
|
+
|
|
67
|
+
// src/consent-banner.ts
|
|
68
|
+
var BANNER_ID = "rt-consent";
|
|
69
|
+
var STORAGE_KEY = CONSENT_STORAGE_KEY;
|
|
70
|
+
function renderConsentBannerMarkup(options = {}) {
|
|
71
|
+
const message = options.message ?? "\uC774 \uC0AC\uC774\uD2B8\uB294 ROOT-ANALYTICS\uC640 \uC678\uBD80 \uBD84\uC11D\xB7\uAD11\uACE0 \uB3C4\uAD6C\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uB3D9\uC758 \uC5EC\uBD80\uB97C \uC120\uD0DD\uD574 \uC8FC\uC138\uC694.";
|
|
72
|
+
const privacy = options.privacyUrl ? ` <a href="${escapeAttr(options.privacyUrl)}" target="_blank" rel="noopener">\uAC1C\uC778\uC815\uBCF4\uCC98\uB9AC\uBC29\uCE68</a>` : "";
|
|
73
|
+
return [
|
|
74
|
+
`<div id="${BANNER_ID}" role="dialog" aria-live="polite" aria-label="\uB3D9\uC758 \uC548\uB0B4" hidden`,
|
|
75
|
+
` style="position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,
|
|
76
|
+
`background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,
|
|
77
|
+
`display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,
|
|
78
|
+
`font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)">`,
|
|
79
|
+
`<span style="flex:1 1 280px;min-width:240px">${escapeHtml(message)}${privacy}</span>`,
|
|
80
|
+
`<button id="rt-consent-all" type="button" style="${BTN_PRIMARY}">\uBAA8\uB450 \uD5C8\uC6A9</button>`,
|
|
81
|
+
`<button id="rt-consent-analytics" type="button" style="${BTN_SECONDARY}">\uBD84\uC11D\uB9CC \uD5C8\uC6A9</button>`,
|
|
82
|
+
`<button id="rt-consent-deny" type="button" style="${BTN_GHOST}">\uAC70\uBD80</button>`,
|
|
83
|
+
`</div>`
|
|
84
|
+
].join("");
|
|
85
|
+
}
|
|
86
|
+
function renderConsentBannerScript() {
|
|
87
|
+
return `(function(){
|
|
88
|
+
var KEY=${JSON.stringify(STORAGE_KEY)};
|
|
89
|
+
var el=document.getElementById(${JSON.stringify(BANNER_ID)});
|
|
90
|
+
function apply(state){
|
|
91
|
+
if(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){
|
|
92
|
+
window.RootTaleAnalytics.updateConsent(state);
|
|
93
|
+
}}
|
|
94
|
+
function persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
|
|
95
|
+
function read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}
|
|
96
|
+
var ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};
|
|
97
|
+
var ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};
|
|
98
|
+
var DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};
|
|
99
|
+
function choose(state){apply(state);persist(state);if(el){el.hidden=true;}}
|
|
100
|
+
var prior=read();
|
|
101
|
+
if(prior){apply(prior);if(el){el.hidden=true;}}
|
|
102
|
+
else if(el){el.hidden=false;}
|
|
103
|
+
function bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}
|
|
104
|
+
bind('rt-consent-all',ALL);
|
|
105
|
+
bind('rt-consent-analytics',ANALYTICS);
|
|
106
|
+
bind('rt-consent-deny',DENY);
|
|
107
|
+
})();`;
|
|
108
|
+
}
|
|
109
|
+
var BTN_BASE = "padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;";
|
|
110
|
+
var BTN_PRIMARY = BTN_BASE + "background:#111;color:#fff;border-color:#111;";
|
|
111
|
+
var BTN_SECONDARY = BTN_BASE + "background:#f3f4f6;color:#111;";
|
|
112
|
+
var BTN_GHOST = BTN_BASE + "background:transparent;color:#6b7280;";
|
|
113
|
+
function escapeHtml(s) {
|
|
114
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
115
|
+
}
|
|
116
|
+
function escapeAttr(s) {
|
|
117
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
118
|
+
}
|
|
79
119
|
export {
|
|
80
120
|
AI_REFERRAL_IDS,
|
|
81
121
|
AI_REFERRAL_LABEL,
|
|
82
122
|
ALL_CONSENT_CATEGORIES,
|
|
83
123
|
ATTRIBUTION_STORAGE_KEY,
|
|
84
124
|
ATTRIBUTION_TTL_MS,
|
|
125
|
+
CONSENT_STORAGE_KEY,
|
|
126
|
+
DEFAULT_CONSENT,
|
|
85
127
|
DENIED_CONSENT,
|
|
128
|
+
ENTRY_CHANNEL_IDS,
|
|
129
|
+
ENTRY_CHANNEL_LABEL,
|
|
86
130
|
GA4_MIRROR,
|
|
87
131
|
JOURNEY_MAX_ITEMS,
|
|
88
132
|
JOURNEY_STORAGE_KEY,
|
|
@@ -91,8 +135,10 @@ export {
|
|
|
91
135
|
aiReferralRulesLiteral,
|
|
92
136
|
buildManifest,
|
|
93
137
|
classifyAiReferral,
|
|
138
|
+
classifyEntryChannel,
|
|
94
139
|
initObservers,
|
|
95
140
|
isAiReferralId,
|
|
141
|
+
isEntryChannelId,
|
|
96
142
|
isTagAllowed,
|
|
97
143
|
isTrackEvent,
|
|
98
144
|
readJourney,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/
|
|
1
|
+
{"version":3,"sources":["../src/manifest.ts","../src/consent-banner.ts"],"sourcesContent":["// ADR-0038 §1 — site 태그 구성 → manifest (순수 함수).\n\nimport { DEFAULT_CONSENT, PROVIDER_CONSENT } from \"./consent\";\nimport type { AnalyticsManifest, AnalyticsSiteConfig } from \"./types\";\n\n/**\n * site 태그 구성을 manifest 로 해석.\n * - `enabled=false` 또는 빈 id 는 제외.\n * - provider 당 1개 (중복은 첫 항목만).\n * - 각 태그에 필요한 동의 카테고리(`PROVIDER_CONSENT`)를 부착.\n * - 동의 기본값 = 통계 granted · 광고 denied (ADR-0038 Amend 4 — 광고 도구는\n * 동의 UI 없이는 발화하지 않는다).\n */\nexport function buildManifest(config: AnalyticsSiteConfig): AnalyticsManifest {\n const seen = new Set<string>();\n const tags = config.tags\n .filter(\n (t) => t.enabled && typeof t.id === \"string\" && t.id.trim() !== \"\",\n )\n .filter((t) => {\n if (seen.has(t.provider)) return false;\n seen.add(t.provider);\n return true;\n })\n .map((t) => ({\n provider: t.provider,\n id: t.id.trim(),\n requiredConsent: PROVIDER_CONSENT[t.provider],\n }));\n\n return {\n siteId: config.siteId,\n tags,\n consentDefault: { ...DEFAULT_CONSENT },\n };\n}\n","// ADR-0038 §2 — 동의 배너. **Amend 4(2026-08-19)부터 기본 미렌더** —\n// 통계는 기본 허용이라 배너가 필요 없고, 광고 도구는 기본 denied 로 잠겨\n// 있다. 광고 연동을 도입하는 날 이 모듈을 다시 렌더러에 물린다(그때까지\n// 광고 태그는 발화 불가). 과거 배너가 저장한 선택은 loader.ts 가 계속 읽어\n// 존중한다(CONSENT_STORAGE_KEY 공유).\n//\n// 주의: `set:html`/`dangerouslySetInnerHTML` 로 주입된 <script> 는 실행되지\n// 않는다. 본 모듈은 *마크업* 과 *JS 본문* 을 분리 반환하고, 렌더러가 JS 는\n// <script is:inline> 로, 마크업은 컨테이너로 주입해야 한다.\n//\n// 마크업/스크립트가 공유하는 DOM id (계약):\n// #rt-consent · #rt-consent-all · #rt-consent-analytics · #rt-consent-deny\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\n\nexport interface ConsentBannerOptions {\n /** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */\n privacyUrl?: string;\n /** 본문 카피. 기본 한국어. */\n message?: string;\n}\n\nconst BANNER_ID = \"rt-consent\";\nconst STORAGE_KEY = CONSENT_STORAGE_KEY;\n\n/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */\nexport function renderConsentBannerMarkup(\n options: ConsentBannerOptions = {},\n): string {\n const message =\n options.message ??\n \"이 사이트는 ROOT-ANALYTICS와 외부 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\n const privacy = options.privacyUrl\n ? ` <a href=\"${escapeAttr(options.privacyUrl)}\" target=\"_blank\" rel=\"noopener\">개인정보처리방침</a>`\n : \"\";\n\n return [\n `<div id=\"${BANNER_ID}\" role=\"dialog\" aria-live=\"polite\" aria-label=\"동의 안내\" hidden`,\n ` style=\"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,\n `background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,\n `display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,\n `font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)\">`,\n `<span style=\"flex:1 1 280px;min-width:240px\">${escapeHtml(message)}${privacy}</span>`,\n `<button id=\"rt-consent-all\" type=\"button\" style=\"${BTN_PRIMARY}\">모두 허용</button>`,\n `<button id=\"rt-consent-analytics\" type=\"button\" style=\"${BTN_SECONDARY}\">분석만 허용</button>`,\n `<button id=\"rt-consent-deny\" type=\"button\" style=\"${BTN_GHOST}\">거부</button>`,\n `</div>`,\n ].join(\"\");\n}\n\n/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */\nexport function renderConsentBannerScript(): string {\n return `(function(){\nvar KEY=${JSON.stringify(STORAGE_KEY)};\nvar el=document.getElementById(${JSON.stringify(BANNER_ID)});\nfunction apply(state){\nif(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){\nwindow.RootTaleAnalytics.updateConsent(state);\n}}\nfunction persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}\nfunction read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}\nvar ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};\nvar ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nvar DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nfunction choose(state){apply(state);persist(state);if(el){el.hidden=true;}}\nvar prior=read();\nif(prior){apply(prior);if(el){el.hidden=true;}}\nelse if(el){el.hidden=false;}\nfunction bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}\nbind('rt-consent-all',ALL);\nbind('rt-consent-analytics',ANALYTICS);\nbind('rt-consent-deny',DENY);\n})();`;\n}\n\nconst BTN_BASE =\n \"padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;\";\nconst BTN_PRIMARY = BTN_BASE + \"background:#111;color:#fff;border-color:#111;\";\nconst BTN_SECONDARY = BTN_BASE + \"background:#f3f4f6;color:#111;\";\nconst BTN_GHOST = BTN_BASE + \"background:transparent;color:#6b7280;\";\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \""\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAaO,SAAS,cAAc,QAAgD;AAC5E,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,OAAO,OAAO,KACjB;AAAA,IACC,CAAC,MAAM,EAAE,WAAW,OAAO,EAAE,OAAO,YAAY,EAAE,GAAG,KAAK,MAAM;AAAA,EAClE,EACC,OAAO,CAAC,MAAM;AACb,QAAI,KAAK,IAAI,EAAE,QAAQ,EAAG,QAAO;AACjC,SAAK,IAAI,EAAE,QAAQ;AACnB,WAAO;AAAA,EACT,CAAC,EACA,IAAI,CAAC,OAAO;AAAA,IACX,UAAU,EAAE;AAAA,IACZ,IAAI,EAAE,GAAG,KAAK;AAAA,IACd,iBAAiB,iBAAiB,EAAE,QAAQ;AAAA,EAC9C,EAAE;AAEJ,SAAO;AAAA,IACL,QAAQ,OAAO;AAAA,IACf;AAAA,IACA,gBAAgB,EAAE,GAAG,gBAAgB;AAAA,EACvC;AACF;;;ACbA,IAAM,YAAY;AAClB,IAAM,cAAc;AAGb,SAAS,0BACd,UAAgC,CAAC,GACzB;AACR,QAAM,UACJ,QAAQ,WACR;AACF,QAAM,UAAU,QAAQ,aACpB,aAAa,WAAW,QAAQ,UAAU,CAAC,0FAC3C;AAEJ,SAAO;AAAA,IACL,YAAY,SAAS;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,gDAAgD,WAAW,OAAO,CAAC,GAAG,OAAO;AAAA,IAC7E,oDAAoD,WAAW;AAAA,IAC/D,0DAA0D,aAAa;AAAA,IACvE,qDAAqD,SAAS;AAAA,IAC9D;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAGO,SAAS,4BAAoC;AAClD,SAAO;AAAA,UACC,KAAK,UAAU,WAAW,CAAC;AAAA,iCACJ,KAAK,UAAU,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmB1D;AAEA,IAAM,WACJ;AACF,IAAM,cAAc,WAAW;AAC/B,IAAM,gBAAgB,WAAW;AACjC,IAAM,YAAY,WAAW;AAE7B,SAAS,WAAW,GAAmB;AACrC,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,WAAW,GAAmB;AACrC,SAAO,WAAW,CAAC,EAAE,QAAQ,MAAM,QAAQ;AAC7C;","names":[]}
|
package/dist/next.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
initObservers,
|
|
4
4
|
teardownObservers
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-GV4CWEWY.js";
|
|
6
6
|
import {
|
|
7
7
|
trackCustomEvent
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-K3NVHH7K.js";
|
|
9
9
|
import "./chunk-6NWJK7V7.js";
|
|
10
10
|
|
|
11
11
|
// src/next.tsx
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import { b as AnalyticsManifest
|
|
2
|
+
import { b as AnalyticsManifest } from './types-VA3xp0bt.js';
|
|
3
3
|
|
|
4
4
|
interface RootTaleAnalyticsProps {
|
|
5
5
|
/** buildManifest() 결과. plane ① 태그 + fail-closed 동의 기본값. */
|
|
@@ -8,12 +8,8 @@ interface RootTaleAnalyticsProps {
|
|
|
8
8
|
collectUrl?: string;
|
|
9
9
|
/** 비콘용 site 식별자. collectUrl 과 함께 제공. */
|
|
10
10
|
siteId?: string;
|
|
11
|
-
/** 동의 배너 개인정보처리방침 링크. */
|
|
12
|
-
privacyUrl?: string;
|
|
13
11
|
/** 비콘 pageview 자동 전송 (기본 true). */
|
|
14
12
|
trackPageview?: boolean;
|
|
15
|
-
/** 동의 배너 카피 override. */
|
|
16
|
-
bannerOptions?: ConsentBannerOptions;
|
|
17
13
|
/**
|
|
18
14
|
* GA4 미러 브리지 스크립트 주입 여부(설계 §0, 기본 true). beacon 이 없으면
|
|
19
15
|
* (collectUrl/siteId 미설정) 어차피 미주입 — 이벤트를 흘려보낼 send() 자체가
|
|
@@ -34,6 +30,6 @@ interface RootTaleAnalyticsProps {
|
|
|
34
30
|
* 단일 드롭인 컴포넌트. RSC 호환(hook/상태 없음) — Server Component layout 에
|
|
35
31
|
* 바로 배치 가능.
|
|
36
32
|
*/
|
|
37
|
-
declare function RootTaleAnalytics({ manifest, collectUrl, siteId,
|
|
33
|
+
declare function RootTaleAnalytics({ manifest, collectUrl, siteId, trackPageview, enableGa4Bridge, enableObservers, }: RootTaleAnalyticsProps): react_jsx_runtime.JSX.Element;
|
|
38
34
|
|
|
39
35
|
export { RootTaleAnalytics, type RootTaleAnalyticsProps };
|
package/dist/react.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
renderConsentBannerMarkup,
|
|
3
|
-
renderConsentBannerScript,
|
|
4
2
|
renderConsentBootstrap,
|
|
5
3
|
renderGa4BridgeScript,
|
|
6
4
|
renderJourneyBridgeScript,
|
|
7
5
|
renderLoaderScript
|
|
8
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-KS5MJEOM.js";
|
|
9
7
|
import {
|
|
10
8
|
renderBeaconScript
|
|
11
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-K3NVHH7K.js";
|
|
12
10
|
import "./chunk-GBIGQHVN.js";
|
|
13
11
|
import "./chunk-6NWJK7V7.js";
|
|
14
12
|
|
|
@@ -18,17 +16,11 @@ function RootTaleAnalytics({
|
|
|
18
16
|
manifest,
|
|
19
17
|
collectUrl,
|
|
20
18
|
siteId,
|
|
21
|
-
privacyUrl,
|
|
22
19
|
trackPageview,
|
|
23
|
-
bannerOptions,
|
|
24
20
|
enableGa4Bridge = true,
|
|
25
21
|
enableObservers = true
|
|
26
22
|
}) {
|
|
27
23
|
const beacon = collectUrl && siteId ? renderBeaconScript({ collectUrl, siteId, trackPageview }) : null;
|
|
28
|
-
const bannerOpts = {
|
|
29
|
-
privacyUrl,
|
|
30
|
-
...bannerOptions
|
|
31
|
-
};
|
|
32
24
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
33
25
|
/* @__PURE__ */ jsx(
|
|
34
26
|
"script",
|
|
@@ -42,21 +34,6 @@ function RootTaleAnalytics({
|
|
|
42
34
|
dangerouslySetInnerHTML: { __html: renderLoaderScript(manifest) }
|
|
43
35
|
}
|
|
44
36
|
),
|
|
45
|
-
/* @__PURE__ */ jsx(
|
|
46
|
-
"div",
|
|
47
|
-
{
|
|
48
|
-
suppressHydrationWarning: true,
|
|
49
|
-
dangerouslySetInnerHTML: {
|
|
50
|
-
__html: renderConsentBannerMarkup(bannerOpts)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
),
|
|
54
|
-
/* @__PURE__ */ jsx(
|
|
55
|
-
"script",
|
|
56
|
-
{
|
|
57
|
-
dangerouslySetInnerHTML: { __html: renderConsentBannerScript() }
|
|
58
|
-
}
|
|
59
|
-
),
|
|
60
37
|
beacon ? /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: beacon } }) : null,
|
|
61
38
|
beacon ? /* @__PURE__ */ jsx(
|
|
62
39
|
"script",
|
package/dist/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react.tsx"],"sourcesContent":["// ADR-0038 — Next.js/React 드롭인 어댑터.\n//\n// `@roottale/analytics-runtime/react` 로 import. core 는 React-free 유지하고\n// 본 어댑터만 react(peerDependency)에 의존한다.\n//\n// 사용 (Next App Router root layout):\n// import { RootTaleAnalytics } from \"@roottale/analytics-runtime/react\";\n// ...\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// </body>\n//\n// 모든 <script> 는 dangerouslySetInnerHTML 인라인 — Next 가 실제 실행되는\n// <script> 로 렌더(set:html innerHTML 함정 회피). 순서: consent bootstrap
|
|
1
|
+
{"version":3,"sources":["../src/react.tsx"],"sourcesContent":["// ADR-0038 — Next.js/React 드롭인 어댑터.\n//\n// `@roottale/analytics-runtime/react` 로 import. core 는 React-free 유지하고\n// 본 어댑터만 react(peerDependency)에 의존한다.\n//\n// 사용 (Next App Router root layout):\n// import { RootTaleAnalytics } from \"@roottale/analytics-runtime/react\";\n// ...\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// </body>\n//\n// 모든 <script> 는 dangerouslySetInnerHTML 인라인 — Next 가 실제 실행되는\n// <script> 로 렌더(set:html innerHTML 함정 회피). 순서: consent bootstrap\n// (통계 granted·광고 denied, ADR-0038 Amend 4) → loader → 비콘.\n//\n// 동의 배너는 렌더하지 않는다(Amend 4) — 통계는 한국법상 옵트인 대상이\n// 아니고, 광고 도구는 기본 denied 라 동의 UI 없이는 발화 자체가 불가하다.\n// 광고 연동을 도입하는 날이 오면 그때 consent-banner.ts 를 다시 물린다.\n\nimport type { AnalyticsManifest } from \"./types\";\nimport { renderConsentBootstrap, renderLoaderScript } from \"./loader\";\nimport { renderBeaconScript } from \"./beacon\";\nimport { renderJourneyBridgeScript } from \"./journey\";\nimport { renderGa4BridgeScript } from \"./ga4-bridge\";\n\nexport interface RootTaleAnalyticsProps {\n /** buildManifest() 결과. plane ① 태그 + fail-closed 동의 기본값. */\n manifest: AnalyticsManifest;\n /** plane ② 비콘 수집 엔드포인트. 없으면 비콘 미주입. */\n collectUrl?: string;\n /** 비콘용 site 식별자. collectUrl 과 함께 제공. */\n siteId?: string;\n /** 비콘 pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /**\n * GA4 미러 브리지 스크립트 주입 여부(설계 §0, 기본 true). beacon 이 없으면\n * (collectUrl/siteId 미설정) 어차피 미주입 — 이벤트를 흘려보낼 send() 자체가\n * 없기 때문.\n */\n enableGa4Bridge?: boolean;\n /**\n * 방문 여정(observers.ts) 감지기 활성 여부(설계 §1 2단계, 기본 true). 본\n * 컴포넌트는 RSC 호환(hook 없음)이라 IntersectionObserver 등을 직접\n * 구동하지 못한다 — `false` 로 지정하면 `window.__rtObserversEnabled=false`\n * 를 심어 `RootTaleAnalyticsNextAdapter`(next.tsx)가 initObservers() 호출\n * 전 이 값을 읽고 건너뛰게 한다. `true`(기본)는 아무 것도 심지 않음(next.tsx\n * 의 기본값이 이미 활성이므로 무동작이 곧 활성).\n */\n enableObservers?: boolean;\n}\n\n/**\n * 단일 드롭인 컴포넌트. RSC 호환(hook/상태 없음) — Server Component layout 에\n * 바로 배치 가능.\n */\nexport function RootTaleAnalytics({\n manifest,\n collectUrl,\n siteId,\n trackPageview,\n enableGa4Bridge = true,\n enableObservers = true,\n}: RootTaleAnalyticsProps) {\n const beacon =\n collectUrl && siteId\n ? renderBeaconScript({ collectUrl, siteId, trackPageview })\n : null;\n\n return (\n <>\n <script\n dangerouslySetInnerHTML={{ __html: renderConsentBootstrap() }}\n />\n <script\n dangerouslySetInnerHTML={{ __html: renderLoaderScript(manifest) }}\n />\n {beacon ? (\n <script dangerouslySetInnerHTML={{ __html: beacon }} />\n ) : null}\n {/*\n journey 브리지는 beacon 이 있을 때만 의미가 있다(훅 대상인\n window.__rtTrack/send() 자체가 beacon 스크립트가 심는다) — opt-out\n 프롭 없음(1차 캡처 인프라, 설계 §3).\n */}\n {beacon ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderJourneyBridgeScript() }}\n />\n ) : null}\n {beacon && enableGa4Bridge ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderGa4BridgeScript() }}\n />\n ) : null}\n {enableObservers === false ? (\n <script\n dangerouslySetInnerHTML={{\n __html: \"window.__rtObserversEnabled=false;\",\n }}\n />\n ) : null}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;;AAuEI,mBACE,KADF;AAdG,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,kBAAkB;AACpB,GAA2B;AACzB,QAAM,SACJ,cAAc,SACV,mBAAmB,EAAE,YAAY,QAAQ,cAAc,CAAC,IACxD;AAEN,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,uBAAuB,EAAE;AAAA;AAAA,IAC9D;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,mBAAmB,QAAQ,EAAE;AAAA;AAAA,IAClE;AAAA,IACC,SACC,oBAAC,YAAO,yBAAyB,EAAE,QAAQ,OAAO,GAAG,IACnD;AAAA,IAMH,SACC;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,0BAA0B,EAAE;AAAA;AAAA,IACjE,IACE;AAAA,IACH,UAAU,kBACT;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,sBAAsB,EAAE;AAAA;AAAA,IAC7D,IACE;AAAA,IACH,oBAAoB,QACnB;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB;AAAA,UACvB,QAAQ;AAAA,QACV;AAAA;AAAA,IACF,IACE;AAAA,KACN;AAEJ;","names":[]}
|
|
@@ -39,15 +39,4 @@ interface AnalyticsManifest {
|
|
|
39
39
|
consentDefault: ConsentState;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
/** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */
|
|
44
|
-
privacyUrl?: string;
|
|
45
|
-
/** 본문 카피. 기본 한국어. */
|
|
46
|
-
message?: string;
|
|
47
|
-
}
|
|
48
|
-
/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */
|
|
49
|
-
declare function renderConsentBannerMarkup(options?: ConsentBannerOptions): string;
|
|
50
|
-
/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */
|
|
51
|
-
declare function renderConsentBannerScript(): string;
|
|
52
|
-
|
|
53
|
-
export { type AnalyticsSiteConfig as A, type ConsentCategory as C, type ResolvedTag as R, type TagProvider as T, type ConsentState as a, type AnalyticsManifest as b, type ConsentBannerOptions as c, type ConsentValue as d, type TagConfig as e, renderConsentBannerScript as f, renderConsentBannerMarkup as r };
|
|
42
|
+
export type { AnalyticsSiteConfig as A, ConsentCategory as C, ResolvedTag as R, TagProvider as T, ConsentState as a, AnalyticsManifest as b, ConsentValue as c, TagConfig as d };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/analytics-runtime",
|
|
3
|
-
"version": "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",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"import": "./dist/ai-referrals.js",
|
|
32
32
|
"default": "./dist/ai-referrals.js"
|
|
33
33
|
},
|
|
34
|
+
"./entry-channels": {
|
|
35
|
+
"types": "./dist/entry-channels.d.ts",
|
|
36
|
+
"import": "./dist/entry-channels.js",
|
|
37
|
+
"default": "./dist/entry-channels.js"
|
|
38
|
+
},
|
|
34
39
|
"./package.json": "./package.json"
|
|
35
40
|
},
|
|
36
41
|
"files": [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/loader.ts","../src/consent-banner.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2·§4 — 공유 주입 산출물. Astro·Next 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default = 전부\n// denied (fail-closed). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 태그는 동의\n// update 전까지 발화하지 않음. 동의 배너가 `RootTaleAnalytics.updateConsent`\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 type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩. 전 카테고리 denied + `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':'denied',\",\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);\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 한 로더 스크립트. fail-closed — 기본 동의가 denied 라\n * `applyAll()` 은 아무 태그도 주입하지 않고, 동의 배너의 `updateConsent` 후에만\n * 허용된 태그가 붙는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return \"var __RT_MANIFEST__=\" + JSON.stringify(manifest) + \";\" + RUNTIME;\n}\n","// ADR-0038 §2 — 동의 배너. plane ① 외부 트래커는 동의 전까지 미발화(fail-closed)\n// 이므로, 배너가 방문자 선택을 받아 RootTaleAnalytics.updateConsent 를 호출한다.\n//\n// 주의: `set:html`/`dangerouslySetInnerHTML` 로 주입된 <script> 는 실행되지\n// 않는다. 본 모듈은 *마크업* 과 *JS 본문* 을 분리 반환하고, 렌더러가 JS 는\n// <script is:inline> 로, 마크업은 컨테이너로 주입해야 한다.\n//\n// 마크업/스크립트가 공유하는 DOM id (계약):\n// #rt-consent · #rt-consent-all · #rt-consent-analytics · #rt-consent-deny\n\nexport interface ConsentBannerOptions {\n /** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */\n privacyUrl?: string;\n /** 본문 카피. 기본 한국어. */\n message?: string;\n}\n\nconst BANNER_ID = \"rt-consent\";\nconst STORAGE_KEY = \"rt_consent_v1\";\n\n/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */\nexport function renderConsentBannerMarkup(\n options: ConsentBannerOptions = {},\n): string {\n const message =\n options.message ??\n \"이 사이트는 ROOT-ANALYTICS와 외부 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\n const privacy = options.privacyUrl\n ? ` <a href=\"${escapeAttr(options.privacyUrl)}\" target=\"_blank\" rel=\"noopener\">개인정보처리방침</a>`\n : \"\";\n\n return [\n `<div id=\"${BANNER_ID}\" role=\"dialog\" aria-live=\"polite\" aria-label=\"동의 안내\" hidden`,\n ` style=\"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,\n `background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,\n `display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,\n `font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)\">`,\n `<span style=\"flex:1 1 280px;min-width:240px\">${escapeHtml(message)}${privacy}</span>`,\n `<button id=\"rt-consent-all\" type=\"button\" style=\"${BTN_PRIMARY}\">모두 허용</button>`,\n `<button id=\"rt-consent-analytics\" type=\"button\" style=\"${BTN_SECONDARY}\">분석만 허용</button>`,\n `<button id=\"rt-consent-deny\" type=\"button\" style=\"${BTN_GHOST}\">거부</button>`,\n `</div>`,\n ].join(\"\");\n}\n\n/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */\nexport function renderConsentBannerScript(): string {\n return `(function(){\nvar KEY=${JSON.stringify(STORAGE_KEY)};\nvar el=document.getElementById(${JSON.stringify(BANNER_ID)});\nfunction apply(state){\nif(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){\nwindow.RootTaleAnalytics.updateConsent(state);\n}}\nfunction persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}\nfunction read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}\nvar ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};\nvar ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nvar DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nfunction choose(state){apply(state);persist(state);if(el){el.hidden=true;}}\nvar prior=read();\nif(prior){apply(prior);if(el){el.hidden=true;}}\nelse if(el){el.hidden=false;}\nfunction bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}\nbind('rt-consent-all',ALL);\nbind('rt-consent-analytics',ANALYTICS);\nbind('rt-consent-deny',DENY);\n})();`;\n}\n\nconst BTN_BASE =\n \"padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;\";\nconst BTN_PRIMARY = BTN_BASE + \"background:#111;color:#fff;border-color:#111;\";\nconst BTN_SECONDARY = BTN_BASE + \"background:#f3f4f6;color:#111;\";\nconst BTN_GHOST = BTN_BASE + \"background:transparent;color:#6b7280;\";\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \""\");\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":";;;;;;AAoBO,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;AA4CT,SAAS,mBAAmB,UAAqC;AACtE,SAAO,yBAAyB,KAAK,UAAU,QAAQ,IAAI,MAAM;AACnE;;;AC9DA,IAAM,YAAY;AAClB,IAAM,cAAc;AAGb,SAAS,0BACd,UAAgC,CAAC,GACzB;AACR,QAAM,UACJ,QAAQ,WACR;AACF,QAAM,UAAU,QAAQ,aACpB,aAAa,WAAW,QAAQ,UAAU,CAAC,0FAC3C;AAEJ,SAAO;AAAA,IACL,YAAY,SAAS;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,gDAAgD,WAAW,OAAO,CAAC,GAAG,OAAO;AAAA,IAC7E,oDAAoD,WAAW;AAAA,IAC/D,0DAA0D,aAAa;AAAA,IACvE,qDAAqD,SAAS;AAAA,IAC9D;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAGO,SAAS,4BAAoC;AAClD,SAAO;AAAA,UACC,KAAK,UAAU,WAAW,CAAC;AAAA,iCACJ,KAAK,UAAU,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmB1D;AAEA,IAAM,WACJ;AACF,IAAM,cAAc,WAAW;AAC/B,IAAM,gBAAgB,WAAW;AACjC,IAAM,YAAY,WAAW;AAE7B,SAAS,WAAW,GAAmB;AACrC,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,WAAW,GAAmB;AACrC,SAAO,WAAW,CAAC,EAAE,QAAQ,MAAM,QAAQ;AAC7C;;;ACrEA,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"]}
|
|
File without changes
|